lib/graft/teardown.ex

defmodule Graft.Teardown do
  @moduledoc """
  Safely remove materialized graft entries.

  Only `:managed` repos inside the graft root are touched. `:external`
  source repos are **never** deleted. This is a hard invariant: teardown
  can only remove what materialization created.

  Teardown is idempotent: removing an already-removed entry is a no-op.

  Safety: teardown refuses to remove a managed path that is not a symlink,
  unless it is an empty directory. This prevents accidental deletion of
  real source trees.

  All removals are gated by `Graft.Safety`.
  """

  alias Graft.{Error, Workspace}
  alias Graft.Safety

  @type result :: :ok | {:error, Error.t()}

  @doc """
  Tear down a single materialized repo entry.

  If `repo.ownership` is `:external`, returns `:ok` immediately (nothing
  to tear down). If the repo is `:managed`, removes the symlink inside
  `graft_root`. Never touches `repo.absolute_path`.

  ## Safety rules

    * If the path does not exist → `:ok` (idempotent).
    * If the path is a symlink → remove it.
    * If the path is an empty directory → remove it (allows cleanup of
      the parent directory created by `File.mkdir_p!`).
    * If the path is a non-empty directory or regular file → **refuse**.
    * If the path name contains path-traversal characters → **refuse**.
    * If the resolved path escapes `graft_root` → **refuse**.
  """
  @spec teardown_repo(Workspace.Repo.t(), Path.t()) :: result()
  def teardown_repo(%Workspace.Repo{ownership: :external}, _graft_root), do: :ok

  def teardown_repo(%Workspace.Repo{name: name}, graft_root) when is_binary(graft_root) do
    with :ok <- Safety.allowed_root?(graft_root),
         {:ok, link_path} <- Safety.resolve_managed_path(graft_root, name) do
      cond do
        not File.exists?(link_path) ->
          # Already gone — idempotent
          :ok

        symlink?(link_path) ->
          case File.rm(link_path) do
            :ok ->
              :ok

            {:error, reason} ->
              {:error,
               Error.new(
                 :runner_rollback_failed,
                 "Failed to remove symlink #{link_path}: #{:file.format_error(reason)}"
               )}
          end

        File.dir?(link_path) ->
          case File.ls(link_path) do
            {:ok, []} ->
              case File.rmdir(link_path) do
                :ok ->
                  :ok

                {:error, reason} ->
                  {:error,
                   Error.new(
                     :runner_rollback_failed,
                     "Failed to remove empty directory #{link_path}: #{:file.format_error(reason)}"
                   )}
              end

            {:ok, _entries} ->
              {:error,
               Error.new(
                 :runner_rollback_failed,
                 "Refusing to remove #{link_path}: it is a non-empty directory. Manual cleanup required."
               )}
          end

        true ->
          {:error,
           Error.new(
             :runner_rollback_failed,
             "Refusing to remove #{link_path}: it is a regular file, not a symlink. Manual cleanup required."
           )}
      end
    end
  end

  @doc """
  Tear down all managed repos in a workspace snapshot from the graft root.

  Returns `:ok` if all teardowns succeed, or the first error encountered.
  """
  @spec teardown_workspace(Workspace.t(), Path.t()) :: result()
  def teardown_workspace(%Workspace{repos: repos}, graft_root) when is_binary(graft_root) do
    managed = Enum.filter(repos, &(&1.ownership == :managed))

    Enum.reduce(managed, :ok, fn repo, acc ->
      case acc do
        :ok -> teardown_repo(repo, graft_root)
        {:error, _} = err -> err
      end
    end)
  end

  defp symlink?(path) do
    case File.read_link(path) do
      {:ok, _} -> true
      _ -> false
    end
  end
end