lib/mix/tasks/graft.link.off.ex

defmodule Mix.Tasks.Graft.Link.Off do
  @shortdoc "Restore sibling-repo deps from `.graft/state.json`"

  @moduledoc """
  Revert a prior `mix graft.link.on` rewrite by restoring literal
  preimages recorded in `.graft/state.json`.

      mix graft.link.off req_llm --dry-run
      mix graft.link.off req_llm
      mix graft.link.off req_llm --json

  Restoration is always *literal* — Off never reasons about AST. The
  recorded `preimage` (original Hex/Git tuple) replaces the `replacement`
  (path-link tuple currently in the consumer's `mix.exs`). Hash
  verification gates every write; mismatches abort before mutation.

  No git, no `mix deps.unlock`, no `mix deps.get`.
  """

  use Mix.Task

  alias Graft.{Error, State, Workspace}
  alias Graft.CLI.Errors
  alias Graft.Link.Off.{Plan, Runner}
  alias Graft.Link.Off.Plan.Render

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

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

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

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

  @spec execute([String.t()]) :: {:ok, String.t()} | {:error, String.t(), :stdout | :stderr}
  def execute(argv) do
    case parse_args(argv) do
      {:ok, opts, target_strings} ->
        format = if opts[:json], do: :json, else: :text

        cond do
          target_strings == [] ->
            {:error, "graft.link.off: at least one target app is required", :stderr}

          Keyword.get(opts, :dry_run, false) ->
            run_dry_run(opts, target_strings, format)

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

      {:error, msg} ->
        {:error, "graft.link.off: #{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

  ## ─── Dry-run / apply ────────────────────────────────────────────────

  defp run_dry_run(opts, target_strings, format) do
    case build_plan(opts, target_strings) do
      {:ok, plan} -> {:ok, Render.render(plan, format)}
      {:error, %Error{} = err} -> format_error(err, format)
    end
  end

  defp run_apply(opts, target_strings, format) do
    with {:ok, plan} <- build_plan(opts, target_strings),
         {:ok, result} <- Runner.run(plan) do
      {:ok, Render.render_applied(plan, result, format)}
    else
      {:error, %Error{} = err} -> format_error(err, format)
    end
  end

  defp build_plan(opts, target_strings) do
    root = opts[:root] || File.cwd!()

    with {:ok, snapshot} <- Workspace.snapshot(root),
         {:ok, state} <- load_state(root),
         {:ok, targets} <- resolve_targets(target_strings, snapshot, state),
         {:ok, plan} <- Plan.build(snapshot, state, targets) do
      {:ok, plan}
    end
  end

  defp load_state(root) do
    case State.load(root) do
      {:ok, state} ->
        {:ok, state}

      {:error, %Error{kind: :state_io_error} = err} ->
        {:error,
         Error.new(
           :off_state_missing,
           "No link state to restore at #{State.state_path(root)} — run `mix graft.link.on` first",
           Map.put(err.details, :cause, err.kind)
         )}

      {:error, %Error{}} = err ->
        err
    end
  end

  # Only resolve target strings against atoms already declared either
  # as workspace siblings or as recorded `target_app`s in state. We
  # never call `String.to_atom/1`.
  defp resolve_targets(strings, snapshot, state) do
    sibling_pairs = Enum.map(snapshot.repos, fn r -> {Atom.to_string(r.name), r.name} end)

    state_pairs =
      Enum.map(state.entries, fn e -> {Atom.to_string(e.target_app), e.target_app} end)

    by_name = Map.new(sibling_pairs ++ state_pairs)

    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(
              :off_target_not_in_state,
              "Target app(s) not declared as siblings or recorded in state.json: [#{s}]",
              %{targets: [s]}
            )}}
      end
    end)
    |> case do
      {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
      {:error, _} = err -> err
    end
  end

  defp format_error(%Error{} = err, format), do: Errors.format(err, format, "graft.link.off")
end