Skip to main content

lib/mix/tasks/certitudo.ex

defmodule Mix.Tasks.Certitudo do
  @moduledoc """
  Run tests, capture coverage, and compare against a previous snapshot.

  Each run produces an **impressio** — the current coverage snapshot. If an
  identical impressio already exists (same fingerprint), it is reused and
  labelled *Deja vu*. The snapshot we compare against is the **retentio** —
  either the most recent prior run or an explicit `--since` target.

  Output written to `.certitudo/<run_id>/`:
  - `snapshot.json` — the impressio
  - `coverage.coverdata` — raw Erlang cover data
  - `diff.prev.json` — differentia between retentio and impressio (when available)

  ```
  mix certitudo
  mix certitudo --example
  mix certitudo --no-color
  mix certitudo --label scenario -- --only scenario
  mix certitudo --from-coverdata cover/covprobe.coverdata
  mix certitudo --no-auto-diff
  mix certitudo --since 20260605T190458Z
  ```

  Pass `--no-color` to disable ANSI colors in Certitudo's own output.
  Pass `--example` to render the bundled demonstration diff with all statuses,
  without running tests or comparing snapshots.

  ## Related tasks

  - `mix certitudo.discrimen` — compare any two snapshots explicitly
  - `mix certitudo.inspectio` — inspect and prune `.certitudo/` artifacts
  - `mix certitudo.lacunae` — show uncovered lines
  - `mix certitudo.speculum` — print a snapshot as a coverage table
  """

  use Mix.Task

  alias Certitudo.Coverage
  alias Certitudo.Coverage.Runtime

  @shortdoc "Create coverage snapshot (+ optional diff.prev)"

  @impl Mix.Task
  def run(args) do
    {opts, passthrough, _invalid} =
      OptionParser.parse(args,
        strict: [
          example: :boolean,
          label: :string,
          run_id: :string,
          since: :string,
          from_coverdata: :string,
          auto_diff: :boolean,
          color: :boolean,
          prefix: :keep
        ],
        allow_nonexistent_atoms: true
      )

    shell_info("Signa obductionis quaerimus", opts)

    if Keyword.get(opts, :example, false) do
      print_example(opts)
    else
      run_id = opts[:run_id] || Coverage.timestamp_id()

      opts =
        opts
        |> Keyword.put(:run_id, run_id)
        |> put_runtime_defaults()

      coverdata_path =
        case opts[:from_coverdata] do
          nil ->
            run_tests_and_export_coverdata!(run_id, passthrough)

          path ->
            path
        end

      result = Certitudo.obducere(coverdata_path, opts)

      shell_info("", opts)

      case result.kind do
        :new ->
          "impressio: #{Path.basename(result.impressio_run_dir)}"
          |> shell_info(opts)

        :known ->
          "Deja vu! seen at #{Path.basename(result.impressio_run_dir)}"
          |> shell_info(opts, :yellow)

          "impressio: #{Path.basename(result.impressio_run_dir)}"
          |> shell_info(opts)
      end

      print_diff(result.diff, opts)

      "Certitudo: #{coverage_label(result.snapshot["totals"]["coverage_percent"])} for #{result.snapshot["totals"]["modules"]} modules"
      |> shell_info(opts)

      shell_info("", opts)
      maybe_hint_since(Path.basename(result.impressio_run_dir), opts)
    end
  end

  defp coverage_label(nil), do: "coverage not measured (no modules matched)"
  defp coverage_label(percent), do: "#{percent}%"

  defp run_tests_and_export_coverdata!(run_id, passthrough) do
    export_name = "export_#{run_id}"

    test_args =
      ["test", "--cover", "--export-coverage", export_name] ++ passthrough

    {_, status} =
      System.cmd("mix", test_args,
        into: IO.stream(:stdio, :line),
        stderr_to_stdout: true,
        env: [{"MIX_ENV", "test"}]
      )

    if status != 0 do
      Mix.raise("mix test failed with status #{status}")
    end

    Path.join("cover", "#{export_name}.coverdata")
  end

  defp put_runtime_defaults(opts) do
    app = Mix.Project.config()[:app]
    build_path = Mix.Project.build_path()
    beam_dirs = [Path.join([build_path, "lib", to_string(app), "ebin"])]

    prefixes = Keyword.get_values(opts, :prefix)

    own_modules =
      case prefixes do
        # no explicit --prefix: scope to what's actually compiled for this
        # app (Runtime.own_module_names/1 — physical .beam files under
        # beam_dirs, not a guess from the app name).
        [] -> Runtime.own_module_names(beam_dirs)
        # explicit --prefix given: caller's intent is authoritative, don't
        # additively widen it back out with every compiled module.
        _ -> MapSet.new()
      end

    ignore_modules =
      case Mix.Project.config()[:test_coverage] do
        cfg when is_list(cfg) -> Keyword.get(cfg, :ignore_modules, [])
        _ -> []
      end

    opts
    |> Keyword.put(:prefixes, prefixes)
    |> Keyword.put(:own_modules, own_modules)
    |> Keyword.put(:ignore_modules, ignore_modules)
    |> Keyword.put(:beam_dirs, beam_dirs)
  end

  defp print_example(opts) do
    diff =
      example_diff_path()
      |> File.read!()
      |> Jason.decode!()

    shell_info("", opts)
    print_auto_diff_modules(diff, opts)
    shell_info(example_summary(), opts)
    shell_info("", opts)
  end

  defp example_diff_path do
    Path.join([File.cwd!(), "examples", "example.diff.json"])
  end

  defp example_summary do
    "\nCertitudo: 89.23% for 42 modules"
  end

  defp print_diff({:ok, %{retentio_path: retentio_path, diff: diff}}, opts) do
    "retentio: #{Path.basename(Path.dirname(retentio_path))}"
    |> shell_info(opts)

    shell_info("", opts)
    print_auto_diff_modules(diff, opts)
    shell_info("", opts)
  end

  defp print_diff({:since_not_found, since}, opts) do
    shell_info(
      "warning: --since #{since} not found, skipping diff",
      opts,
      :yellow
    )
  end

  defp print_diff(:skipped, _opts), do: :ok

  defp maybe_hint_since(run_id, opts) do
    if is_nil(Keyword.get(opts, :since)) do
      shell_info(
        "since: to compare future runs against this snapshot:",
        opts
      )

      shell_info("  mix certitudo --since #{run_id}", opts)
    end
  end

  defp print_auto_diff_modules(%{"changed_modules" => changed}, opts)
       when is_list(changed) do
    if changed == [] do
      shell_info("Identical: coverage not changed", opts, :blue)
    else
      shell_info("Differentia (#{length(changed)} modules):", opts, :blue)

      Enum.each(changed, fn mod ->
        module = mod["module"]
        source_changed = mod["source_changed"] || false
        block_diffs = mod["block_diffs"] || []

        shell_info(
          "  #{module}: block_diffs=#{length(block_diffs)} source_changed=#{source_changed}",
          opts,
          :blue
        )

        print_block_diffs(block_diffs, opts)
      end)
    end
  end

  defp print_block_diffs(block_diffs, opts) when is_list(block_diffs) do
    block_diffs
    |> Enum.filter(&printable_block_diff?/1)
    |> Enum.take(20)
    |> Enum.each(fn diff ->
      color = status_color(diff["status"])

      shell_info(
        "    #{diff["status"]}: #{diff_range(diff)}",
        opts,
        color
      )

      print_residue(diff["residue"], opts)
    end)
  end

  defp printable_block_diff?(diff) do
    diff["status"] != "unchanged" and diff_range(diff) != "none"
  end

  defp print_residue(%{"entries" => entries}, opts) when is_list(entries) do
    Enum.each(entries, fn entry ->
      status = entry["status"]
      line = entry["right"] || entry["left"]

      shell_info(
        "      #{line_symbol(status)} #{line["number"]} | #{line["text"]}",
        opts,
        status_color(status)
      )
    end)
  end

  defp print_residue(_residue, _opts), do: :ok

  defp block_range(nil), do: "none"

  defp block_range(%{"range" => %{"first" => first, "last" => last}}),
    do: "#{first}-#{last}"

  defp diff_range(%{"left" => left, "right" => right}) do
    left_range = block_range(left)
    right_range = block_range(right)

    if left_range == right_range do
      left_range
    else
      "#{left_range} -> #{right_range}"
    end
  end

  defp status_color("new_covered"), do: :green
  defp status_color("existing_coverage_gained"), do: :green
  defp status_color("new_uncovered"), do: :orange
  defp status_color("removed_uncovered"), do: :yellow
  defp status_color("removed_covered"), do: :red
  defp status_color("existing_coverage_lost"), do: :red
  defp status_color("existing_coverage_mixed"), do: :purple
  defp status_color("moved_unchanged"), do: :magenta
  defp status_color("moved_coverage_changed"), do: :purple
  defp status_color("ambiguous_moved"), do: :cyan

  defp status_color(status),
    do: Mix.raise("unknown coverage status: #{inspect(status)}")

  defp line_symbol("new_covered"), do: "+"
  defp line_symbol("new_uncovered"), do: "+"
  defp line_symbol("removed_covered"), do: "-"
  defp line_symbol("removed_uncovered"), do: "-"
  defp line_symbol("existing_coverage_gained"), do: "~"
  defp line_symbol("existing_coverage_lost"), do: "~"

  defp line_symbol(status),
    do: Mix.raise("unknown coverage line status: #{inspect(status)}")

  defp shell_info(text, opts, color \\ :blue)
       when is_binary(text) and is_list(opts) do
    case Keyword.get(opts, :color, true) do
      false ->
        Mix.shell().info(text)

      true ->
        Mix.shell().info(colorize(text, color))
    end
  end

  defp colorize(text, color) when is_atom(color) do
    color
    |> ansi_color()
    |> Kernel.++([text, :reset])
    |> IO.ANSI.format()
    |> IO.iodata_to_binary()
  end

  defp ansi_color(:orange), do: ["\e[38;5;208m"]
  defp ansi_color(:purple), do: ["\e[38;5;183m"]
  defp ansi_color(color), do: [color]
end