twin4rust
A source file under src/ should have a test file mirroring it under tests/.
twin4rust fails the build when one doesn't.
twin4rust report
Files without a matching mirrored test file:
- node: src/raft_node.rs -> tests/raft_node_tests.rs
- node: src/state/raft_state.rs -> tests/state/raft_state_tests.rs
summary: packages_with_gaps=1 missing_files=2
What it is not
This is not a coverage tool. It does not measure lines, it does not measure branches, and it has no opinion on whether your tests are any good — it never opens the test file it is looking for.
It answers one structural question: does the mirrored test file exist?
That question is worth asking on its own, because line coverage cannot answer
it. A source file can be at 90% coverage purely as collateral from an
end-to-end test three layers up, with nothing anywhere that names it as its
subject. Coverage reports that as covered. twin4rust reports it as untested,
which is the more useful answer when the file later breaks and no failing test
points at it.
Install
Development
Both must be green before a change is complete, and both run the same way on
Windows, Linux and macOS. Stage 1 is formatting, clippy and tests — cargo
built-ins only, so it works on a fresh checkout with none of the tools below
installed. Stage 2 is cargo xtask stage2, which runs, in order:
cargo stern4rust (house coding rules), cargo crap4rust (complexity against
coverage), twin4rust self-analysis, and cargo iceberg4rust (file risk).
The self-analysis gate runs this tool against its own manifest, built from the working tree rather than from an install — a tool that enforces a rule it does not satisfy is not worth installing.
The repository is a workspace: core/ is the published crate, xtask/ runs the
gates, and xtask is held to the house rules alongside core.
Everything the two stages need, none of which ships with cargo:
| Tool | Install | Needed by |
|---|---|---|
just |
cargo install just |
both stages |
cargo-llvm-cov |
cargo install cargo-llvm-cov |
stage 2 |
llvm-tools rustup component |
rustup component add llvm-tools |
stage 2 |
cargo-stern4rust |
cargo install cargo-stern4rust |
stage 2 |
cargo-crap4rust |
cargo install cargo-crap4rust |
stage 2 |
cargo-iceberg4rust |
cargo install cargo-iceberg4rust |
stage 2 |
cargo-twin4rust itself is deliberately absent — the gate builds it from your
checkout rather than taking an installed copy.
CI (.github/workflows/ci.yml) runs both stages on Ubuntu, Windows and macOS
for every pull request and every push to main.
Use
| Flag | Description |
|---|---|
--manifest-path |
Cargo manifest to analyze. Defaults to the manifest in the current directory. |
--package |
Package to analyze. Repeatable. If omitted, a single-package manifest is analyzed; a workspace requires at least one. |
Exit code is 0 when every expected mirror exists and 1 when any is missing,
so it drops into CI as-is. Gaps are sorted by package, then source path, so the
output is stable across runs and diffable.
The mirror rule
src/<path>/<name>.rs -> tests/<path>/<name>_tests.rs
| Source file | Expected test file |
|---|---|
src/raft_node.rs |
tests/raft_node_tests.rs |
src/state/storage/storage_query.rs |
tests/state/storage/storage_query_tests.rs |
The rule is src/-rooted, and that is currently a hard requirement rather
than a default. A file outside src/ yields no mirror path, and a file with
no mirror path is not reported — see Known limitations.
What it skips
The gate is deliberately conservative — it would rather stay quiet than force a test file for something with no behaviour to test.
- Entry points:
src/lib.rs,src/main.rs,build.rs, and any file directly undersrc/bin/. A module inside asrc/bin/<name>/binary stays in scope - every
mod.rs, unconditionally — including one that carries behaviour - Definition-only files: every top-level item is a
struct,enum,type,trait,constorstaticdeclaration, or an ignorableuse,extern crateor bodilessmod— with at least one declaring kind present. A top-level macro invocation keeps the file in scope, since its expansion is never seen - Method-less trait impls, such as
impl Marker for T {}or a blanketimpl<T> Alias for T where ..., which introduce nothing to assert - Single-type files whose only
implis a trivialnew— one method, no generics, returnsSelf, body is a single struct literal, no branching, no loops, no helper calls. Pure data holders do not earn a test file. - Humble adapters — a single type whose methods all either hold what they were given or forward it: return nothing, one statement, and that statement is a call. A method returning a value stays in scope however short its body, and one branch takes the whole file back out of the exemption
- The
src/tree of any package whose name ends in-validation - Items carrying
#[cfg(test)], stripped before any of the above is evaluated
Anything else stays in scope. A new with a branch in it, a second method, or
a trait impl carrying at least one method all put the file back on the list. A
file with no top-level items at all is reported rather than skipped — an empty
source file is more likely an accident than a decision.
Known limitations
Files outside src/ are analyzed and then silently dropped. Source roots
come from each production target's own path, so a [[bin]] at
path = "tools/x.rs" contributes tools/ as a root and the file is walked,
read and classified — but no mirror path can be derived for it, and a file with
no mirror path is treated as having no expectation. It does not appear in the
report and does not affect the exit code.
Relatedly, a package with no target rooted under src/ never walks src/ at
all, because the fallback that adds it fires only when no root was collected.
Until this is resolved, twin4rust is only trustworthy on packages whose
production sources live under src/. Both behaviours are recorded in
docs/OPEN_POINTS.md, along with the ordering and mod.rs quirks
behind them.
Choosing what to point it at
Only pass packages whose test tree takes files as its subject.
A repository running a tiered test ladder has trees with different subjects: one
covers files, another covers a single-node cluster, another covers a multi-node
cluster. node/tests/raft/replication_tests.rs and
validation/tests/cluster/replication_tests.rs can both exist and both be
correct — one covers the file, the other covers a cluster replicating. Neither
substitutes for the other.
Point this tool at a cluster-level harness and every one of its sources is
reported as a gap, because by design none of them has a mirror. The
-validation suffix rule covers the common naming convention; anything else is
your call.
There is deliberately no flag letting one package's tests satisfy another's expectation. Such a flag existed briefly and was removed: aimed at a harness tree it made the number improve while coverage did not, because a cluster test was silencing a missing file-level test. If a source file has no test whose subject is that file, that is the finding — wherever else it happens to be exercised.
Documentation
| Document | Contents |
|---|---|
| docs/RULES.md | The canonical policy — mirror rule, every exclusion, package resolution, output |
| docs/ARCHITECTURE.md | How an invocation flows through the code |
| docs/ADRs/ | The load-bearing decisions and why they were forced |
| docs/IMPLEMENTED-FEATURES.md | What ships today |
| docs/ROADMAP.md | What comes next |
| docs/OPEN_POINTS.md | Known gaps, deliberately deferred |
| CHANGELOG.md | Release history |
Related
cargo-crap4rust— CRAP scores across Rust cratesslotgate— bounded-parallelism job runner giving each slot a disjoint port range
License
MIT. See LICENSE.