lib/mix/tasks/relyra.conformance.ex

defmodule Mix.Tasks.Relyra.Conformance do
  @moduledoc """
  Generates the checked-in conformance report from executable manifest state.

      mix relyra.conformance
      mix relyra.conformance --output tmp/CONFORMANCE.md
      mix relyra.conformance --check
  """
  @shortdoc "Generate or drift-check CONFORMANCE.md."

  use Mix.Task

  alias Relyra.ConformanceFixtures

  @conformance_manifest "priv/conformance/sp_manifest.json"
  @security_manifest "priv/security_corpus.json"
  @default_output "CONFORMANCE.md"

  @impl true
  def run(args) do
    Mix.Task.run("app.start")

    {opts, _argv, invalid} =
      OptionParser.parse(args,
        strict: [output: :string, check: :boolean],
        aliases: [o: :output]
      )

    if invalid != [] do
      Mix.raise("invalid options: #{Enum.map_join(invalid, ", ", &elem(&1, 0))}")
    end

    output_path = output_path(opts)
    contents = render_report(load_report_rows())

    if Keyword.get(opts, :check, false) do
      check_report!(output_path, contents)
    else
      File.write!(output_path, contents)
      Mix.shell().info("relyra.conformance: wrote generated report to #{output_path}")
      :ok
    end
  end

  defp output_path(opts) do
    opts
    |> Keyword.get(:output, @default_output)
    |> Path.expand()
  end

  defp load_report_rows do
    %{
      conformance_rows: ConformanceFixtures.load_manifest!(@conformance_manifest),
      security_rows: ConformanceFixtures.load_manifest!(@security_manifest)
    }
  end

  defp check_report!(output_path, contents) do
    case File.read(output_path) do
      {:ok, existing} when existing == contents ->
        Mix.shell().info("relyra.conformance: #{output_path} matches generated manifest state")
        :ok

      {:ok, _existing} ->
        Mix.raise(
          "relyra.conformance drift detected for #{output_path}; rerun mix relyra.conformance"
        )

      {:error, :enoent} ->
        Mix.raise("relyra.conformance --check target is missing: #{output_path}")

      {:error, reason} ->
        Mix.raise(
          "relyra.conformance could not read #{output_path}: #{:file.format_error(reason)}"
        )
    end
  end

  defp render_report(%{conformance_rows: conformance_rows, security_rows: security_rows}) do
    [
      "# Conformance",
      "",
      "Generated from executable manifest state in `priv/conformance/sp_manifest.json` and `priv/security_corpus.json`.",
      "",
      "## Requirement Summary",
      "",
      requirement_summary_table(conformance_rows),
      "",
      cve_summary_lines(security_rows),
      "",
      "## CONF-01 SP Conformance Coverage",
      "",
      conformance_rows_table(conformance_rows),
      "",
      "## CVE-REG-01 Regression Coverage",
      "",
      security_rows_table(security_rows),
      ""
    ]
    |> Enum.join("\n")
  end

  defp requirement_summary_table(conformance_rows) do
    {requirement_id, counts} = requirement_summary_row("CONF-01", conformance_rows)

    [
      "| Requirement | pass | reject | unsupported | deferred | total |",
      "| --- | --- | --- | --- | --- | --- |",
      "| #{requirement_id} | #{counts["pass"]} | #{counts["reject"]} | #{counts["unsupported"]} | #{counts["deferred"]} | #{counts["total"]} |"
    ]
    |> Enum.join("\n")
  end

  defp cve_summary_lines(security_rows) do
    families =
      security_rows
      |> Enum.map(&Map.fetch!(&1, "family"))
      |> Enum.uniq()
      |> Enum.join(", ")

    [
      "- `CVE-REG-01` fixtures pinned: #{length(security_rows)}",
      "- Families covered: #{families}"
    ]
    |> Enum.join("\n")
  end

  defp requirement_summary_row(requirement_id, rows) do
    counts =
      Enum.reduce(
        rows,
        %{"pass" => 0, "reject" => 0, "unsupported" => 0, "deferred" => 0, "total" => 0},
        fn row, acc ->
          status = Map.fetch!(row, "status")

          acc
          |> Map.update!(status, &(&1 + 1))
          |> Map.update!("total", &(&1 + 1))
        end
      )

    {requirement_id, counts}
  end

  defp conformance_rows_table(rows) do
    [
      "| Scope | status | profile | rule | binding | provenance | notes |",
      "| --- | --- | --- | --- | --- | --- | --- |"
      | Enum.map(rows, fn row ->
          "| #{Map.fetch!(row, "id")} | #{Map.fetch!(row, "status")} | #{Map.fetch!(row, "profile")} | #{Map.fetch!(row, "rule_id")} | #{Map.fetch!(row, "binding")} | #{provenance_cell(row)} | #{escape_cell(Map.get(row, "notes", ""))} |"
        end)
    ]
    |> Enum.join("\n")
  end

  defp security_rows_table(rows) do
    [
      "| Fixture | family | class | expected rejection | provenance | notes |",
      "| --- | --- | --- | --- | --- | --- |"
      | Enum.map(rows, fn row ->
          "| #{Map.fetch!(row, "id")} | #{Map.fetch!(row, "family")} | #{Map.fetch!(row, "class")} | #{Map.fetch!(row, "expected_error_type")} | #{provenance_cell(row)} | #{escape_cell(Map.get(row, "notes", ""))} |"
        end)
    ]
    |> Enum.join("\n")
  end

  defp provenance_cell(row) do
    provenance = Map.fetch!(row, "provenance")
    source = Map.get(provenance, "source", "unknown")
    section = Map.get(provenance, "section")
    kind = Map.get(provenance, "kind")

    [source, section, kind]
    |> Enum.reject(&is_nil/1)
    |> Enum.join(" / ")
    |> escape_cell()
  end

  defp escape_cell(value) do
    value
    |> to_string()
    |> String.replace("|", "\\|")
    |> String.replace("\n", " ")
  end
end