lib/graft/validate/quick.ex

defmodule Graft.Validate.Quick do
  @moduledoc """
  Lightweight workspace health checks for `mix graft.validate --quick`.

  Quick validation reads the manifest, checks sibling filesystem shape, asks
  git for local repository metadata, and compares `origin` when the manifest
  declares one. It never runs `mix deps.get`, `mix compile`, or `mix test`.
  """

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

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

  defmodule SiblingResult do
    @moduledoc false
    defstruct [:name, :path, :absolute_path, :origin, :status, failures: []]
  end

  defmodule Result do
    @moduledoc false
    defstruct [:root, :target_apps, :passed?, siblings: []]
  end

  @type format :: :text | :json

  @doc """
  Run quick validation for all siblings, or only `target_strings` when given.
  """
  @spec run(Path.t(), [String.t()]) :: {:ok, Result.t()} | {:error, Error.t()}
  def run(root, target_strings \\ []) do
    with {:ok, manifest} <- Manifest.load(root),
         {:ok, selected} <- select_siblings(manifest.siblings, target_strings) do
      git_by_sibling = read_git(selected)
      siblings = Enum.map(selected, &check_sibling(&1, manifest.root, git_by_sibling))

      {:ok,
       %Result{
         root: manifest.root,
         target_apps: Enum.map(selected, & &1.name),
         passed?: Enum.all?(siblings, &(&1.status == :ok)),
         siblings: siblings
       }}
    end
  end

  @doc "Render a quick validation result."
  @spec render(Result.t(), format()) :: String.t()
  def render(%Result{} = result, :text) do
    status = if result.passed?, do: "passed", else: "failed"

    header = [
      "Graft validate --quick",
      "Root: #{result.root}",
      "Siblings: #{length(result.siblings)}",
      "Status: #{status}",
      ""
    ]

    body =
      result.siblings
      |> Enum.flat_map(&sibling_text/1)

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

  def render(%Result{} = result, :json) do
    %{
      quick: true,
      root: result.root,
      target_apps: Enum.map(result.target_apps, &Atom.to_string/1),
      passed: result.passed?,
      siblings: Enum.map(result.siblings, &sibling_json/1)
    }
    |> Jason.encode!()
  end

  defp select_siblings(siblings, []) do
    {:ok, siblings}
  end

  defp select_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(
              :validate_target_not_in_workspace,
              "Target app(s) not declared as siblings in graft.exs: [#{target}]",
              %{targets: [target]}
            )}}
      end
    end)
    |> case do
      {:ok, reversed} -> {:ok, Enum.reverse(reversed)}
      error -> error
    end
  end

  defp read_git(siblings) do
    siblings
    |> Enum.filter(&File.dir?(&1.absolute_path))
    |> Map.new(fn sibling ->
      {sibling.name, GitState.read(sibling.absolute_path, repo: sibling.name)}
    end)
  end

  defp check_sibling(%Sibling{} = sibling, root, git_by_sibling) do
    failures =
      []
      |> check_path_exists(sibling)
      |> check_path_is_directory(sibling)
      |> check_resolved_path_inside_root(sibling, root)
      |> check_mix_exs(sibling)
      |> check_git_repo(sibling, git_by_sibling)
      |> check_origin(sibling, git_by_sibling)

    %SiblingResult{
      name: sibling.name,
      path: sibling.path,
      absolute_path: sibling.absolute_path,
      origin: sibling.origin,
      status: if(failures == [], do: :ok, else: :failed),
      failures: failures
    }
  end

  defp check_path_exists(failures, %Sibling{absolute_path: path}) do
    if File.exists?(path) do
      failures
    else
      failures ++
        [
          failure(:path_missing, "Path does not exist: #{path}", %{path: path})
        ]
    end
  end

  defp check_path_is_directory(failures, %Sibling{absolute_path: path}) do
    cond do
      not File.exists?(path) ->
        failures

      File.dir?(path) ->
        failures

      true ->
        failures ++
          [
            failure(:path_not_directory, "Path exists but is not a directory: #{path}", %{
              path: path
            })
          ]
    end
  end

  defp check_resolved_path_inside_root(failures, %Sibling{absolute_path: path}, root) do
    if File.exists?(path) do
      case Safety.real_path(path) do
        {:ok, resolved} ->
          resolved_root = resolved_root(root)

          case Safety.within_root?(resolved, resolved_root) do
            :ok ->
              failures

            {:error, _} ->
              failures ++
                [
                  failure(
                    :path_escapes_root,
                    "Path resolves outside workspace root: #{path} -> #{resolved}",
                    %{path: path, resolved: resolved, root: resolved_root}
                  )
                ]
          end

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

  defp check_mix_exs(failures, %Sibling{absolute_path: path}) do
    mix_exs = Path.join(path, "mix.exs")

    cond do
      not File.dir?(path) ->
        failures

      File.regular?(mix_exs) ->
        failures

      true ->
        failures ++
          [
            failure(:mix_exs_missing, "mix.exs does not exist: #{mix_exs}", %{path: mix_exs})
          ]
    end
  end

  defp check_git_repo(failures, %Sibling{absolute_path: path, name: name}, git_by_sibling) do
    cond do
      not File.dir?(path) ->
        failures

      true ->
        git = Map.get(git_by_sibling, name) || GitState.read(path, repo: name)

        cond do
          git.is_git_repo? ->
            failures

          git.error == :git_not_installed ->
            failures ++
              [
                failure(:git_not_installed, "git executable is not installed", %{})
              ]

          true ->
            failures ++
              [
                failure(:git_repo_missing, "Path is not a git repository: #{path}", %{path: path})
              ]
        end
    end
  end

  defp check_origin(failures, %Sibling{origin: nil}, _git_by_sibling), do: failures

  defp check_origin(
         failures,
         %Sibling{absolute_path: path, name: name, origin: expected},
         git_by_sibling
       ) do
    if File.dir?(path) do
      git = Map.get(git_by_sibling, name) || GitState.read(path, repo: name)
      actual = git.origin_url

      cond do
        not git.is_git_repo? ->
          failures

        is_nil(actual) ->
          failures ++
            [
              failure(
                :origin_missing,
                "Expected origin #{expected}, but repo has no origin remote",
                %{
                  expected: expected,
                  actual: nil
                }
              )
            ]

        GitRemote.same?(expected, actual) ->
          failures

        true ->
          failures ++
            [
              failure(
                :origin_mismatch,
                "Expected origin #{expected}, got #{actual}",
                %{expected: expected, actual: actual}
              )
            ]
      end
    else
      failures
    end
  end

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

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

  defp sibling_text(%SiblingResult{status: :ok} = sibling) do
    [
      "#{sibling.name} [ok]",
      "  path: #{sibling.path}",
      ""
    ]
  end

  defp sibling_text(%SiblingResult{} = sibling) do
    [
      "#{sibling.name} [failed]",
      "  path: #{sibling.path}"
      | Enum.map(sibling.failures, fn failure ->
          "  - #{failure.kind}: #{failure.message}"
        end)
    ] ++ [""]
  end

  defp sibling_json(%SiblingResult{} = sibling) do
    %{
      name: Atom.to_string(sibling.name),
      path: sibling.path,
      absolute_path: sibling.absolute_path,
      origin: sibling.origin,
      status: Atom.to_string(sibling.status),
      failures:
        Enum.map(sibling.failures, fn failure ->
          %{
            kind: Atom.to_string(failure.kind),
            message: failure.message,
            details: Errors.jsonable(failure.details)
          }
        end)
    }
  end
end