lib/mix/tasks/graft.validate.ex

defmodule Mix.Tasks.Graft.Validate do
  @shortdoc "Validate workspace health or run target build/test checks"

  @moduledoc """
  Run `mix deps.get → mix compile --warnings-as-errors → mix test` across
  the target's transitive consumer closure, in topological dependency
  order. Answers a single question:

      did my cross-repo change actually work?

  ## Usage

      mix graft.validate req_llm                # run; text output; fail-fast
      mix graft.validate req_llm --dry-run      # show the plan only
      mix graft.validate req_llm --json         # JSONL output for agents
      mix graft.validate req_llm --continue     # run every repo even after a failure
      mix graft.validate --quick                # manifest/workspace health only

  ## Trust contract

    * `passed?: true` means every command in every affected repo ran
      and exited 0. Skipped commands never count as passed.
    * `first_failure` is the earliest topological failure. One place
      to look first.
    * Skipped ≠ failed. Fail-fast aborts downstream repos; they are
      reported as skipped, not failed.
    * Exit code is 0 iff `passed? == true`.

  No git, no `deps.unlock`, no parallelism, no retry, no daemon.
  """

  use Mix.Task

  alias Graft.{Error, Workspace}
  alias Graft.CLI.Errors
  alias Graft.Validate.{Plan, Runner}
  alias Graft.Validate.Plan.Render

  @switches [
    json: :boolean,
    dry_run: :boolean,
    root: :string,
    continue: :boolean,
    quick: :boolean
  ]

  @impl Mix.Task
  def run(argv) do
    case execute(argv) do
      {:ok, output} ->
        Mix.shell().info(output)

      {:fail, output, :stdout} ->
        Mix.shell().info(output)
        exit({:shutdown, 1})

      {:fail, output, :stderr} ->
        Mix.shell().error(output)
        exit({:shutdown, 1})
    end
  end

  @doc false
  def execute(argv) do
    case parse_args(argv) do
      {:ok, opts, target_strings} ->
        format = if opts[:json], do: :jsonl, else: :text

        cond do
          opts[:quick] ->
            do_quick_execute(opts, target_strings)

          target_strings == [] ->
            {:fail, "graft.validate: at least one target app is required", :stderr}

          true ->
            do_execute(opts, target_strings, format)
        end

      {:error, msg} ->
        {:fail, "graft.validate: #{msg}", :stderr}
    end
  end

  defp parse_args(argv) do
    {opts, positional} = OptionParser.parse!(argv, strict: @switches)
    {:ok, opts, positional}
  rescue
    e in OptionParser.ParseError -> {:error, Exception.message(e)}
  end

  defp do_execute(opts, target_strings, format) do
    root = opts[:root] || File.cwd!()
    dry_run? = Keyword.get(opts, :dry_run, false)
    fail_fast? = not Keyword.get(opts, :continue, false)

    with {:ok, snapshot} <- Workspace.snapshot(root),
         {:ok, targets} <- resolve_targets(target_strings, snapshot),
         {:ok, plan} <- Plan.build(snapshot, targets) do
      cond do
        dry_run? ->
          {:ok, Render.render(plan, format)}

        true ->
          {:ok, result} = Runner.run(plan, fail_fast: fail_fast?)

          output = Render.render_result(plan, result, format)

          if result.passed? do
            {:ok, output}
          else
            stream = if format == :jsonl, do: :stdout, else: :stderr
            {:fail, output, stream}
          end
      end
    else
      {:error, %Error{} = err} -> format_error(err, format)
    end
  end

  defp do_quick_execute(opts, target_strings) do
    cond do
      opts[:dry_run] ->
        {:fail, "graft.validate: --quick cannot be combined with --dry-run", :stderr}

      opts[:continue] ->
        {:fail, "graft.validate: --quick cannot be combined with --continue", :stderr}

      true ->
        root = opts[:root] || File.cwd!()
        format = if opts[:json], do: :json, else: :text

        case Graft.Validate.Quick.run(root, target_strings) do
          {:ok, result} ->
            output = Graft.Validate.Quick.render(result, format)

            if result.passed? do
              {:ok, output}
            else
              stream = if format == :json, do: :stdout, else: :stderr
              {:fail, output, stream}
            end

          {:error, %Error{} = err} ->
            format_error(err, if(format == :json, do: :jsonl, else: :text))
        end
    end
  end

  defp resolve_targets(strings, snapshot) do
    by_name = Map.new(snapshot.repos, fn r -> {Atom.to_string(r.name), r.name} end)

    Enum.reduce_while(strings, {:ok, []}, fn s, {:ok, acc} ->
      case Map.fetch(by_name, s) do
        {:ok, atom} ->
          {:cont, {:ok, [atom | acc]}}

        :error ->
          {:halt,
           {:error,
            Error.new(
              :validate_target_not_in_workspace,
              "Target app(s) not declared as siblings in graft.exs: [#{s}]",
              %{targets: [s]}
            )}}
      end
    end)
    |> case do
      {:ok, rev} -> {:ok, Enum.reverse(rev)}
      err -> err
    end
  end

  defp format_error(%Error{} = err, :jsonl) do
    case Errors.format(err, :json, "graft.validate") do
      {:error, output, _stream} -> {:fail, output, :stdout}
    end
  end

  defp format_error(%Error{} = err, :text) do
    case Errors.format(err, :text, "graft.validate") do
      {:error, output, stream} -> {:fail, output, stream}
    end
  end
end