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