lib/mix/tasks/foundry.diagram.generate.ex

defmodule Mix.Tasks.Foundry.Diagram.Generate do
  @shortdoc "Generate system map graph JSON from context introspection (INV-008)"

  @moduledoc """
  Builds the system map graph by calling `Foundry.Context.Introspector.build_all/1`,
  projecting the result into `Foundry.Diagram.SystemMap` (nodes, edges, clusters),
  and writing the output to `docs/diagrams/system_map.json`.

  The committed JSON file is the source of truth for the Phase 2 D3 renderer.
  INV-008 requires that the diagram is always current — CI runs this task with
  `--check` and fails if the output would differ from the committed file.

  ## Usage

      mix foundry.diagram.generate            # regenerate and write to disk
      mix foundry.diagram.generate --json     # emit JSON to stdout, no disk write
      mix foundry.diagram.generate --check    # exit 1 if output differs from committed

  ## Output file

  `docs/diagrams/system_map.json` relative to the project root.
  The directory is created if it does not exist.

  ## Check mode (CI)

  In check mode, the task generates the graph, compares it to the committed
  file, and exits 1 if they differ. The comparison ignores the `generated_at`
  timestamp field — only structural changes (nodes, edges, clusters) are
  considered meaningful differences.

  ## Graph construction

      Nodes   — one per module in context.all output
      Edges   — derived from:
                  - Ash relationships (belongs_to, has_many, has_one, many_to_many)
                  - Transfer rules: Transfer → Rule (applies_rule edge)
                  - Transfer steps that reference resources (transfer_step edge)
      Clusters — one per domain name (grouping the domain's nodes)

  ## test_coverage derivation

  `Node.test_coverage` is derived from `ModuleContext.test_coverage`:
    :none    — all three coverage booleans false
    :partial — at least one true, not all
    :full    — all three true
  """

  use Mix.Task

  alias Foundry.Diagram.SystemMap
  alias Foundry.Diagram.SystemMap.{Node, Edge, Cluster}
  alias Foundry.Context.Introspector

  @output_path "docs/diagrams/system_map.json"

  @impl Mix.Task
  def run(args) do
    app = Mix.Project.config()[:app]
    Application.put_env(app, :foundry_tasks_only, true)

    Mix.Task.run("app.start")

    project_root = File.cwd!()
    check_mode = "--check" in args
    stdout_only = "--json" in args

    all_context = Introspector.build_all(project_root: project_root)
    system_map = build_system_map(all_context)

    json = Jason.encode!(system_map, pretty: true)

    cond do
      check_mode ->
        run_check(json, project_root)

      stdout_only ->
        IO.puts(json)

      true ->
        write_output(json, project_root)
        Mix.shell().info("System map written to #{@output_path}")
    end
  end

  # ---------------------------------------------------------------------------
  # Graph construction
  # ---------------------------------------------------------------------------

  defp build_system_map(all_context) do
    flat_contexts = all_context |> Map.values() |> List.flatten()

    nodes = Enum.map(flat_contexts, &to_node/1)
    edges = flat_contexts |> Enum.flat_map(&to_edges/1) |> Enum.uniq()
    clusters = build_clusters(all_context)

    %SystemMap{
      nodes: nodes,
      edges: edges,
      clusters: clusters,
      generated_at: DateTime.utc_now() |> DateTime.to_iso8601()
    }
  end

  defp to_node(ctx) do
    %Node{
      id: ctx.module,
      module: ctx.module,
      type: ctx.type,
      domain: ctx.domain,
      label: ctx.module |> String.split(".") |> List.last(),
      sensitive: ctx.sensitive,
      has_compliance: ctx.compliance != [],
      test_coverage: derive_coverage(ctx.test_coverage),
      pending_migrations: ctx.pending_migrations
    }
  end

  # Derive :none | :partial | :full from the three-boolean test_coverage map.
  defp derive_coverage(%{property_tests: pt, scenario_tests: st, e2e_tests: et}) do
    case {pt, st, et} do
      {true, true, true} -> :full
      {false, false, false} -> :none
      _ -> :partial
    end
  end

  defp derive_coverage(_), do: :none

  defp to_edges(ctx) do
    resource_edges =
      ctx.related_resources
      |> Enum.map(fn target ->
        %Edge{from: ctx.module, to: target, kind: :belongs_to}
      end)

    rule_edges =
      ctx.rules
      |> Enum.map(fn rule ->
        %Edge{from: ctx.module, to: rule, kind: :applies_rule}
      end)

    step_edges =
      if ctx.type == :transfer do
        # Transfer step → resource edges: derive from steps that share names
        # with known resource modules. Phase 1 approximation — full step
        # introspection requires reading Reactor step DSL (Phase 2 enhancement).
        []
      else
        []
      end

    resource_edges ++ rule_edges ++ step_edges
  end

  defp build_clusters(all_context) do
    all_context
    |> Enum.map(fn {domain_name, contexts} ->
      node_ids = Enum.map(contexts, & &1.module)

      %Cluster{
        id: domain_name,
        label: domain_name,
        node_ids: node_ids
      }
    end)
  end

  # ---------------------------------------------------------------------------
  # Output / check
  # ---------------------------------------------------------------------------

  defp write_output(json, project_root) do
    out_path = Path.join(project_root, @output_path)
    out_path |> Path.dirname() |> File.mkdir_p!()
    File.write!(out_path, json)
  end

  defp run_check(fresh_json, project_root) do
    committed_path = Path.join(project_root, @output_path)

    committed_json =
      case File.read(committed_path) do
        {:ok, content} ->
          content

        {:error, :enoent} ->
          Mix.raise("""
          #{@output_path} does not exist.
          Run `mix foundry.diagram.generate` to create it, then commit the file.
          """)
      end

    fresh_map = Jason.decode!(fresh_json) |> Map.delete("generated_at")
    committed_map = Jason.decode!(committed_json) |> Map.delete("generated_at")

    if fresh_map == committed_map do
      Mix.shell().info("System map is current. No diff.")
    else
      Mix.shell().error("""
      System map is stale — the committed #{@output_path} does not match
      the current codebase.

      Run `mix foundry.diagram.generate` and commit the result.
      """)

      exit({:shutdown, 1})
    end
  end
end