cargo-affected
Like pytest-testmon for Rust. Maps each test to the source-line ranges it
touches via LLVM coverage, then reruns only the tests whose ranges overlap
git diff hunks.
Status: extremely early. Linux, macOS, and Windows MSVC (
x86_64-pc-windows-msvc) are supported.x86_64-pc-windows-gnuandaarch64-pc-windows-msvcare intentionally excluded — coverage instrumentation is broken upstream on those targets (see rust-lang/rust#111098 and #150123). The author is starting to use this in their own repos; others probably shouldn't yet. The schema can change without migration, behavior may break, and there is no support promise. CI should still run the full test suite.
Installation
Not on crates.io yet. Install from source:
Quick start
# Build with coverage instrumentation and record what each test touches.
# After editing:
For CI integration or debugging selection, both run and status
accept --report-json <PATH> to emit a structured artifact alongside
their normal output. See docs/report-json.md
for the schema and a stable summary line CI can grep.
run diffs the working tree against the git sha that was HEAD when
collect last ran. Recollect periodically — every committed change since
the last collect adds to the diff and broadens selection.
When the coverage cache can't anchor a precise selection — no coverage yet,
environment fingerprint changed, every recorded collect_sha missing from
the repo (rebased and pruned, garbage-collected, beyond a shallow boundary) —
run emits a stderr notice naming which fingerprint components differ and
runs every test. Common fingerprint changes: a workspace Cargo.toml /
Cargo.lock edit, a new rustc version, or a DB collected on a different
host OS — rustc -vV records the host triple, so a Linux-collected DB
cache-misses on macOS or Windows (and vice versa). A collect_sha that is
present in the repo but not on HEAD's lineage (siblings, post-reset
orphans, the CI PR-vs-main-tip shape) is still usable: git diff <sha> HEAD resolves either way and stored ranges live in the sha's coordinate
system. cargo affected run is therefore a strict superset of cargo nextest run: always at least as safe.
How it works
collect:
cargo nextest listenumerates every test.cargo nextest runruns them with-C instrument-coverageand a per-testLLVM_PROFILE_FILE.- For each test,
llvm-profdatamerges its profraw andllvm-cov exportlists every hit function with its source-line regions. - Per
(test, file, function), the min/max line span is stored intarget/affected/coverage.db(SQLite), keyed by a fingerprint ofCargo.lock, all workspaceCargo.tomls,rustc -vV,RUSTFLAGS, andCARGO_BUILD_TARGET. The git HEAD sha is recorded alongside.
run:
git diff -U0 <collect_sha>produces OLD-side line ranges for changed files — same coordinate system as storage.- For each changed file, the DB returns stored function ranges overlapping
any hunk; the union of matching tests is run via
cargo nextest run. - If a hunk overlaps no stored range (struct fields,
#[derive],const,use,mod), a per-file backstop selects every test that touched the file. Crate roots (lib.rs/main.rs/tests/*.rs) are stored with a sentinel range covering the whole file, scoped per nextest target. An edit to a crate root reselects every test in that target, every test in the same package that links the lib (bins, integration tests), and every test in workspace packages that transitively depend on it.
Accuracy model
cargo affected run is an approximation — it trades correctness for speed.
CI should still run the full suite.
False positives (tests selected that didn't need to run)
- Function-level granularity. A hit function's full line span is treated as one range, so an edit anywhere inside it reruns every test that touched the function — even if the edited line is unreachable from those tests.
- Structural-edit backstop. Hunks outside any LLVM region (struct
fields, derives, consts,
use,mod) reselect every test that touched the file. - Crate roots. Any edit to
lib.rs/main.rs/tests/*.rsreruns every test in that target, every test in the same package that links the lib, and every test in workspace packages that transitively depend on it. - Comment- and whitespace-only edits. Selection diffs lines, not semantics.
False negatives (tests skipped that should have run)
- Non-Rust sources.
include_str!/include_bytes!targets, SQL files, migrations, assets, and templates aren't seen by llvm-cov. - Build-time inputs not in the fingerprint. The fingerprint covers
Cargo.lock, workspaceCargo.tomls,rustc -vV,RUSTFLAGS, andCARGO_BUILD_TARGET. Changes tobuild.rs,rust-toolchain.toml, or.cargo/config.tomldon't currently invalidate the cache. - Proc-macro crate source. A proc-macro's own source files compile into a host dylib, not the test binary, so editing a proc-macro crate won't reselect its downstream tests.
- External state. Tests that read env vars, filesystem state, or the network can change outcome without any source file changing.
When in doubt, cargo affected collect to refresh coverage, or skip
cargo-affected and run the full suite.
Comparison with similar tools
The biggest design choice is how a tool decides what changed. The headline
difference vs. pytest-testmon (the closest analogue) is that
cargo-affected anchors selection on a git SHA: collect records the HEAD
sha alongside the coverage data, and run asks git for the diff against
it. testmon is VCS-agnostic — it stores a per-block checksum and compares
the current source's checksums against the stored ones on every test run.
| cargo-affected | pytest-testmon | jest --changedSince |
Bazel / Buck | |
|---|---|---|---|---|
| Test-to-code mapping | LLVM source-based coverage | coverage.py |
Static module-import graph | Declared BUILD deps |
| Granularity | Function-level source line ranges | AST blocks (function / method / class) | File | Target |
| Change detection | git diff -U0 <collect_sha> (text) |
AST-block checksum mismatch | git/hg diff of changed files |
Build-graph reachability |
| Uses VCS commit data | Yes — records HEAD sha at collect, diffs against it on every run |
No — works independently of VCS | Yes — at runtime only, no stored sha | No |
| Persistent state | SQLite at target/affected/coverage.db (per-test line ranges + env fingerprint + collect_sha) |
SQLite at .testmondata (per-test block checksums) |
None | Build graph + remote cache |
| When state updates | Explicit cargo affected collect |
Silently after every test run | n/a | On every build |
| Whitespace/comment edits | Count as changes (text diff) | Ignored (checksums stable across formatting) | Count (file mtime / diff) | Ignored (no source diff) |
| Env invalidation | Fingerprint: Cargo.lock, workspace Cargo.tomls, rustc -vV, RUSTFLAGS, CARGO_BUILD_TARGET |
Python version, env vars, installed package versions | n/a | Toolchain + declared inputs |
| Falls back to full run when | Fingerprint mismatch, every recorded collect_sha missing from the repo, no coverage yet |
DB schema mismatch | No git repo / no merge base | n/a |
The trade-off:
- Anchoring on a SHA (cargo-affected) means
collectis a separate, explicit step andrundoes cheap text diffs — but it depends on the recordedcollect_shastill being in the repo (any commit reachable by the local.git/works, including siblings ofHEAD), and any commit sincecollectwidens the diff. Whitespace and comment edits look like real changes because we diff text, not AST. - Recomputing checksums every run (testmon) is VCS-agnostic and ignores cosmetic edits, at the cost of reparsing all source on every invocation and updating the DB on every run.
- Static-graph approaches (jest, Bazel, Buck) skip dynamic coverage entirely — fast and deterministic, but conservative on reflection, plugin loading, and runtime dispatch, where coverage-based tools see the actual edges.
Why git instead of content hashes
The obvious alternative — testmon's design — is to hash each item and rerun any test whose dependencies' hashes changed. We track line ranges instead because of coordinates: stored data is keyed to OLD-side line numbers, and after any edit those don't point at the same code in the working tree.
Bridging the two coordinate systems takes either:
- A diff in OLD-side coordinates (
git diff -U0 <collect_sha>, language-agnostic), or - An AST parse to re-find each item in current source by stable
identity and rehash (
synfor Rust).
Tests themselves don't need stable identity — nextest gives canonical names, and "rerun any test in a file that changed" is a fine concession. The coordinate problem is on the source side, where dropping git means choosing between a parser and a precision drop:
| Precision | Needs parser | Needs git | |
|---|---|---|---|
Line ranges + git diff (today) |
Function | No | Yes |
| Per-file content hash | File | No | No |
Per-item content hash via syn |
Function | Yes | No |
Git is the cheapest bridge that keeps function-level precision without
a parser. If the git dependency becomes a real constraint, per-item
hashes via syn are the natural next step — strictly more work, but
VCS-agnostic and robust to whitespace and comment edits.
License
Dual-licensed under MIT or Apache-2.0 at your option.