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 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 oneresultwithlevel: "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.driverenvelope. --baselineis rejected with--format sarif; SARIF describes findings, not deltas. Use--format jsonfor 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
= 30.0
= true
= "pessimistic" # pessimistic | optimistic | skip
= ["tests/**", "benches/**"]
# `allow` accepts both function-name globs and path globs (any entry
# containing `/` or `**` is a path glob).
= ["generated::*", "src/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
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
- 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.