lib/graft/manifest.ex

defmodule Graft.Manifest do
  @moduledoc """
  Loads and validates `graft.exs` — the workspace manifest.

  Format (eval'd as Elixir):

      %{
        root: ".",
        siblings: [
          %{name: :req_llm, path: "req_llm"},
          %{name: :jido,    path: "jido"}
        ]
      }

  ## Validation

  * file must exist at `<dir>/graft.exs`
  * the file must evaluate to a map
  * `:root` must be a binary path (relative or absolute)
  * `:siblings` must be a list
  * every sibling must declare an atom `:name` and a binary `:path`
  * sibling `:origin`, when present, must be a binary Git remote URL
  * sibling names must be unique
  * sibling paths must be unique
  * every resolved sibling path must remain strictly inside the
    workspace root (no `..` escape)

  ## Normalization

  The returned struct exposes both absolute (`root`, `Sibling.absolute_path`)
  and original (`root_declared`, `Sibling.path`) forms — the absolute forms
  for actual filesystem work, the originals for display and state-file
  recording.
  """

  alias Graft.Error

  defmodule Sibling do
    @moduledoc "A sibling repo declared in `graft.exs`."

    @type t :: %__MODULE__{
            name: atom(),
            path: Path.t(),
            absolute_path: Path.t(),
            origin: String.t() | nil
          }

    defstruct [:name, :path, :absolute_path, :origin]
  end

  @type t :: %__MODULE__{
          root: Path.t(),
          root_declared: Path.t(),
          siblings: [Sibling.t()],
          source_path: Path.t()
        }

  defstruct [:root, :root_declared, :siblings, :source_path]

  @manifest_filename "graft.exs"

  @doc "The conventional manifest filename."
  @spec filename() :: String.t()
  def filename, do: @manifest_filename

  @doc """
  Write a manifest using Graft's canonical literal data format.

  `siblings` may contain `%Sibling{}` structs or maps with `:name`, `:path`,
  and optional `:origin`. The write is atomic within the manifest directory.
  """
  @spec write(Path.t(), Path.t(), [Sibling.t() | map()]) :: :ok | {:error, Error.t()}
  def write(path, root_declared, siblings) when is_binary(path) and is_binary(root_declared) do
    entries = Enum.map(siblings, &sibling_entry/1)
    content = format(root_declared, entries)
    tmp_path = path <> ".tmp"

    with :ok <- File.write(tmp_path, content),
         :ok <- File.rename(tmp_path, path) do
      :ok
    else
      {:error, reason} ->
        File.rm(tmp_path)

        {:error,
         Error.new(
           :manifest_write_failed,
           "Failed to write manifest #{path}: #{inspect(reason)}",
           %{path: path, reason: reason}
         )}
    end
  end

  @doc """
  Load and validate the manifest from `dir` (defaults to `File.cwd!/0`).

  Side-effect free: reads the manifest file and resolves paths via
  `Path.expand/2` only. No filesystem mutations, no shell-outs.
  """
  @spec load(Path.t()) :: {:ok, t()} | {:error, Error.t()}
  def load(dir \\ File.cwd!()) when is_binary(dir) do
    path = Path.join(dir, @manifest_filename)

    with {:ok, raw} <- read_and_eval(path),
         {:ok, root_declared, siblings_raw} <- validate_top_level(raw),
         abs_root = Path.expand(root_declared, dir),
         {:ok, siblings} <- validate_siblings(siblings_raw, abs_root) do
      {:ok,
       %__MODULE__{
         root: abs_root,
         root_declared: root_declared,
         siblings: siblings,
         source_path: path
       }}
    end
  end

  ## ─── Read / parse ─────────────────────────────────────────────────────

  # Parses graft.exs via Code.string_to_quoted/1 instead of
  # Code.eval_file/2 for safety: the manifest is data, not code.
  # Only literal maps, lists, atoms, and strings are permitted.
  defp read_and_eval(path) do
    if File.regular?(path) do
      case File.read(path) do
        {:ok, source} ->
          case Code.string_to_quoted(source) do
            {:ok, ast} ->
              try do
                parse_manifest_ast(ast)
              catch
                {:error, %Error{} = err} ->
                  {:error, %{err | details: Map.put(err.details || %{}, :path, path)}}
              end

            {:error, {_meta, message, _token}} ->
              {:error,
               Error.new(
                 :manifest_eval_failed,
                 "Failed to parse #{path}: #{message}",
                 %{path: path}
               )}
          end

        {:error, reason} ->
          {:error,
           Error.new(
             :manifest_not_found,
             "Could not read #{path}: #{inspect(reason)}",
             %{path: path}
           )}
      end
    else
      {:error,
       Error.new(
         :manifest_not_found,
         "No #{@manifest_filename} found at #{path}",
         %{path: path}
       )}
    end
  end

  # Accept: %{root: "...", siblings: [...]}
  defp parse_manifest_ast({:%{}, _meta, fields}) do
    {:ok, Map.new(parse_map_fields(fields))}
  end

  defp parse_manifest_ast(other) do
    {:error,
     Error.new(
       :manifest_invalid_shape,
       "Manifest must be a literal map, got: #{Macro.to_string(other)}",
       %{ast: other}
     )}
  end

  defp parse_map_fields(fields) when is_list(fields) do
    Enum.map(fields, fn
      {key, value} when is_atom(key) ->
        {key, parse_literal(value)}

      other ->
        throw(
          {:error,
           Error.new(:manifest_invalid_field, "Map keys must be atoms, got: #{inspect(other)}")}
        )
    end)
  end

  # Literal value parser — only data, no function calls, no variables
  defp parse_literal({:%{}, _meta, fields}), do: Map.new(parse_map_fields(fields))
  defp parse_literal({_, _meta, list}) when is_list(list), do: Enum.map(list, &parse_literal/1)
  defp parse_literal(list) when is_list(list), do: Enum.map(list, &parse_literal/1)
  defp parse_literal(atom) when is_atom(atom), do: atom
  defp parse_literal(string) when is_binary(string), do: string
  defp parse_literal(integer) when is_integer(integer), do: integer
  defp parse_literal(boolean) when is_boolean(boolean), do: boolean
  defp parse_literal(nil), do: nil

  defp parse_literal(other) do
    throw(
      {:error,
       Error.new(
         :manifest_invalid_field,
         "Only literal values permitted in manifest, got: #{Macro.to_string(other)}"
       )}
    )
  end

  ## ─── Top-level shape ────────────────────────────────────────────────

  defp validate_top_level(raw) when is_map(raw) do
    with {:ok, root} <- fetch_field(raw, :root, &is_binary/1, "binary path"),
         {:ok, siblings} <- fetch_field(raw, :siblings, &is_list/1, "list") do
      {:ok, root, siblings}
    end
  end

  defp validate_top_level(other) do
    {:error,
     Error.new(
       :manifest_invalid_shape,
       "graft.exs must evaluate to a map, got #{inspect(other)}",
       %{value: other}
     )}
  end

  defp fetch_field(map, key, predicate, expected) do
    case Map.fetch(map, key) do
      {:ok, value} ->
        if predicate.(value) do
          {:ok, value}
        else
          {:error,
           Error.new(
             :manifest_invalid_field,
             "Manifest field #{inspect(key)} must be a #{expected}, got #{inspect(value)}",
             %{key: key, value: value}
           )}
        end

      :error ->
        {:error,
         Error.new(
           :manifest_invalid_field,
           "Manifest is missing required field #{inspect(key)}",
           %{key: key}
         )}
    end
  end

  ## ─── Sibling validation ─────────────────────────────────────────────

  defp validate_siblings(raw_siblings, abs_root) do
    raw_siblings
    |> Enum.with_index()
    |> Enum.reduce_while({:ok, []}, fn {raw, idx}, {:ok, acc} ->
      case build_sibling(raw, idx, abs_root) do
        {:ok, sibling} -> {:cont, {:ok, [sibling | acc]}}
        {:error, _} = err -> {:halt, err}
      end
    end)
    |> case do
      {:ok, reversed} ->
        siblings = Enum.reverse(reversed)

        with :ok <- check_unique(siblings, & &1.name, :manifest_duplicate_sibling_name, "name"),
             :ok <-
               check_unique(
                 siblings,
                 & &1.absolute_path,
                 :manifest_duplicate_sibling_path,
                 "path"
               ) do
          {:ok, siblings}
        end

      err ->
        err
    end
  end

  defp build_sibling(raw, idx, abs_root) when is_map(raw) do
    with {:ok, name} <- fetch_sibling_field(raw, :name, &is_atom/1, "atom", idx),
         {:ok, path} <-
           fetch_sibling_field(raw, :path, &is_binary/1, "binary", idx, %{name: name}),
         {:ok, origin} <-
           fetch_optional_sibling_field(raw, :origin, &is_binary/1, "binary", idx, %{name: name}),
         abs = Path.expand(path, abs_root),
         :ok <- check_inside_root(abs, abs_root, name, path, idx) do
      {:ok, %Sibling{name: name, path: path, absolute_path: abs, origin: origin}}
    end
  end

  defp build_sibling(other, idx, _abs_root) do
    {:error,
     Error.new(
       :manifest_invalid_field,
       "Sibling at index #{idx} must be a map, got #{inspect(other)}",
       %{index: idx, value: other}
     )}
  end

  defp fetch_sibling_field(map, key, predicate, expected, idx, context \\ %{}) do
    case Map.fetch(map, key) do
      {:ok, v} ->
        if predicate.(v) do
          {:ok, v}
        else
          context_msg = if name = context[:name], do: " #{inspect(name)}", else: ""

          {:error,
           Error.new(
             :manifest_invalid_field,
             "Sibling#{context_msg} at index #{idx}: #{inspect(key)} must be #{expected}, got #{inspect(v)}",
             %{index: idx, key: key, value: v, name: context[:name]}
           )}
        end

      :error ->
        context_msg = if name = context[:name], do: " #{inspect(name)}", else: ""

        {:error,
         Error.new(
           :manifest_invalid_field,
           "Sibling#{context_msg} at index #{idx} is missing required field #{inspect(key)}",
           %{index: idx, key: key, name: context[:name]}
         )}
    end
  end

  defp fetch_optional_sibling_field(map, key, predicate, expected, idx, context) do
    case Map.fetch(map, key) do
      {:ok, nil} ->
        {:ok, nil}

      {:ok, v} ->
        if predicate.(v) do
          {:ok, v}
        else
          context_msg = if name = context[:name], do: " #{inspect(name)}", else: ""

          {:error,
           Error.new(
             :manifest_invalid_field,
             "Sibling#{context_msg} at index #{idx}: optional #{inspect(key)} must be #{expected}, got #{inspect(v)}",
             %{index: idx, key: key, value: v, name: context[:name]}
           )}
        end

      :error ->
        {:ok, nil}
    end
  end

  defp check_inside_root(abs_sibling, abs_root, name, original_path, idx) do
    if inside?(abs_sibling, abs_root) do
      :ok
    else
      {:error,
       Error.new(
         :manifest_sibling_outside_root,
         "Sibling #{inspect(name)} at index #{idx} resolves to #{abs_sibling}, which is outside the workspace root #{abs_root}",
         %{index: idx, name: name, path: original_path, resolved: abs_sibling, root: abs_root}
       )}
    end
  end

  # The sibling must live strictly inside the root (not equal to it, not above it).
  defp inside?(abs_sibling, abs_root) do
    String.starts_with?(abs_sibling, abs_root <> "/")
  end

  defp check_unique(siblings, key_fun, kind, label) do
    siblings
    |> Enum.with_index()
    |> Enum.group_by(fn {sib, _idx} -> key_fun.(sib) end)
    |> Enum.find(fn {_k, pairs} -> length(pairs) > 1 end)
    |> case do
      nil ->
        :ok

      {dup, pairs} ->
        indices = Enum.map(pairs, fn {_sib, idx} -> idx end)

        details = %{
          duplicate: dup,
          count: length(pairs),
          indices: indices
        }

        # Include names/paths in the details for richer diagnostics
        details =
          if label == "name" do
            paths = Enum.map(pairs, fn {sib, _idx} -> sib.path end)
            Map.put(details, :paths, paths)
          else
            names = Enum.map(pairs, fn {sib, _idx} -> sib.name end)
            Map.put(details, :names, names)
          end

        {:error,
         Error.new(
           kind,
           "Duplicate sibling #{label}: #{inspect(dup)} appears #{length(pairs)} times " <>
             "at indices #{Enum.join(indices, ", ")}",
           details
         )}
    end
  end

  defp sibling_entry(%Sibling{name: name, path: path, origin: origin}) do
    sibling_entry(%{name: name, path: path, origin: origin})
  end

  defp sibling_entry(%{name: name, path: path} = sibling)
       when is_atom(name) and is_binary(path) do
    base = %{name: name, path: path}

    case Map.get(sibling, :origin) do
      origin when is_binary(origin) -> Map.put(base, :origin, origin)
      _ -> base
    end
  end

  defp format(root_declared, siblings) do
    rendered_siblings =
      siblings
      |> Enum.map(&format_sibling/1)
      |> Enum.join(",\n")

    """
    %{
      root: #{inspect(root_declared)},
      siblings: [
    #{rendered_siblings}
      ]
    }
    """
  end

  defp format_sibling(%{name: name, path: path} = sibling) do
    fields = [
      "name: #{inspect(name)}",
      "path: #{inspect(path)}"
    ]

    fields =
      case Map.get(sibling, :origin) do
        origin when is_binary(origin) -> fields ++ ["origin: #{inspect(origin)}"]
        _ -> fields
      end

    "    %{" <> Enum.join(fields, ", ") <> "}"
  end
end