Skip to main content

lib/mix/tasks/crap.ex

defmodule Mix.Tasks.Crap do
  @shortdoc "Print CRAP scores for project source"

  @moduledoc """
  Prints CRAP scores for Elixir source files and fails when results exceed the configured threshold.

  Usage: mix crap

      mix test --cover --export-coverage default
      mix crap
      mix crap --coverdata path/to/file.coverdata
      mix crap --max-score 30
      mix crap --path path/to/source
      mix crap --verbose

  Coverage workflow: `mix crap` consumes persisted Mix/Erlang coverage data.
  The default path is `cover/default.coverdata`, produced by
  `mix test --cover --export-coverage default`. Plain `mix test --cover` prints
  a coverage report, but does not leave importable coverage data for a later
  `mix crap` run.

  The task scans root `lib/**/*.ex` files by default (default: lib). Use
  `--path PATH` to scan another source directory as `PATH/**/*.ex`. It skips
  valid files with no analyzable function or macro bodies, such as callback-only
  protocols and behaviour modules.
  The default maximum CRAP score is 30 (default: 30). Use
  `--max-score N` to override it. The task fails when any function exceeds the
  threshold or has score calculation errors. Missing function coverage is scored as 0%.
  Missing coverdata input is a usage error when analyzable functions exist. Use
  `--verbose` to print the full scored table on passing runs.
  """

  use Mix.Task
  use Boundary, classify_to: ExCrap.Mix

  @impl Mix.Task
  def run(args) do
    case OptionParser.parse(args,
           strict: [
             coverdata: :string,
             max_score: :string,
             path: :string,
             help: :boolean,
             verbose: :boolean
           ]
         ) do
      {opts, [], []} ->
        if opts[:help] do
          Mix.shell().info(@moduledoc)
        else
          run_report(opts)
        end

      {_opts, [arg | _args], []} ->
        Mix.raise("Unexpected argument: #{arg}")

      {_opts, _args, [{option, _value} | _]} ->
        Mix.raise("Unknown option: #{option}")
    end
  end

  @doc false
  def shortdoc, do: @shortdoc

  @doc false
  def moduledoc, do: @moduledoc

  defp run_report(opts) do
    root = File.cwd!()

    with {:ok, max_score} <- max_score(opts),
         {:ok, coverdata_path, coverdata_source} <- coverdata_path(opts, root) do
      source_path = Keyword.get(opts, :path, "lib")

      case ExCrap.project_report(root, coverdata_path, source_path: source_path) do
        {:ok, rows} ->
          Mix.shell().info(
            ExCrap.render_report(rows, max_score: max_score, verbose: opts[:verbose])
          )

          enforce_threshold!(rows, max_score)

        error ->
          handle_report_error!(error, root, coverdata_source)
      end
    else
      {:error, {:invalid_max_score, value}} ->
        Mix.raise("Invalid --max-score: #{value}. Expected a positive number.")
    end
  end

  defp handle_report_error!({:no_source_files, pattern}, _root, _coverdata_source) do
    Mix.shell().info("No root #{pattern} files found.")
  end

  defp handle_report_error!({:no_analyzable_functions, pattern}, _root, _coverdata_source) do
    Mix.shell().info("No analyzable function bodies found in root #{pattern} files.")
  end

  defp handle_report_error!({:error, {:coverdata_unreadable, path}}, root, :default) do
    Mix.shell().info("""
    No coverage data found at cover/default.coverdata.

    Run persisted coverage first:
        mix test --cover --export-coverage default

    Then run:
        mix crap

    Plain mix test --cover prints a coverage report, but does not leave importable coverage data for a later mix crap run.
    If coverage data is elsewhere, run: mix crap --coverdata path/to/file.coverdata
    """)

    Mix.raise("Coverage data is missing: #{Path.relative_to(path, root)}")
  end

  defp handle_report_error!({:error, {:coverdata_unreadable, path}}, _root, _coverdata_source) do
    Mix.raise("Coverage data is unreadable: #{path}")
  end

  defp handle_report_error!({:error, {path, reason}}, root, _coverdata_source) do
    Mix.raise("Unable to analyze source file #{Path.relative_to(path, root)}: #{inspect(reason)}")
  end

  defp handle_report_error!({:error, reason}, _root, _coverdata_source) do
    Mix.raise("Unable to calculate CRAP report: #{inspect(reason)}")
  end

  defp coverdata_path(opts, root) do
    case Keyword.fetch(opts, :coverdata) do
      {:ok, path} -> {:ok, Path.expand(path, root), :explicit}
      :error -> default_coverdata_path(root)
    end
  end

  defp default_coverdata_path(root) do
    {:ok, Path.join(root, "cover/default.coverdata"), :default}
  end

  defp max_score(opts) do
    case Keyword.fetch(opts, :max_score) do
      {:ok, value} -> parse_max_score(value)
      :error -> {:ok, 30.0}
    end
  end

  defp parse_max_score(value) do
    case Float.parse(value) do
      {score, ""} when score > 0 -> {:ok, score}
      _other -> {:error, {:invalid_max_score, value}}
    end
  end

  defp enforce_threshold!(rows, max_score) do
    failures = ExCrap.failures(rows, max_score)

    if !Enum.all?(failures, fn {_key, rows} -> rows == [] end) do
      Mix.raise(failure_message(failures, max_score))
    end
  end

  defp failure_message(failures, max_score) do
    [
      "CRAP threshold failed: max_score=#{format_number(max_score)}",
      failure_section("High scores", failures.high_scores, &high_score_line/1),
      failure_section("Score calculation errors", failures.score_errors, &status_line/1)
    ]
    |> Enum.reject(&is_nil/1)
    |> Enum.join("\n")
  end

  defp failure_section(_title, [], _line_fun), do: nil

  defp failure_section(title, rows, line_fun) do
    lines = Enum.map(rows, line_fun)

    (["#{title}: #{length(rows)}"] ++ lines)
    |> Enum.join("\n")
  end

  defp high_score_line(row) do
    "  #{row_identity(row)} score=#{format_number(row.score)}"
  end

  defp status_line(row) do
    "  #{row_identity(row)} status=#{format_status(row.status)}"
  end

  defp row_identity(row) do
    "#{row.file} #{inspect(row.module)}.#{row.function}/#{row.arity}"
  end

  defp format_status({:error, reason}), do: "error: #{reason}"
  defp format_status(status), do: to_string(status)

  defp format_number(number), do: :erlang.float_to_binary(number * 1.0, decimals: 2)
end