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):
From source (requires Rust stable ≥ 1.88):
Pre-built binary (manual download):
# macOS (Apple Silicon)
|
# macOS (Intel)
|
# Linux (x86_64)
|
# Linux (aarch64)
|
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.
# 2. Score every function.
# 3. Gate CI on the threshold.
# 4. Whole-workspace analysis (monorepos).
# 5. Quick aggregate summary (no table).
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 functions whose names match this pattern (repeatable). * matches ::. |
--format {human,json,github,markdown,pr-comment} |
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. |
--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.
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
= 30.0
= true
= "pessimistic" # pessimistic | optimistic | skip
= ["tests/**", "benches/**"]
= ["generated::*"]
= 0.01 # regression-detector tolerance
= 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:
- Absolute paths —
/home/alice/project/src/foo.rs - Workspace-relative paths —
src/foo.rs - Crate-relative paths in a workspace —
crates/core/src/foo.rs - 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.rsmust not matchoofoo/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
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
- Savoia, A. & Evans, B. (2007). The CRAP Metric.
- Crap4j — the original Java implementation.
License
This project is licensed under the MIT License - see the LICENSE file for details.