lib/foundry/context/project_context.ex

defmodule Foundry.Context.ProjectContext do
  @moduledoc """
  Provides convenient access to project context (nodes, edges) without running mix tasks.

  Used by LiveView and other runtime contexts to build or fetch project graphs.
  """

  @doc """
  Builds the complete project context from a project root directory.

  Returns `{:ok, context_map}` or `{:error, reason}`.

  The context_map includes:
  - nodes: list of NodeEntry structs
  - edges: list of EdgeEntry structs
  - generated_at: timestamp
  - project, project_type, domain_type: manifest data
  - spec_kit: spec-kit index
  - scenarios: verified executable test traces
  """
  def build(project_root) do
    try do
      build_map(project_root)
    rescue
      e ->
        {:error, Exception.message(e)}
    end
  end

  def build_map(project_root) do
    case Foundry.Manifest.Parser.read(project_root) do
      {:ok, manifest} ->
        {nodes, edges} = Foundry.Context.GraphBuilder.build(project_root, manifest)
        spec_kit = Foundry.Context.SpecKitIndexBuilder.build(project_root)
        report = Foundry.Context.ScenarioCache.get()
        scenarios = cached_or_extracted_scenarios(report, project_root, nodes)
        nodes_with_scenarios = enrich_nodes_with_scenarios(nodes, scenarios)

        {:ok,
         %{
           generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),
           project: Keyword.get(manifest, :project_name, ""),
           project_type: Keyword.get(manifest, :project_type, "standard"),
           domain_type: Keyword.get(manifest, :domain_type, ""),
           nodes: nodes_with_scenarios,
           edges: edges,
           spec_kit: spec_kit,
           scenarios: scenarios
         }}

      {:error, reason} ->
        {:error, reason}
    end
  end

  defp enrich_nodes_with_scenarios(nodes, scenarios) do
    Enum.map(nodes, fn node ->
      matching_scenarios = Enum.filter(scenarios, &(node.module in &1.nodes))
      scenario_ids = Enum.map(matching_scenarios, & &1.id)

      # Update test_coverage with scenario counts and categories
      updated_coverage =
        update_test_coverage_with_scenarios(node.test_coverage, matching_scenarios)

      %{
        node
        | scenario_refs: scenario_ids,
          test_coverage: updated_coverage
      }
    end)
  end

  defp cached_or_extracted_scenarios(%{scenarios: scenarios}, _project_root, _nodes)
       when is_list(scenarios) and scenarios != [] do
    scenarios
  end

  defp cached_or_extracted_scenarios(_report, project_root, nodes) do
    Foundry.Context.ScenarioExtractor.extract(project_root, nodes)
  end

  defp update_test_coverage_with_scenarios(coverage, scenarios) do
    coverage
    |> Map.put(
      :scenario_tests,
      Enum.any?(scenarios, &(&1.category in [:invariant, :state_machine]))
    )
    |> Map.put(:e2e_tests, Enum.any?(scenarios, &(&1.category == :compliance)))
    |> Map.put(:property_tests, Enum.any?(scenarios, &(&1.category == :property)))
    |> Map.put(:scenario_count, Enum.count(scenarios))
  end

  @doc """
  Builds a single node by module ID from a project root.

  Returns `{:ok, NodeEntry}` or `{:error, reason}`.
  """
  def build_one(project_root, module_id) when is_binary(module_id) do
    try do
      case Foundry.Manifest.Parser.read(project_root) do
        {:ok, manifest} ->
          module =
            try do
              String.to_existing_atom("Elixir." <> module_id)
            rescue
              _ -> raise "Module not found: #{module_id}"
            end

          unless Code.ensure_loaded?(module) do
            raise "Module not found: #{module_id}"
          end

          {:ok, pending_set} = Foundry.Context.PendingMigrations.check(project_root)

          info = Foundry.SparkMeta.walk(module)
          pending = Foundry.Context.PendingMigrations.pending?(module, pending_set)
          node = Foundry.Context.NodeBuilder.build(info, manifest, pending)

          {:ok, node}

        {:error, reason} ->
          {:error, reason}
      end
    rescue
      e ->
        {:error, Exception.message(e)}
    end
  end
end