lib/graft/validate/plan.ex

defmodule Graft.Validate.Plan do
  @moduledoc """
  Plans a `validate` operation across a Graft workspace. Pure: reads
  the workspace snapshot only; no shell-outs, no filesystem mutation.

  ## Closure

  The closure of validating `targets` is the targets *and* every
  workspace sibling that transitively depends on them — the same shape
  link.on uses, because changing `req_llm` requires verifying its
  consumers still build and pass.

  ## Execution order

  Topological by workspace dep direction: a sibling's dependencies
  validate before the sibling itself. Within a topological layer,
  alphabetic for determinism.

  Cycles in the sibling dep graph (rare but legal) are reported as a
  plan warning; remaining nodes are appended alphabetically rather than
  failing the plan.

  ## Command sequence (v0.1)

  Per repo, in fixed order:

    1. `mix deps.get`
    2. `mix compile --warnings-as-errors`
    3. `mix test`

  v0.1 is hardcoded. A future milestone will read an optional
  `:validate` key from `graft.exs` for project-configured commands.
  """

  alias Graft.{Error, Workspace}
  alias Graft.Workspace.Repo
  alias Graft.Validate.Plan.{Command, Step}

  @type t :: %__MODULE__{
          operation: :validate,
          generated_at: DateTime.t(),
          workspace_root: Path.t(),
          target_apps: [atom()],
          affected_repos: [atom()],
          steps: [Step.t()],
          warnings: [String.t()]
        }

  defstruct operation: :validate,
            generated_at: nil,
            workspace_root: nil,
            target_apps: [],
            affected_repos: [],
            steps: [],
            warnings: []

  @default_commands [
    %Command{kind: :deps_get, argv: ["deps.get"], description: "mix deps.get"},
    %Command{
      kind: :compile,
      argv: ["compile", "--warnings-as-errors"],
      description: "mix compile --warnings-as-errors"
    },
    %Command{kind: :test, argv: ["test"], description: "mix test"}
  ]

  @doc "Default per-repo command sequence (used unless overridden later)."
  def default_commands, do: @default_commands

  @spec build(Workspace.t(), [atom()]) :: {:ok, t()} | {:error, Error.t()}
  def build(%Workspace{} = workspace, target_apps) when is_list(target_apps) do
    targets = target_apps |> Enum.uniq() |> Enum.sort_by(&Atom.to_string/1)

    with :ok <- validate_nonempty(targets),
         :ok <- validate_targets_in_workspace(targets, workspace) do
      {closure, warnings} = compute_closure_and_order(workspace, targets)

      steps =
        closure
        |> Enum.map(&build_step(&1, workspace))
        |> Enum.reject(&is_nil/1)

      {:ok,
       %__MODULE__{
         operation: :validate,
         generated_at: DateTime.utc_now(),
         workspace_root: workspace.root,
         target_apps: targets,
         affected_repos: Enum.map(steps, & &1.repo),
         steps: steps,
         warnings: warnings
       }}
    end
  end

  ## ─── Validation ─────────────────────────────────────────────────────

  defp validate_nonempty([]),
    do: {:error, Error.new(:plan_invalid_operation, "Target apps list must not be empty")}

  defp validate_nonempty(_), do: :ok

  defp validate_targets_in_workspace(targets, workspace) do
    sibling_names = MapSet.new(workspace.repos, & &1.name)

    case Enum.reject(targets, &MapSet.member?(sibling_names, &1)) do
      [] ->
        :ok

      missing ->
        {:error,
         Error.new(
           :validate_target_not_in_workspace,
           "Target app(s) not declared as siblings in graft.exs: #{inspect(missing)}",
           %{targets: missing}
         )}
    end
  end

  ## ─── Closure + topological order ───────────────────────────────────

  defp compute_closure_and_order(workspace, targets) do
    sibling_set = MapSet.new(workspace.repos, & &1.name)

    consumers_of =
      workspace.deps
      |> Enum.filter(
        &(MapSet.member?(sibling_set, &1.app) and MapSet.member?(sibling_set, &1.repo))
      )
      |> Enum.group_by(& &1.app, & &1.repo)
      |> Map.new(fn {app, consumers} -> {app, Enum.uniq(consumers)} end)

    closure = walk_consumers(consumers_of, MapSet.new(targets), MapSet.new(targets))
    topological_order(MapSet.to_list(closure), workspace.deps, sibling_set)
  end

  defp walk_consumers(consumers_of, frontier, visited) do
    next =
      frontier
      |> Enum.flat_map(fn app -> Map.get(consumers_of, app, []) end)
      |> Enum.uniq()
      |> Enum.reject(&MapSet.member?(visited, &1))

    case next do
      [] -> visited
      _ -> walk_consumers(consumers_of, MapSet.new(next), MapSet.union(visited, MapSet.new(next)))
    end
  end

  # Kahn's algorithm with alphabetic tie-break. Cycles produce a warning
  # and the cyclic remainder is appended alphabetically.
  defp topological_order(nodes, deps, sibling_set) do
    closure_set = MapSet.new(nodes)

    edges =
      deps
      |> Enum.filter(
        &(MapSet.member?(closure_set, &1.app) and MapSet.member?(closure_set, &1.repo))
      )
      |> Enum.uniq_by(&{&1.app, &1.repo})

    adj =
      edges
      |> Enum.group_by(& &1.app, & &1.repo)
      |> Map.new(fn {k, vs} -> {k, Enum.uniq(vs)} end)

    in_degree =
      edges
      |> Enum.group_by(& &1.repo, & &1.app)
      |> Map.new(fn {k, vs} -> {k, length(Enum.uniq(vs))} end)

    in_degree = Map.merge(Map.new(nodes, &{&1, 0}), in_degree)

    {order, leftover} = kahn([], in_degree, adj, MapSet.new(nodes))

    case leftover do
      [] ->
        _ = sibling_set
        {order, []}

      remaining ->
        sorted = Enum.sort_by(remaining, &Atom.to_string/1)

        {order ++ sorted,
         [
           "Cycle detected in workspace dep graph; remaining repos ordered alphabetically: #{inspect(sorted)}"
         ]}
    end
  end

  defp kahn(order, in_degree, adj, remaining) do
    ready =
      remaining
      |> MapSet.to_list()
      |> Enum.filter(&(Map.get(in_degree, &1) == 0))
      |> Enum.sort_by(&Atom.to_string/1)

    case ready do
      [] ->
        {order, MapSet.to_list(remaining)}

      [pick | _] ->
        new_in_degree =
          Map.get(adj, pick, [])
          |> Enum.reduce(in_degree, fn cons, acc -> Map.update!(acc, cons, &(&1 - 1)) end)

        kahn(order ++ [pick], new_in_degree, adj, MapSet.delete(remaining, pick))
    end
  end

  ## ─── Step construction ─────────────────────────────────────────────

  defp build_step(repo_name, workspace) do
    case Enum.find(workspace.repos, &(&1.name == repo_name)) do
      nil ->
        nil

      %Repo{} = repo ->
        %Step{
          repo: repo.name,
          repo_path: repo.absolute_path,
          commands: @default_commands
        }
    end
  end
end