lib/graft/remove.ex

defmodule Graft.Remove do
  @moduledoc """
  Remove sibling entries from `graft.exs`, optionally deleting their directories.

  Manifest removal is the default operation. Filesystem deletion only happens
  when the caller passes `delete: true`, and dirty git repositories are refused
  unless `force: true` is also supplied.
  """

  alias Graft.{Error, GitState, Manifest, Safety}
  alias Graft.CLI.Errors
  alias Graft.Manifest.Sibling

  defmodule Outcome do
    @moduledoc false
    defstruct [
      :name,
      :path,
      :absolute_path,
      :status,
      :message,
      delete?: false,
      dirty?: false,
      failures: []
    ]
  end

  defmodule Failure do
    @moduledoc false
    defstruct [:kind, :message, details: %{}]
  end

  defmodule Result do
    @moduledoc false
    defstruct [
      :root,
      :dry_run?,
      :delete?,
      :force?,
      :applied?,
      :passed?,
      outcomes: []
    ]
  end

  @type format :: :text | :json

  @doc """
  Remove `target_strings` from the manifest rooted at `root`.
  """
  @spec remove([String.t()], Path.t(), keyword()) ::
          {:ok, Result.t()} | {:error, Result.t() | Error.t()}
  def remove(target_strings, root, opts \\ []) do
    dry_run? = Keyword.get(opts, :dry_run, false)
    delete? = Keyword.get(opts, :delete, false)
    force? = Keyword.get(opts, :force, false)

    with {:ok, manifest} <- Manifest.load(root),
         {:ok, targets} <- resolve_targets(manifest, target_strings) do
      outcomes = Enum.map(targets, &plan_outcome(&1, manifest.root, delete?, force?))
      passed? = Enum.all?(outcomes, &(&1.status != :error))

      planned = %Result{
        root: manifest.root,
        dry_run?: dry_run?,
        delete?: delete?,
        force?: force?,
        applied?: false,
        passed?: passed?,
        outcomes: outcomes
      }

      cond do
        not passed? ->
          {:error, planned}

        dry_run? ->
          {:ok, planned}

        true ->
          apply_remove(manifest, targets, planned)
      end
    end
  end

  @doc "Render a remove result."
  @spec render(Result.t(), format()) :: String.t()
  def render(%Result{} = result, :text) do
    mode =
      cond do
        not result.passed? -> "refused"
        result.dry_run? -> "dry-run"
        result.applied? -> "applied"
        true -> "planned"
      end

    header = [
      "Graft remove (#{mode})",
      "Root: #{result.root}",
      ""
    ]

    body = Enum.flat_map(result.outcomes, &outcome_text/1)

    (header ++ body)
    |> Enum.intersperse("\n")
    |> IO.iodata_to_binary()
  end

  def render(%Result{} = result, :json) do
    %{
      operation: "remove",
      root: result.root,
      dry_run: result.dry_run?,
      delete: result.delete?,
      force: result.force?,
      applied: result.applied?,
      passed: result.passed?,
      outcomes: Enum.map(result.outcomes, &outcome_json/1)
    }
    |> Jason.encode!()
  end

  defp resolve_targets(_manifest, []) do
    {:error,
     Error.new(
       :remove_target_required,
       "At least one sibling name is required",
       %{}
     )}
  end

  defp resolve_targets(%Manifest{siblings: siblings}, target_strings) do
    by_name = Map.new(siblings, fn sibling -> {Atom.to_string(sibling.name), sibling} end)

    Enum.reduce_while(target_strings, {:ok, []}, fn target, {:ok, acc} ->
      case Map.fetch(by_name, target) do
        {:ok, sibling} ->
          {:cont, {:ok, [sibling | acc]}}

        :error ->
          {:halt,
           {:error,
            Error.new(
              :remove_target_not_in_manifest,
              "Sibling #{inspect(target)} is not declared in graft.exs",
              %{target: target}
            )}}
      end
    end)
    |> case do
      {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
      error -> error
    end
  end

  defp plan_outcome(%Sibling{} = sibling, root, delete?, force?) do
    failures =
      []
      |> check_delete_path_safety(sibling, root, delete?)
      |> check_dirty_repo(sibling, delete?, force?)

    status = if failures == [], do: :planned, else: :error

    %Outcome{
      name: sibling.name,
      path: sibling.path,
      absolute_path: sibling.absolute_path,
      status: status,
      message: planned_message(sibling, delete?),
      delete?: delete?,
      dirty?: dirty_repo?(sibling),
      failures: failures
    }
  end

  defp check_delete_path_safety(failures, _sibling, _root, false), do: failures

  defp check_delete_path_safety(failures, %Sibling{absolute_path: path}, root, true) do
    with :ok <- Safety.within_root?(path, root),
         {:exists, true} <- {:exists, File.exists?(path)},
         {:ok, resolved} <- Safety.real_path(path),
         :ok <- Safety.within_root?(resolved, resolved_root(root)) do
      failures
    else
      {:exists, false} ->
        failures

      {:error, %Error{} = err} ->
        failures ++ [failure(err.kind, err.message, err.details)]

      {:error, reason} ->
        failures ++
          [
            failure(:remove_path_unresolvable, "Could not resolve #{path}: #{inspect(reason)}", %{
              path: path,
              reason: reason
            })
          ]
    end
  end

  defp check_dirty_repo(failures, _sibling, false, _force?), do: failures
  defp check_dirty_repo(failures, _sibling, true, true), do: failures

  defp check_dirty_repo(failures, %Sibling{} = sibling, true, false) do
    if dirty_repo?(sibling) do
      failures ++
        [
          failure(
            :remove_dirty_repo,
            "Refusing to delete dirty repo #{sibling.name}; commit or stash changes, or pass --force",
            %{name: sibling.name, path: sibling.absolute_path}
          )
        ]
    else
      failures
    end
  end

  defp dirty_repo?(%Sibling{absolute_path: path, name: name}) do
    File.dir?(path) and
      case GitState.read(path, repo: name) do
        %GitState{is_git_repo?: true, dirty?: dirty?} -> dirty?
        _ -> false
      end
  end

  defp resolved_root(root) do
    case Safety.real_path(root) do
      {:ok, resolved} -> resolved
      {:error, _} -> root
    end
  end

  defp planned_message(%Sibling{} = sibling, false) do
    "remove #{sibling.name} from graft.exs"
  end

  defp planned_message(%Sibling{} = sibling, true) do
    "remove #{sibling.name} from graft.exs and delete #{sibling.absolute_path}"
  end

  defp apply_remove(%Manifest{} = manifest, targets, %Result{} = planned) do
    target_names = MapSet.new(targets, & &1.name)
    remaining = Enum.reject(manifest.siblings, &MapSet.member?(target_names, &1.name))

    with :ok <- Manifest.write(manifest.source_path, manifest.root_declared, remaining),
         :ok <- maybe_delete_targets(targets, planned.delete?) do
      outcomes =
        Enum.map(planned.outcomes, fn outcome ->
          %{outcome | status: :removed, message: applied_message(outcome)}
        end)

      {:ok, %{planned | applied?: true, outcomes: outcomes}}
    end
  end

  defp maybe_delete_targets(_targets, false), do: :ok

  defp maybe_delete_targets(targets, true) do
    Enum.reduce_while(targets, :ok, fn sibling, :ok ->
      case delete_path(sibling.absolute_path) do
        :ok -> {:cont, :ok}
        {:error, %Error{} = err} -> {:halt, {:error, err}}
      end
    end)
  end

  defp delete_path(path) do
    if File.exists?(path) do
      case File.rm_rf(path) do
        {:ok, _paths} ->
          :ok

        {:error, reason, failed_path} ->
          {:error,
           Error.new(
             :remove_delete_failed,
             "Failed to delete #{failed_path}: #{inspect(reason)}",
             %{path: failed_path, reason: reason}
           )}
      end
    else
      :ok
    end
  end

  defp applied_message(%Outcome{delete?: false, name: name}), do: "removed #{name} from graft.exs"

  defp applied_message(%Outcome{delete?: true, name: name, absolute_path: path}) do
    "removed #{name} from graft.exs and deleted #{path}"
  end

  defp failure(kind, message, details) do
    %Failure{kind: kind, message: message, details: details}
  end

  defp outcome_text(%Outcome{status: :error} = outcome) do
    [
      "#{outcome.name} [error]",
      "  path: #{outcome.path}"
      | Enum.map(outcome.failures, fn failure ->
          "  - #{failure.kind}: #{failure.message}"
        end)
    ] ++ [""]
  end

  defp outcome_text(%Outcome{} = outcome) do
    [
      "#{outcome.name} [#{outcome.status}]",
      "  #{outcome.message}",
      ""
    ]
  end

  defp outcome_json(%Outcome{} = outcome) do
    %{
      name: Atom.to_string(outcome.name),
      path: outcome.path,
      absolute_path: outcome.absolute_path,
      status: Atom.to_string(outcome.status),
      message: outcome.message,
      delete: outcome.delete?,
      dirty: outcome.dirty?,
      failures:
        Enum.map(outcome.failures, fn failure ->
          %{
            kind: Atom.to_string(failure.kind),
            message: failure.message,
            details: Errors.jsonable(failure.details)
          }
        end)
    }
  end
end