lib/graft/workspace/diff.ex

defmodule Graft.Workspace.Diff do
  @moduledoc """
  Compare two workspace snapshots and produce a structural delta.

  The diff is **declarative**: it describes what changed, not how to
  effect the change. That is the job of `Graft.Plan`.

  ## Design

  * Two snapshots taken at different times, or a snapshot vs a desired state.
  * Produces a `Delta` with `:added`, `:removed`, `:changed`, `:same` buckets.
  * Each entry is a path + before + after value (or nil for additions/removals).

  ## Use cases

  * `dry-run` previews: "what would link.on do to the workspace?"
  * Drift detection: "has the workspace changed since last snapshot?"
  * Plan generation: `Graft.Plan.from_delta/2`
  * Audit logging: append-only delta log
  """

  alias Graft.Workspace

  defmodule Delta do
    @moduledoc "A structural diff between two workspace snapshots."

    @type entry :: %{
            path: [atom() | String.t()],
            before: any(),
            after: any()
          }

    @type t :: %__MODULE__{
            from_id: String.t(),
            to_id: String.t(),
            generated_at: DateTime.t(),
            added: [entry()],
            removed: [entry()],
            changed: [entry()],
            same: [entry()]
          }

    defstruct [:from_id, :to_id, :generated_at, added: [], removed: [], changed: [], same: []]
  end

  alias Graft.Workspace

  defmodule Delta do
    @moduledoc "A structural diff between two workspace snapshots."

    @type entry :: %{
            path: [atom() | String.t()],
            before: any(),
            after: any()
          }

    @type t :: %__MODULE__{
            from_id: String.t(),
            to_id: String.t(),
            generated_at: DateTime.t(),
            added: [entry()],
            removed: [entry()],
            changed: [entry()],
            same: [entry()]
          }

    defstruct [:from_id, :to_id, :generated_at, added: [], removed: [], changed: [], same: []]
  end

  @doc """
  Diff two workspace snapshots.

  Returns a `Delta` describing every structural change.
  """
  @spec diff(Workspace.t(), Workspace.t()) :: Delta.t()
  def diff(%Workspace{} = before, %Workspace{} = after_snapshot) do
    repos_delta = diff_repos(before.repos, after_snapshot.repos)
    deps_delta = diff_deps(before.deps, after_snapshot.deps)
    links_delta = diff_links(before.links, after_snapshot.links)
    git_delta = diff_git(before.git, after_snapshot.git)

    all_entries = repos_delta ++ deps_delta ++ links_delta ++ git_delta

    {added, rest} = Enum.split_with(all_entries, &(&1.before == nil and &1.after != nil))
    {removed, rest} = Enum.split_with(rest, &(&1.before != nil and &1.after == nil))
    {changed, same} = Enum.split_with(rest, &(&1.before != &1.after))

    %Delta{
      from_id: before.id,
      to_id: after_snapshot.id,
      generated_at: DateTime.utc_now(),
      added: added,
      removed: removed,
      changed: changed,
      same: same
    }
  end

  @doc """
  True iff the delta contains no added, removed, or changed entries.
  """
  @spec empty?(Delta.t()) :: boolean()
  def empty?(%Delta{added: [], removed: [], changed: []}), do: true
  def empty?(_), do: false

  @doc """
  Return only entries affecting a specific repo.
  """
  @spec for_repo(Delta.t(), atom()) :: [Delta.entry()]
  def for_repo(%Delta{} = delta, repo_name) do
    (delta.added ++ delta.removed ++ delta.changed)
    |> Enum.filter(fn entry ->
      repo_in_path?(entry.path, repo_name)
    end)
  end

  ## ─── Repo diff ──────────────────────────────────────────────────────

  defp diff_repos(before_repos, after_snapshot_repos) do
    before_map = Map.new(before_repos, &{&1.name, &1})
    after_map = Map.new(after_snapshot_repos, &{&1.name, &1})

    all_keys = (Map.keys(before_map) ++ Map.keys(after_map)) |> Enum.uniq() |> Enum.sort()

    Enum.flat_map(all_keys, fn name ->
      b = Map.get(before_map, name)
      a = Map.get(after_map, name)

      cond do
        is_nil(b) ->
          [%{path: [:repos, name], before: nil, after: a}]

        is_nil(a) ->
          [%{path: [:repos, name], before: b, after: nil}]

        b != a ->
          diff_repo_fields(name, b, a)

        true ->
          [%{path: [:repos, name], before: b, after: a}]
      end
    end)
  end

  defp diff_repo_fields(name, before, after_val) do
    fields = [:path, :absolute_path, :exists?, :has_mix_exs?]

    Enum.flat_map(fields, fn field ->
      b = Map.get(before, field)
      a = Map.get(after_val, field)

      if b != a do
        [%{path: [:repos, name, field], before: b, after: a}]
      else
        []
      end
    end)
  end

  ## ─── Dep diff ───────────────────────────────────────────────────────

  defp diff_deps(before_deps, after_snapshot_deps) do
    before_map = index_deps(before_deps)
    after_map = index_deps(after_snapshot_deps)

    all_keys = (Map.keys(before_map) ++ Map.keys(after_map)) |> Enum.uniq() |> Enum.sort()

    Enum.flat_map(all_keys, fn key ->
      b = Map.get(before_map, key)
      a = Map.get(after_map, key)

      cond do
        is_nil(b) -> [%{path: [:deps, key], before: nil, after: a}]
        is_nil(a) -> [%{path: [:deps, key], before: b, after: nil}]
        b != a -> [%{path: [:deps, key], before: b, after: a}]
        true -> [%{path: [:deps, key], before: b, after: a}]
      end
    end)
  end

  defp index_deps(deps) do
    Map.new(deps, &{{&1.repo, &1.app}, &1})
  end

  ## ─── Link diff ──────────────────────────────────────────────────────

  defp diff_links(before_links, after_snapshot_links) do
    before_map = index_links(before_links)
    after_map = index_links(after_snapshot_links)

    all_keys = (Map.keys(before_map) ++ Map.keys(after_map)) |> Enum.uniq() |> Enum.sort()

    Enum.flat_map(all_keys, fn key ->
      b = Map.get(before_map, key)
      a = Map.get(after_map, key)

      cond do
        is_nil(b) -> [%{path: [:links, key], before: nil, after: a}]
        is_nil(a) -> [%{path: [:links, key], before: b, after: nil}]
        b != a -> [%{path: [:links, key], before: b, after: a}]
        true -> [%{path: [:links, key], before: b, after: a}]
      end
    end)
  end

  defp index_links(links) do
    Map.new(links, &{{&1.repo, &1.dep}, &1})
  end

  ## ─── Git state diff ─────────────────────────────────────────────────

  defp diff_git(before_git, after_snapshot_git) do
    before_map = Map.new(before_git, &{&1.repo, &1})
    after_map = Map.new(after_snapshot_git, &{&1.repo, &1})

    all_keys = (Map.keys(before_map) ++ Map.keys(after_map)) |> Enum.uniq() |> Enum.sort()

    Enum.flat_map(all_keys, fn name ->
      b = Map.get(before_map, name)
      a = Map.get(after_map, name)

      cond do
        is_nil(b) ->
          [%{path: [:git, name], before: nil, after: a}]

        is_nil(a) ->
          [%{path: [:git, name], before: b, after: nil}]

        b != a ->
          diff_git_fields(name, b, a)

        true ->
          []
      end
    end)
  end

  defp diff_git_fields(name, before, after_val) do
    fields = [:branch, :head_sha, :upstream, :ahead, :behind, :dirty?, :in_progress, :error]

    Enum.flat_map(fields, fn field ->
      b = Map.get(before, field)
      a = Map.get(after_val, field)

      if b != a do
        [%{path: [:git, name, field], before: b, after: a}]
      else
        []
      end
    end)
  end

  ## ─── Helpers ────────────────────────────────────────────────────────

  defp repo_in_path?([:repos, repo | _], repo), do: true
  defp repo_in_path?([:deps, {repo, _} | _], repo), do: true
  defp repo_in_path?([:links, {repo, _} | _], repo), do: true
  defp repo_in_path?([:git, repo | _], repo), do: true
  defp repo_in_path?(_, _), do: false
end