GitHub - minikin/cargo-crap: Change Risk Anti-Patterns (CRAP) metric for Rust projects (original) (raw)

v0.3.0 crates.io docs.rs CRAP

Compute the CRAP (Change Risk Anti-Patterns) metric for Rust projects.

CRAP combines cyclomatic complexity and test coverage into a single number that is high when code is both hard to understand and poorly tested — i.e. where bugs love to hide. The metric was introduced by Savoia & Evans in 2007 and was originally implemented for Java (Crap4j) and .NET (NDepend).cargo-crap brings it to the Rust ecosystem.

CRAP(m) = comp(m)² × (1 − cov(m)/100)³ + comp(m)

A few properties worth internalizing before you use the output:

Install

Via cargo binstall (downloads the right pre-built binary automatically):

cargo binstall cargo-crap

From source (requires Rust stable ≥ 1.88):

From the AUR:

Pre-built binary (manual download):

macOS (Apple Silicon)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/minikin/cargo-crap/releases/latest/download/cargo-crap-aarch64-apple-darwin.tar.gz | tar xz -C ~/.cargo/bin

macOS (Intel)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/minikin/cargo-crap/releases/latest/download/cargo-crap-x86_64-apple-darwin.tar.gz | tar xz -C ~/.cargo/bin

Linux (x86_64)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/minikin/cargo-crap/releases/latest/download/cargo-crap-x86_64-unknown-linux-gnu.tar.gz | tar xz -C ~/.cargo/bin

Linux (aarch64)

curl --proto '=https' --tlsv1.2 -LsSf https://github.com/minikin/cargo-crap/releases/latest/download/cargo-crap-aarch64-unknown-linux-gnu.tar.gz | tar xz -C ~/.cargo/bin

Windows: download cargo-crap-x86_64-pc-windows-msvc.zip from the latest release and extract cargo-crap.exe into a directory on your PATH.

Quick start

1. Generate an LCOV coverage report.

cargo llvm-cov --lcov --output-path lcov.info

2. Score every function.

cargo crap --lcov lcov.info

3. Gate CI on the threshold.

cargo crap --lcov lcov.info --fail-above

4. Whole-workspace analysis (monorepos).

cargo llvm-cov --workspace --lcov --output-path lcov.info cargo crap --workspace --lcov lcov.info

5. Quick aggregate summary (no table).

cargo crap --workspace --lcov lcov.info --summary

Example output:

┌───┬───────┬────┬───────────────────┬──────────┬───────────────┐
│   │  CRAP │ CC │ Coverage          │ Function │ Location      │
╞═══╪═══════╪════╪═══════════════════╪══════════╪═══════════════╡
│ ✗ │ 156.0 │ 12 │ ░░░░░░░░░░   0.0% │ crappy   │ src/lib.rs:24 │
│ ▲ │   6.7 │  4 │ ████░░░░░░  44.4% │ moderate │ src/lib.rs:12 │
│ ✓ │   1.0 │  1 │ ██████████ 100.0% │ trivial  │ src/lib.rs:8  │
└───┴───────┴────┴───────────────────┴──────────┴───────────────┘
✗ 1/3 function(s) exceed CRAP threshold 30.

Flags

Flag Default Purpose
--lcov LCOV file from cargo llvm-cov or cargo tarpaulin.
--path . Root to walk for .rs files (respects .gitignore).
--threshold 30 Score above which a function is flagged.
--min Hide entries below this score.
--top Show only the N worst offenders.
--sort {crap,file} crap Final ordering of entries. crap sorts by score descending (best for reading top-down). file sorts by (file, function, line) ascending — stable across score changes, so a committed JSON baseline produces minimal diffs. --top always selects the N highest-CRAP functions first, then --sort reorders them. Applies to every format.
--missing {pessimistic,optimistic,skip} pessimistic How to score a function with no coverage data.
--exclude Skip files matching this pattern (repeatable). ** crosses directories. Appends to the default exclusions.
--no-default-excludes off Disable the built-in default exclusions (tests/**, benches/**, examples/**, matched relative to each analyzed root). By default these standard Cargo target directories are skipped — integration tests exist to cover production code, and benches/examples are not executed during a coverage run, so they only add 0%-coverage noise.
--allow Suppress matching functions (repeatable). An entry containing / or ** is a path glob and matches the file the function is in (e.g. src/generated/**); otherwise it matches the function name and * crosses :: (e.g. Foo::*). Path globs analyze the file but hide its functions — distinct from --exclude, which skips files at walk time.
--format {human,json,github,markdown,pr-comment,sarif,shields} human Output format. json emits a versioned envelope (see JSON output schema below). github emits ::warning annotations. markdown emits a GFM table (exhaustive). pr-comment is the opinionated PR-bot variant: hides unchanged rows, caps each section, collapses non-critical info into
blocks. sarif emits SARIF 2.1.0 JSON for upload to GitHub Code Scanning, VS Code, and other static-analysis tooling (see SARIF output below). shields emits Shields.io endpoint-badge JSON for a README badge (see Shields.io badge below).
--summary off Print only aggregate stats (total, crappy count, worst offender) — no per-function table. In --workspace mode this becomes the per-crate summary plus the aggregate line.
--workspace off Analyze all Cargo workspace members (discovered via cargo metadata). Ignores --path. Adds a Per-crate summary table to human and markdown output, and a crate field to JSON entries.
--fail-above off Exit 1 if any function exceeds --threshold.
--baseline JSON from a previous --format json run. Enables delta mode (shows Δ column). Functions that moved between files (same name, body unchanged) are detected and reported as Moved rather than as separate New + Removed entries; renderers show ← <previous_file> next to the new location. Baseline entries that the current run's --exclude/--allow/default exclusions would drop are filtered out before comparison, so changing the exclusion set does not flood the report with phantom removed entries.
--fail-regression off Exit 1 if any function's score increased since --baseline. Moved (pure relocation, no score change) is not a regression.
--show-unchanged off In --baseline mode, also list Unchanged rows in the human and markdown tables. By default only changed functions (Regressed/Improved/New/Moved) are shown; when everything is unchanged the table is replaced with No changes since baseline.. The summary line always counts every entry. Requires --baseline. Does not affect json (always exhaustive) or pr-comment (keeps its own row policy).
--epsilon 0.01 Tolerance for the regression detector. Score deltas with absolute value at or below this count as Unchanged. Set to 0.0 to flag every increase, or higher to tolerate noisy coverage. Must be non-negative.
--jobs host CPUs Cap parallel source-file analysis at N threads. Useful in memory-constrained CI/Docker environments. Must be a positive integer.
--output Write output to FILE instead of stdout (useful for saving JSON baselines).

JSON output schema

--format json produces a versioned envelope with a $schema URL pointing at the published JSON Schema. Consumers can validate output offline or generate types directly from the schema.

Variant Schema
Absolute (no --baseline) schemas/report-v1.json
Delta (with --baseline) schemas/delta-v2.json

--baseline only reads files in this envelope shape; bare-array baselines from older runs must be regenerated.

SARIF output

--format sarif emits a SARIF 2.1.0JSON document — the format consumed by GitHub Code Scanning, VS Code, rust-analyzer, and most static-analysis tooling.

Shields.io badge

--format shields emits a single JSON object following theShields.io endpoint schema. Serve the file at a stable URL (GitHub Pages, raw blob) and embed it as a normal badge image:

CRAP

The label embeds the effective threshold (CRAP > 15) so the badge reads as a complete statement. The message is passing (brightgreen) when no function exceeds --threshold, N crappy in yellow for 1–5 offenders, and red for 6 or more. --baseline is silently ignored — the badge always reflects absolute current scores. See Badge generation for a CI recipe.

Configuration file

Any flag can be set persistently in .cargo-crap.toml at the project root (or any parent directory — the tool walks up until it finds one). CLI flags always take precedence.

.cargo-crap.toml

threshold = 30.0 fail-above = true missing = "pessimistic" # pessimistic | optimistic | skip

exclude appends to the default exclusions.

exclude = ["src/generated/**"]

default-excludes replaces the built-in default list

(tests/, benches/, examples/**). Set to [] to disable it;

list a subset to re-include some directories; extend it freely.

default-excludes = ["benches/", "examples/", "fuzz/**"]

allow accepts both function-name globs and path globs (any entry

containing / or ** is a path glob).

allow = ["generated::*", "src/generated/**"] epsilon = 0.01 # regression-detector tolerance jobs = 4 # cap parallel analysis at 4 threads sort = "file" # entry ordering: crap (default) | file show_unchanged = false # list Unchanged rows in --baseline mode

All keys are optional. Unknown keys are rejected to catch typos.

Design

The tool has six orthogonal modules. Each is testable in isolation; the join between them has its own integration test.

  cargo llvm-cov                  syn
  (LCOV file)                 (Rust AST)
        │                         │
        ▼                         ▼
  ┌───────────┐            ┌────────────┐
  │ coverage  │            │ complexity │
  │  module   │            │   module   │
  └─────┬─────┘            └──────┬─────┘
        │                         │
        └──────────┬──────────────┘
                   ▼
             ┌──────────┐
             │  merge   │  ← path normalization lives here
             └─────┬────┘
                   ▼
             ┌──────────┐     ┌───────┐
             │  score   │ ──▶ │ delta │  ← baseline comparison (optional)
             └─────┬────┘     └───────┘
                   ▼
             ┌──────────┐
             │  report  │  ← human / JSON / GitHub / Markdown
             └──────────┘

The path-matching problem

This is where silent failures happen. Complexity analysis produces absolute paths (whatever was passed to the walker). LCOV files contain whatever the coverage tool decided to write:

  1. Absolute paths — /home/alice/project/src/foo.rs
  2. Workspace-relative paths — src/foo.rs
  3. Crate-relative paths in a workspace — crates/core/src/foo.rs
  4. Paths with ./ or ../ components

A naïve HashMap<PathBuf, _> lookup silently returns None for 100% of files when the two don't agree, and every function reports as 0% covered.cargo-crap handles this with a two-level index:

Relative paths are never canonicalized against the process's CWD, which would otherwise silently bind them to whatever file happened to exist under the tool's working directory. The regression testrelative_coverage_paths_are_not_resolved_against_cwd in src/merge.rspins this.

The --missing policy

Some functions have complexity data but no coverage data — the coverage tool didn't instrument them, or they were excluded via #[cfg(test)], or the coverage run was scoped to a subset of the workspace. Three policies:

Integrating with CI

Absolute threshold gate

Save a baseline on main, then fail on any PR that makes a score go up. This works regardless of the absolute threshold and catches regressions as they are introduced, not weeks later.

On main branch — upload baseline as a CI artifact

On pull requests — download baseline and compare

NOTE: actions/download-artifact@v4 extracts to a subfolder named after the

artifact by default — pin path: so the file lands somewhere predictable.

Two flags make this workflow nicer:

GitHub Code Scanning (SARIF)

Upload --format sarif output to surface crappy functions in the repository's Security → Code scanning tab. The job needssecurity-events: write.

self_score: permissions: security-events: write steps: - run: cargo llvm-cov --lcov --output-path lcov.info - run: cargo crap --lcov lcov.info --format sarif --output crap.sarif - uses: github/codeql-action/upload-sarif@v3 with: sarif_file: crap.sarif category: cargo-crap

Badge generation

Regenerate the badge JSON on every push to the default branch and commit it back so the README embed stays current:

PR comment bot

--format pr-comment produces a sticky comment that surfaces regressions and new functions in the primary table and tucks improvements / removed functions / above-threshold hot-spots into collapsed <details> blocks. A hidden marker (<!-- cargo-crap-report -->) lets the script update an existing comment instead of posting duplicates. The job needspull-requests: write.

self_score: permissions: pull-requests: write steps: # ...generate lcov.info and download the baseline as above...

- name: Generate PR comment
  if: github.event_name == 'pull_request'
  run: |
    cargo crap \
      --lcov lcov.info \
      --baseline baseline.json \
      --format pr-comment \
      --output crap-comment.md

- name: Post or update PR comment
  if: github.event_name == 'pull_request'
  uses: actions/github-script@v7
  with:
    script: |
      const fs = require('fs');
      const body = fs.readFileSync('crap-comment.md', 'utf8');
      const marker = '<!-- cargo-crap-report -->';
      const { data: comments } = await github.rest.issues.listComments({
        owner: context.repo.owner,
        repo: context.repo.repo,
        issue_number: context.issue.number,
      });
      const existing = comments.find(c => c.body.startsWith(marker));
      const args = {
        owner: context.repo.owner,
        repo: context.repo.repo,
        body,
      };
      if (existing) {
        await github.rest.issues.updateComment({ ...args, comment_id: existing.id });
      } else {
        await github.rest.issues.createComment({ ...args, issue_number: context.issue.number });
      }

Prior art and references

License

This project is licensed under the MIT License - see the LICENSE file for details.