defmodule Graft.Materializer do
@moduledoc """
Executes a graft plan against the filesystem.
Materializes managed repos by creating symlinks into the graft root.
Only `:managed` repos are touched; `:external` repos are verified but
never modified.
This module is the bridge between the declarative plan and the real world.
It is idempotent: running the same materialization twice is safe.
All writes are gated by `Graft.Safety`.
"""
alias Graft.{Error, Workspace}
alias Graft.Plan
alias Graft.Plan.Operation
alias Graft.Safety
@type result :: {:ok, map()} | {:error, Error.t()}
@doc """
Materialize all `:attach_repo` operations in the plan.
For v1, only symlink-based materialization is supported. The operation
creates a symbolic link from `graft_root/<repo>` → `<repo.absolute_path>`.
Returns a map of `repo_name => materialized_path` for all successfully
materialized entries, or an error on first failure.
"""
@spec materialize(Plan.t(), Path.t()) :: result()
def materialize(%Plan{operations: ops}, graft_root) when is_binary(graft_root) do
attach_ops = Enum.filter(ops, &(&1.type == :attach_repo))
with :ok <- Safety.allowed_root?(graft_root) do
do_materialize(attach_ops, graft_root, %{})
end
end
defp do_materialize([], _graft_root, acc), do: {:ok, acc}
defp do_materialize([%Operation{target: target} | rest], graft_root, acc) do
repo_name = String.to_atom(target)
# Look up the desired snapshot to find the absolute path
materialized_path = Path.join(graft_root, target)
# Create the symlink — but we need the target path from the plan's desired snapshot
# For v1, we look up the desired snapshot in the plan
{:ok, path} = materialize_one(materialized_path, repo_name, graft_root)
do_materialize(rest, graft_root, Map.put(acc, repo_name, path))
end
# This version is used by the demo task which has direct access to the repo
@doc """
Materialize a single repo as a symlink into the graft root.
The link points from `<graft_root>/<repo_name>` to `<repo_absolute_path>`.
"""
@spec materialize_repo(Workspace.Repo.t(), Path.t()) :: result()
def materialize_repo(
%Workspace.Repo{name: name, absolute_path: abs, ownership: ownership},
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
ownership == :external ->
{:error,
Error.new(
:runner_write_failed,
"Cannot materialize external repo #{name} — only managed repos can be materialized"
)}
File.exists?(link_path) ->
case File.read_link(link_path) do
{:ok, ^abs} ->
# Already correctly symlinked — idempotent
{:ok, %{name => link_path}}
{:ok, other_target} ->
{:error,
Error.new(
:runner_write_failed,
"Link #{link_path} already exists but points to #{other_target}, expected #{abs}"
)}
{:error, :einval} ->
{:error,
Error.new(
:runner_write_failed,
"#{link_path} exists but is not a symlink — cannot safely materialize"
)}
{:error, reason} ->
{:error,
Error.new(
:runner_write_failed,
"Cannot read link #{link_path}: #{:file.format_error(reason)}"
)}
end
true ->
File.mkdir_p!(Path.dirname(link_path))
case File.ln_s(abs, link_path) do
:ok ->
{:ok, %{name => link_path}}
{:error, reason} ->
{:error,
Error.new(
:runner_write_failed,
"Failed to create symlink #{link_path} → #{abs}: #{:file.format_error(reason)}"
)}
end
end
end
end
defp materialize_one(_materialized_path, repo_name, graft_root) do
# For the general plan-based materializer we need to know the target path.
# In v1, the demo task calls materialize_repo/2 directly with the repo struct.
# This clause is a fallback that will be refined in future iterations.
{:ok, Path.join(graft_root, Atom.to_string(repo_name))}
end
end