defmodule Graft.Link.Plan do
@moduledoc """
Computes the exact mutation plan for a `link.on` operation across a
Graft workspace. Pure: filesystem reads only (no writes), no git,
no Mix shell-outs.
## Closure semantics
Linking a target app `T` cascades through the sibling dependency graph:
1. Every sibling that depends on `T` gets a change pair `(sibling, T)`.
2. Each such sibling is itself now treated as a linked app — so any
*further* sibling depending on it also gets a change pair.
3. The expansion repeats until no new pairs are added.
Cycles in the sibling graph terminate naturally: once a pair has been
recorded once it is not re-added.
## Determinism
Given identical `(workspace, target_apps, opts)` input, `build/3`
returns a plan whose `target_apps`, `affected_repos`, and `changes`
are byte-stable. Only `:generated_at` differs across runs.
## Workspace fence
Plans never include changes for repos that are not declared in
`graft.exs`. The closure walks only `workspace.deps`, which by
construction (`Workspace.snapshot/1`) covers only manifest siblings.
Targets outside the workspace are rejected with a structured error.
## v0.1 scope
Only `:operation, :link_on` is implementable here. `:link_off` planning
awaits the state-file milestone (`Graft.State`) since revert requires
recorded preimages.
"""
alias Graft.{Error, Workspace}
alias Graft.Workspace.Repo
alias Graft.Link.{Plan.Change, RewriteResult, Rewriter}
@type operation :: :link_on | :link_off
@type t :: %__MODULE__{
operation: operation(),
generated_at: DateTime.t(),
workspace_root: Path.t(),
target_apps: [atom()],
affected_repos: [atom()],
changes: [Change.t()],
warnings: [String.t()]
}
defstruct [
:operation,
:generated_at,
:workspace_root,
target_apps: [],
affected_repos: [],
changes: [],
warnings: []
]
@doc """
Build a link plan for `target_apps` against `workspace`.
Options:
* `:operation` — `:link_on` (default) or `:link_off`. `:link_off` is
not yet implementable and returns a structured `:not_implemented`
error.
"""
@spec build(Workspace.t(), [atom()], keyword()) :: {:ok, t()} | {:error, Error.t()}
def build(%Workspace{} = workspace, target_apps, opts \\ [])
when is_list(target_apps) and is_list(opts) do
operation = Keyword.get(opts, :operation, :link_on)
with :ok <- validate_operation(operation),
:ok <- validate_targets(workspace, target_apps) do
build_plan(workspace, Enum.sort(Enum.uniq(target_apps)), operation)
end
end
## ─── Validation ─────────────────────────────────────────────────────
defp validate_operation(:link_on), do: :ok
defp validate_operation(:link_off) do
{:error,
Error.new(
:not_implemented,
"link.off planning awaits the state-file milestone; not yet supported"
)}
end
defp validate_operation(other) do
{:error,
Error.new(
:plan_invalid_operation,
"Unknown operation #{inspect(other)} (expected :link_on or :link_off)",
%{operation: other}
)}
end
defp validate_targets(_workspace, []) do
{:error, Error.new(:plan_invalid_operation, "Target apps list must not be empty")}
end
defp validate_targets(workspace, target_apps) do
sibling_names = MapSet.new(workspace.repos, & &1.name)
case Enum.reject(target_apps, &MapSet.member?(sibling_names, &1)) do
[] ->
:ok
bad ->
{:error,
Error.new(
:plan_target_not_in_workspace,
"Target app(s) not declared as siblings in graft.exs: #{inspect(bad)}",
%{targets: bad}
)}
end
end
## ─── Plan construction ─────────────────────────────────────────────
defp build_plan(workspace, target_apps, operation) do
pairs =
workspace.deps
|> closure(MapSet.new(target_apps))
|> Enum.sort()
{changes, warnings} = collect_changes(workspace, pairs)
affected_repos =
changes
|> Enum.map(& &1.repo)
|> Enum.uniq()
|> Enum.sort()
{:ok,
%__MODULE__{
operation: operation,
generated_at: DateTime.utc_now(),
workspace_root: workspace.root,
target_apps: target_apps,
affected_repos: affected_repos,
changes: changes,
warnings: warnings
}}
end
## ─── Closure ────────────────────────────────────────────────────────
# Returns a deduped list of `{consumer_repo_atom, target_app_atom}`
# pairs covering the full transitive impact of linking `target_apps`.
defp closure(deps, target_apps) do
consumers_of =
deps
|> Enum.group_by(& &1.app, & &1.repo)
|> Map.new(fn {app, repos} -> {app, Enum.uniq(repos)} end)
expand(consumers_of, target_apps, [])
end
defp expand(consumers_of, linked, pairs) do
new_pairs =
linked
|> Enum.flat_map(fn app ->
Enum.map(Map.get(consumers_of, app, []), &{&1, app})
end)
|> Enum.uniq()
|> Enum.reject(&(&1 in pairs))
case new_pairs do
[] ->
pairs
_ ->
new_linked_apps = MapSet.new(new_pairs, fn {consumer, _} -> consumer end)
expand(consumers_of, MapSet.union(linked, new_linked_apps), pairs ++ new_pairs)
end
end
## ─── Per-pair change construction ──────────────────────────────────
defp collect_changes(workspace, pairs) do
{changes_rev, warnings_rev} =
Enum.reduce(pairs, {[], []}, fn pair, {changes, warnings} ->
case build_change(workspace, pair) do
{:ok, change} -> {[change | changes], warnings}
{:skip, warning} -> {changes, [warning | warnings]}
end
end)
{Enum.reverse(changes_rev), Enum.reverse(warnings_rev)}
end
defp build_change(workspace, {consumer_atom, dep_atom}) do
consumer = find_repo(workspace, consumer_atom)
target = find_repo(workspace, dep_atom)
cond do
is_nil(consumer) ->
{:skip, "Consumer repo #{inspect(consumer_atom)} is not declared in the workspace"}
is_nil(target) ->
{:skip, "Target #{inspect(dep_atom)} is not declared in the workspace"}
not consumer.exists? ->
{:skip, "Skipping #{inspect(consumer_atom)}: directory not present on disk"}
not consumer.has_mix_exs? ->
{:skip, "Skipping #{inspect(consumer_atom)}: no mix.exs present"}
true ->
do_build_change(consumer, target)
end
end
defp do_build_change(%Repo{} = consumer, %Repo{} = target) do
mix_exs_path = Path.join(consumer.absolute_path, "mix.exs")
case File.read(mix_exs_path) do
{:ok, contents} ->
rewrite_and_build(consumer, target, contents)
{:error, reason} ->
{:skip, "Could not read #{mix_exs_path}: #{inspect(reason)}"}
end
end
defp rewrite_and_build(consumer, target, contents) do
relative = relative_sibling_path(consumer.absolute_path, target.absolute_path)
case Rewriter.rewrite(contents, target.name, path: relative) do
{:ok, %RewriteResult{matched_dep?: true} = result} ->
change = %Change{
repo: consumer.name,
repo_path: consumer.absolute_path,
target_app: target.name,
dependency_source_before: render_dep_ast(result.original_dep_ast),
dependency_source_after: render_dep_ast(result.rewritten_dep_ast),
mix_exs_before_hash: sha256(contents),
proposed_mix_exs_after_hash: sha256(result.rewritten_contents),
changed?: result.changed?
}
{:ok, change}
{:ok, %RewriteResult{matched_dep?: false}} ->
{:skip,
"Skipping #{inspect(consumer.name)}: no #{inspect(target.name)} dep found in mix.exs (parser/rewriter disagreement)"}
{:error, %Error{message: msg}} ->
{:skip,
"Skipping #{inspect(consumer.name)}: rewriter error for #{inspect(target.name)} — #{msg}"}
end
end
## ─── Helpers ────────────────────────────────────────────────────────
defp find_repo(workspace, name), do: Enum.find(workspace.repos, &(&1.name == name))
# Compute a relative POSIX path from `from_dir` to `to_dir`. Both must
# be absolute. Sibling repos under a common workspace root produce
# results like "../req_llm".
defp relative_sibling_path(from_dir, to_dir) do
from_parts = Path.split(from_dir)
to_parts = Path.split(to_dir)
common = common_prefix_length(from_parts, to_parts)
ups = List.duplicate("..", length(from_parts) - common)
rest = Enum.drop(to_parts, common)
case ups ++ rest do
[] -> "."
parts -> Path.join(parts)
end
end
defp common_prefix_length([h | t1], [h | t2]), do: 1 + common_prefix_length(t1, t2)
defp common_prefix_length(_, _), do: 0
defp render_dep_ast(nil), do: nil
defp render_dep_ast(ast), do: Sourceror.to_string(ast)
defp sha256(s) do
:crypto.hash(:sha256, s) |> Base.encode16(case: :lower)
end
end