cargo-crap 0.2.0

Change Risk Anti-Patterns (CRAP) metric for Rust projects
Documentation

cargo-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:

  • A trivial function (CC=1, 100% covered) scores exactly 1.0. That's the lower bound.
  • At 100% coverage the quadratic term collapses and CRAP equals CC. When you see matching values in those two columns, that function is fully covered — tests are capping the damage, but the complexity itself remains. It's a good sign, not a bug.
  • Above CC ≈ 30 no amount of coverage keeps you under the default threshold of 30. That's not a bug in the formula — it's the formula saying "this function is too big to certify as clean, regardless of tests."

Install

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

cargo binstall cargo-crap

From source (requires Rust stable ≥ 1.88):

cargo install cargo-crap

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 <FILE> LCOV file from cargo llvm-cov or cargo tarpaulin.
--path <DIR> . Root to walk for .rs files (respects .gitignore).
--threshold <N> 30 Score above which a function is flagged.
--min <SCORE> Hide entries below this score.
--top <N> Show only the N worst offenders.
--missing {pessimistic,optimistic,skip} pessimistic How to score a function with no coverage data.
--exclude <GLOB> Skip files matching this pattern (repeatable). ** crosses directories.
--allow <GLOB> 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} 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 <details> 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).
--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 <FILE> 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.
--fail-regression off Exit 1 if any function's score increased since --baseline. Moved (pure relocation, no score change) is not a regression.
--epsilon <VALUE> 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 <N> host CPUs Cap parallel source-file analysis at N threads. Useful in memory-constrained CI/Docker environments. Must be a positive integer.
--output <FILE> 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
// cargo crap --format json
{
  "$schema": "https://raw.githubusercontent.com/minikin/cargo-crap/main/schemas/report-v1.json",
  "version": "0.0.2",
  "entries": [
    {
      "file": "src/lib.rs",
      "function": "do_thing",
      "line": 12,
      "cyclomatic": 4.0,
      "coverage": 75.0,        // null when no coverage data was found
      "crap": 5.5625,
      "crate": "my-crate"      // present only with --workspace
    }
  ]
}

// cargo crap --format json --baseline baseline.json
{
  "$schema": "https://raw.githubusercontent.com/minikin/cargo-crap/main/schemas/delta-v2.json",
  "version": "0.0.2",
  "entries": [ /* DeltaEntry — current + baseline_crap + delta + status (+ optional previous_file when moved) */ ],
  "removed": [ /* RemovedEntry — function, file, baseline_crap */ ]
}

--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.0 JSON document — the format consumed by GitHub Code Scanning, VS Code, rust-analyzer, and most static-analysis tooling.

  • Each crappy function (entry above --threshold) becomes one result with level: "warning" and a physical location pointing at the function's start line.
  • Functions below the threshold are not included.
  • An empty result set still produces a valid SARIF document with the full runs[0].tool.driver envelope.
  • --baseline is rejected with --format sarif; SARIF describes findings, not deltas. Use --format json for delta output.

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 = ["tests/**", "benches/**"]
# `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

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:

  • Absolute coverage paths → direct canonical-path hash lookup.
  • Relative coverage paths → suffix match on path components (not bytes — /foo/bar.rs must not match oofoo/bar.rs).

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 test relative_coverage_paths_are_not_resolved_against_cwd in src/merge.rs pins 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:

  • pessimistic (default): treat as 0% covered. Surfaces unmapped code as a red flag. Correct for CI gates.
  • optimistic: treat as 100% covered. Useful during local development when you're iterating on a specific module.
  • skip: drop the row entirely.

Integrating with CI

Absolute threshold gate

- run: cargo llvm-cov --lcov --output-path lcov.info
- run: cargo crap --lcov lcov.info --fail-above --threshold 30

Regression gate (recommended for teams)

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
- run: cargo llvm-cov --lcov --output-path lcov.info
- run: cargo crap --lcov lcov.info --format json --output baseline.json
- uses: actions/upload-artifact@v4
  with:
    name: crap-baseline
    path: baseline.json

# 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.
- uses: actions/download-artifact@v4
  with:
    name: crap-baseline
    path: baseline
- run: cargo llvm-cov --lcov --output-path lcov.info
- run: cargo crap --lcov lcov.info --baseline baseline/baseline.json --fail-regression

GitHub Code Scanning (SARIF)

Upload --format sarif output to surface crappy functions in the repository's Security → Code scanning tab. The job needs security-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

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 needs pull-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.