A codebase health analyzer for Rust workspaces. Deadwood finds maintainability
issues that rustc and clippy stay quiet about — starting with dead module
files, unused pub items, unused re-exports, unused and misplaced
dependencies, and cfg gates that can never hold, in the spirit of
Fallow/knip-style analyzers for other ecosystems.
Status: v1.0.0-beta.1 — the v1 check set is complete and the roadmap is
empty; what stands between this and 1.0 is soak time on codebases that are not
this one. Tunable through a deadwood.toml, adoptable on an existing codebase
through a baseline file, and correct without either. See
docs/SCOPE.md
for what is in and out of scope,
docs/HISTORY.md
for the phase-by-phase record of how it got here, and
docs/ENVIRONMENT.md
for the environment
assessment this project was bootstrapped from.
| Check | What it finds | Why rustc doesn't |
|---|---|---|
| Dead files | .rs files in the directories a package's crate roots sit in — src/, or wherever the manifest points — not reachable from any target root in the workspace via mod declarations — including ones a macro token stream declares, which are followed without expanding it — or through an include! naming a file we can read |
Files outside the module tree are never compiled, so no lint ever sees them |
| Unused pub items | Fully-pub fns, structs, enums, traits, type aliases, consts, statics, and unions that nothing live in the workspace reaches — either no path resolves to them, or every path that does is written inside something itself unreachable |
dead_code assumes pub items have external consumers |
| Unused re-exports | pub use re-exports nothing live in the workspace goes through, where outside code cannot reach them either |
unused_imports only sees imports the crate itself does not use, not ones re-exported for nobody |
| Unused dependencies | Cargo.toml entries — normal, dev, or build — whose crate name the declaring package's code never mentions |
Cargo has no reason to look, and an unused entry still costs build time and supply-chain surface |
| Misplaced dependencies | Cargo.toml entries declared in a table the code naming them cannot see: a [dependencies] entry only tests, examples and benches use, a [dev-dependencies] entry the library itself names — a manifest that does not compile — or a [build-dependencies] entry the build script never touches |
Cargo builds the entry wherever you put it for the two directions that compile, and a normal dependency only your tests need is compiled by everyone who depends on you. The third does not compile — but the error lands on the first line that used the crate, naming neither the entry nor the table it should have been in |
Unsatisfiable cfg gates |
#[cfg(...)] gates that can hold in no build of the package, e.g. a mod behind a feature the manifest does not declare |
The code is never compiled, so no lint ever sees it — and the gate reads as deliberate |
| Test-only public items (off by default) | pub items the workspace reaches only through its test code — reached, so not dead, but pub for nobody |
dead_code says nothing about an item in a test, bench or example target, since the only build that compiles one also uses it. Where rustc can see the item it usually does report it, in a build with the tests left out — see Known limitations |
What each check reports can be tuned by a deadwood.toml — see
Configuration.
Every column of the table above has a story ending in deleted code, and no
other Rust tool covers more than one of them. The dependency checkers check
dependencies; rustc's dead_code lint stops at pub, on the assumption
that somebody outside the crate is calling it — an assumption that is simply
false for the binary crates, internal workspace members, and
pub-out-of-habit items where dead code actually accumulates. Deadwood asks
one question of the whole workspace — what would nobody miss? — and
answers it in one pass, without a build, without nightly, in seconds: the
entire rustc workspace analyzes in about 3.5s.
Three design choices are worth knowing before you run it:
- Reachability, not reference counting. An item survives only when something names it and that something is itself alive, so a dead subsystem comes out in one run instead of one layer per run — and a pair of mutually recursive functions nothing calls, invisible to any counter, comes out at all.
- False negatives over false positives, enforced. Anything Deadwood
cannot resolve — macro input, attribute strings, ambiguous paths — counts
as a use. Every claim class was measured against ten major workspaces
(rust-lang/rust, tokio, serde, zed, deno, ripgrep among them), and a claim
that misfired there became a filed issue, a fix, and a regression fixture
(
docs/HISTORY.mdrecords each). A dead-code tool that cries wolf gets uninstalled; this one is built, tested, and measured around not doing that. - Adoptable on day one.
--write-baselinerecords the findings you already have so CI fails only on new ones; per-kind severity and allowlists tune the rest. You do not have to clean a backlog to start keeping new dead code out.
How it sits against the neighbours, honestly:
| Deadwood | cargo-machete | cargo-udeps | rustc dead_code |
knip (JS/TS) | jscpd (many langs) | |
|---|---|---|---|---|---|---|
| Dead source files | ✓ | — | — | — | ✓ | — |
Unused pub items / re-exports |
✓ | — | — | private items only | ✓ (exports) | — |
| Unused dependencies | ✓ | ✓ | ✓ | — | ✓ | — |
| Misplaced dependencies (dev ↔ normal ↔ build) | ✓ | — | — | — | — | — |
Unsatisfiable cfg gates |
✓ | — | — | — | — | — |
Test-only pub items |
✓ (opt-in) | — | — | — | — | — |
| Copy-paste duplication | — | — | — | — | — | ✓ |
| Needs nightly / a full build | no / no | no / no | yes / yes | comes with the build | no | no |
| Baseline for existing codebases | ✓ | — | — | — | — | — |
The dashes in Deadwood's column are deliberate, not gaps. Duplication
detection is jscpd's craft and explicitly out of scope
here until the dead-code core has earned its precision
(docs/SCOPE.md);
security advisories are cargo-audit's,
license and ban policy cargo-deny's.
And if unused dependencies are the only question you have,
cargo-machete answers it in
milliseconds — Deadwood earns its seconds when you want the other six columns
answered in the same pass, by path resolution rather than pattern match, with
a baseline to adopt it gradually. Deadwood also never rewrites your code:
reporting only, until precision has been proven in the field.
Usage is decided by resolving paths, not by counting identifiers: use
declarations (renames, nested trees, pub use), qualified paths (crate::,
self::, super::), and cross-crate paths between workspace members are
resolved against a per-crate symbol table. So two items sharing a name no
longer hide each other, and a type mentioned only inside its own impl block
is still reported.
Lexical scopes are part of that resolution: a local, a parameter, or a generic
parameter that shares a name with a module item shadows it exactly as it does
in Rust, so let helper = 5; no longer keeps a dead pub fn helper alive.
Shadowing is per namespace — a let binding hides only expressions and a
generic parameter only types, so let Foo = 1; cannot silence a : Foo beside
it — and it stops at the end of the block, arm, or body that opened it.
And being referenced is not enough — the referrer has to be alive too. Each use is recorded against the definition the naming path is written inside, and an item survives only when something names it and that something is itself reached:
// in `main.rs`, or anywhere a consumer outside the crate cannot reach:
pub fn orphan() { helper(); } // reported: nothing names it
pub fn helper() {} // reported too: only `orphan` names itThe crate kind matters, and it is the root rule below at work: in a library
both of these sit on the public surface, so helper is reached and only
orphan is reported.
So a dead subsystem comes out in one run rather than one layer per run, and a pair of mutually recursive functions nothing reaches — permanently referenced, and so invisible to any reference count — comes out at all. Both members of a dead cycle are reported: each is separately deletable, and a group finding would need a name that moved whenever a member joined or left it. The two kinds of evidence read apart in the message, since saying "never referenced" about an item with visible callers would read as a bug:
src/api.rs:3: pub fn `orphan` is never referenced by any resolved path in this workspace
src/api.rs:7: pub fn `helper` is referenced only from items that nothing reachesThe walk starts from a set of roots, and every omission from it would be a
live item reported dead, so the set is deliberately generous: fn main and the
build script, #[test] and #[bench] functions, the linker and compiler
exports (#[no_mangle], #[export_name], #[proc_macro*], #[panic_handler]
and the rest, including the #[unsafe(...)] spelling), the dead_code
opt-outs, #[cfg(doctest)] items — rustdoc's doctest collection is their
consumer, and the pub struct ReadmeDoctests; idiom is deliberately
referenced by nothing — everything [public-api] declares, a library's
public surface —
every pub item under pub modules from the crate root, and everything a
pub use re-exports from the crate root or from one of those modules, whether
it is written as a glob (pub use inner::*;) or names a module outright
(pub use inner::sub;, with or without an as) — inner itself need not be
pub, which is the whole point — since
consumers Deadwood cannot see call it — and everything opaque. A root is still
reported when nothing in the workspace names it, which is why rooting the
public surface costs no finding: what it changes is that an item the surface
calls is not dragged down with it.
That walk runs twice, and the difference between the two answers is a
finding of its own. Once from the whole root set — the build Deadwood analyzes
— and once from the root set with the test entry points taken out: #[test]
and #[bench] functions, and every entry point written in a test, bench or
example target, which are test code in their entirety rather than only where
the attribute is. An item in the first and not the second is reached only by
test code, which is not the same claim as "dead", so it is not the same
finding:
src/parser.rs:14: pub fn `scan_all` is reached only from test code: make it `pub(crate)`, or move it behind `#[cfg(test)]`The kind is off by default — every #[cfg(test)] helper in every codebase
is a candidate, so it would fire on the first run of every project — and a
project asks for it with test_only_item = "warn" under
[severity]. Nothing on a library's public surface is ever
reported this way, whatever its tests do: a consumer Deadwood cannot see
reaches it in a build with no tests in it at all.
The bias is still toward staying quiet rather than raising noise. Anything
that cannot be resolved counts as a use of every item with that name:
identifiers inside macro invocations and attribute arguments, and names in a
module holding a glob import that leads outside the workspace. Under
reachability those count as roots rather than as ordinary references — a
mention Deadwood has admitted it cannot read must never become evidence that
something is dead. So does a use written where there is no definition to
attribute it to: in an impl block for a type outside the workspace or for a
generic parameter, or inside an item nested in a function body. Items marked
#[no_mangle], #[used], #[export_name], or
#[allow(dead_code)]/#[expect(dead_code)] are skipped, as is fn main. For
library crates with external consumers, treat unused-pub findings as advisory
— Deadwood cannot see your dependents.
The dependency check leans on the same bias, harder. A Cargo.toml entry is
reported only when nothing in the package mentions its crate name — not a
path, not an extern crate, not an identifier in macro input or an attribute
(strings included), not a word in a doc comment (doc examples are compiled,
and often use a dependency that appears nowhere else), and not the
[features] table, where test = ["helper/all-features"] is a use with no
code behind it. Reachability is not required either: files no mod
declaration names are read too, because automod::dir! and friends expand
into declarations Deadwood never sees. A package pulling in code from a file
that cannot be read (include!(concat!(env!("OUT_DIR"), ...))) is skipped with
a warning instead of guessed at.
Optional and [target.'cfg(...)'.dependencies] entries are judged like any
other, because the default analysis covers every feature combination and every
target: the code that uses them is read, so a reference to one is found
wherever it exists. Two cases are still skipped, out loud — an entry that no
feature in a narrowed cfg matrix can turn on, and a
[target.'cfg(any())'.dependencies] entry, which is how a crate pins the
version of something it deliberately never compiles.
Whether an entry sits in the right table is a separate check, because it is a
separate question: the unused check asks whether anything names the crate, and
this one asks whether the code that does can see the table it is declared in.
It needs stronger evidence, so it accepts less. Every mention is attributed to
the code it was written in — runtime targets, test/example/bench targets, the
build script — and only two claims are ever made: a [dependencies] entry
every mention of which is test code belongs in [dev-dependencies], and a
[build-dependencies] entry the build script never names belongs wherever the
code that does name it lives.
Everything else stays quiet, by design:
- Test-confined code counts as test code wherever it sits, so the unit
tests inside a library do not make every dev-dependency they use look
misplaced. That is the single largest false positive the check could make.
#[cfg(test)]is one way to write it and#[test]/#[bench]is the other: rustc leaves the function those sit on out of every build that is not a test build, so a bare#[test] fnat module scope is test code with no#[cfg(test)]in sight. Only the built-in, single-segment attribute counts as confinement — an attribute macro Deadwood cannot expand (#[tokio::test], and the#[core::prelude::v1::test]the built-in one is also spelled as) owns the item instead: the item is that macro's input, what survives expansion and in which build is unknowable, so a runtime item's mentions land in the opaque context — known used, unknown where — and place nothing in either direction. Built-in attributes, tool attributes (#[rustfmt::skip]) and derive helpers (#[serde(..)]beside its#[derive]) rewrite nothing and leave their item attributed as written. - A mention in a doc comment places nothing. Doc examples are compiled as doctests, which link the normal and the dev dependencies, so a crate named in one is correctly declared under either table.
- A mention through a macro, an attribute, or a file no
moddeclaration names places nothing either. These keep an entry alive for the unused check precisely because we cannot see through them; a reference that cannot be attributed to a target cannot prove misplacement. This is most of what the check gives up. - A dev-dependency the library names is reported, as of phase 21 — the claim phase 5 refused. "The library names it" describes a manifest that does not compile, and what kept it unmade was never the reasoning but the risk that a mis-attribution of ours would invent the finding. Both known sources are closed, and with them closed the claim has no candidates in the 35-crate registry corpus nor in Deadwood itself, where it had two before.
- The two directions need different evidence, and that is deliberate. An entry moves down only when every mention is dev code, since one library mention justifies it where it is. It moves up on a single runtime mention, since the library cannot link it at all — test code naming it too changes nothing about the build that fails.
- A crate declared in both
[dependencies]and[dev-dependencies]— usually so the tests get extra features, the wayzerocopy-derivedeclaressyntwice — is judged per entry: the library mentions justify the normal copy and are never held against the dev copy, whose own evidence is the dev mentions. A doubled dev copy dev code names is correctly placed; one nothing dev names is reported as the stale duplicate it is.
cfg gates are evaluated rather than always followed, but the default set of
builds analyzed is the union of every possibility — every feature on and off,
every target, tests included — so a gate is followed whenever it could hold
anywhere. That is exactly the old always-follow behavior;
Configuration is where a project narrows it. What the
evaluation adds is a finding: a gate that can hold in no build, which in
practice means one naming a feature the manifest does not declare. Such a gate
is reported and the code behind it is still analyzed, so the new finding never
moves the others. Gates Deadwood cannot read at all —
cfg(accessible(..)), a cfg a build script sets, a cfg_attr indirection —
are followed as before.
Re-exports get one extra filter, because a pub use exists only to expose a
name outward: one that is reachable from a library's crate root (pub use inner::Thing; in lib.rs, in any pub mod under it, or in a module a
pub use inner::*; glob or a pub use inner::sub; re-export carries there) is doing its job
even when nothing inside the workspace uses it, so it is never reported. A
re-export that outside code cannot reach — because some module on the way is
private — has no such excuse, and is reported. A use names what it imports
on the bound name's behalf, so such a re-export stops keeping its target
alive: the definition under it is reported alongside it, because deleting one
does not delete the other.
From crates.io — the package is deadwood-rs (the plain name belongs to an
unrelated project), and the binary it installs is deadwood:
$ cargo install deadwood-rsOr straight from the repository:
$ cargo install --locked --git https://github.com/rlorenzo/deadwoodPrebuilt binaries — Linux (x86_64 and aarch64), macOS (Intel and Apple silicon), and Windows, each with a SHA-256 checksum beside it — are attached to every GitHub release.
$ deadwood check path/to/workspace
Dead files:
src/orphan.rs: not reachable from any target of package `simple` via `mod` declarations
Unsatisfiable cfg gates:
src/lib.rs:14: `#[cfg(feature = "legacy")]` can never hold: package `demo` declares no feature `legacy`
Unused public items:
src/lib.rs:3: pub fn `entry` is never referenced by any resolved path in this workspace
src/lib.rs:7: pub fn `dead_fn` is never referenced by any resolved path in this workspace
src/lib.rs:9: pub fn `dead_helper` is referenced only from items that nothing reaches
Unused re-exports:
src/lib.rs:11: `pub use` re-export of `Stale` is never referenced through this module
Unused dependencies:
Cargo.toml: dev-dependency `tempfile` is never referenced by any target of package `demo`
Misplaced dependencies:
Cargo.toml: dependency `assert_cmd` is referenced only by the test, example and bench code of package `demo`, so it belongs in `[dev-dependencies]` rather than `[dependencies]`
8 finding(s) in workspace `/path/to/workspace`.deadwood check [PATH]— analyze the package/workspace atPATH(default.)--json— machine-readable output (findings + warnings)--config PATH— use this configuration file instead of searching fordeadwood.toml--write-baseline— record the current findings so later runs fail only on new ones; see Adopting on an existing codebase--prune-baseline— drop baseline entries that no longer occur- Exit codes:
0clean,1findings that are configureddeny(the default for every kind excepttest_only_item, which isoff),2error — suitable for CI gates.
Requires cargo on PATH (workspace discovery shells out to
cargo metadata --no-deps, which works offline).
A codebase that has never been analyzed has findings on day one, and a tool that fails the build for all of them on day one gets uninstalled. Record them once and only new ones fail:
$ deadwood check --write-baseline
No issues found.
Wrote 34 finding(s) to baseline `deadwood-baseline.json`.
$ deadwood check # commit the file; CI is green from here
No issues found.
34 finding(s) suppressed by baseline `deadwood-baseline.json`.The file is deadwood-baseline.json in the workspace root unless a
deadwood.toml says otherwise, and it holds exactly the objects --json puts
in its findings array — no second format, and readable in a diff. That is a
constraint rather than a coincidence: everything the matching keys on has to be
producible from a report, so a baseline stays something you can write by hand.
It is meant to be committed: the debt stays visible, and it can only shrink.
A baselined finding is subtracted, not marked. It is absent from the text
report, from the JSON findings array, and from the count; only the summary
line says how many there were. The report is for what you have to act on, and
reprinting the accepted list would reproduce exactly the noise the baseline was
adopted to remove.
Matching survives line drift. An entry is matched on kind, file, item name,
the module the item is written in and the namespace it binds that name in — not
the line, which moves with every edit above it, and not the severity, which is a
deadwood.toml decision: putting it in the key would mean that turning a check
down from deny to warn un-baselines every finding of that kind at once. The
module path is in the key for the same reason the line is not: it tells
alpha::twin from beta::twin in one file, and it does not move when code
above it does.
And it tells a type from a value of the same name. pub struct Group { .. }
and the #[allow(non_snake_case)] pub fn Group(..) beside it share a kind, a
file, a name and a module; Rust tells them apart by namespace, and so does the
key. The namespace is type, value, or both — a unit or tuple struct binds
a constructor value of its own name, and a use alias binds whatever the path
it names binds, resolved through the symbol table like any other path — and two
entries match when the namespaces they stand for overlap, so both covers
either half. An alias whose target cannot be resolved keeps both: a re-export
leading outside the workspace, or through a glob that does, says nothing about
namespaces rather than guessing at one.
That leaves two definitions sharing a key in exactly one case, and it is the
case where one entry is right: Rust will not compile two definitions of one name
in one module unless they are in different namespaces and neither is in both, so
whatever the key still joins is two #[cfg]-alternative spellings of one item —
one item, one place to open, one fix.
And it survives a moved file, for the kinds that name an item. git mv src/legacy.rs src/legacy/mod.rs changes no code and no item, so the findings
in it stay suppressed. Matching runs in two passes: the key above first, and
then — over what that pass left unmatched on both sides — the identity a move
preserves, which is the kind, the package, the module path and the name. The
file is to the module path what the line is to the file: where you go to read
the item, not what the item is.
That second pass declines far more often than it fires, and every refusal is deliberate:
- A finding with no module is out of its reach entirely. A dead file has no
name and no module, so nothing about it survives a move for anything to
compare — two unrelated dead files are indistinguishable without a content
signal Deadwood does not compute, and a rule that appeared to handle them
would be guessing. The two dependency kinds and
unsatisfiable_cfgare out for the same reason; a manifest path moves only when a whole package does. That boundary was re-examined and kept, with the measurement indocs/HISTORY.mdphase 17: the fix needs a signal recorded in the baseline, and that field would land on the one class of baseline that is portable across every Deadwood released — a file recording only dead files, dependency entries and gate sites carries nomoduleand nonamespace, so every version reads it today. Adding the field ends that: it makes an older Deadwood exit 2 on a file it read yesterday, to buy back a failure that is noise. - It never overrules the file. Two items with the same kind, name and module in two different files are both matched by the key, so baselining one still leaves the other reported.
- It matches only a one-to-one pairing — exactly one leftover entry and exactly one leftover finding under an identity, which is the only shape a move can have. Two candidates for one entry, or two entries for one candidate, and it refuses: the run goes back to reporting the finding and naming the entry stale, which is where a move stops being distinguishable from a coincidence.
- It will not cross a package.
moduleiscrate-relative and says nothing about which crate, so two members can each have acrate::legacy::gone; the recorded file supplies the package. An entry whose path is in no package of the workspace — a package directory that itself moved — matches nothing new.
A relocated entry keeps the path it was written with, exactly as a matched entry
keeps its drifted line. --prune-baseline drops entries and rewrites none;
--write-baseline is what re-records the new paths.
{ "kind": "unused_pub_item", "severity": "deny", "file": "src/lib.rs",
"line": 11, "name": "twin", "module": "crate::alpha", "namespace": "value",
"message": "pub fn `twin` is never referenced by any resolved path in this workspace" }Only the three item kinds have a module and a namespace: a dead file is not an
item, the two dependency kinds name an entry in a manifest, and an unsatisfiable
gate names the site the gate is written at. An entry with no module is not
an entry in the crate root, and one with no namespace is not an entry in
some default namespace — each is an entry that says nothing about that field,
and each field is compared only when both sides record it. That is what keeps a
baseline written by an older Deadwood matching exactly what it always matched,
with no edit: the crate root is spelled crate, never omitted, so the two cases
can never be confused.
The compatibility runs one way, and only as far as those fields reach. Both are
written only for the kinds that have them — unused_pub_item,
unused_reexport, test_only_item — so a baseline recording none of those
carries neither and every Deadwood that ever shipped still reads it. Record one
item finding and it does not: that file makes a Deadwood older than the field it
carries exit 2 with unknown field `module` or unknown field `namespace` , on a file it read yesterday. That is the same strictness that turns a typo'd
key into an error rather than a silently ignored one, and the reason a field
that decides matching may not be quietly dropped. Downgrading after rewriting
such a baseline means deleting the fields by hand, or regenerating the file with
the older binary.
Fixed findings are reported, not forgotten. An entry nothing matches any
more is stale, and every run says so; --prune-baseline rewrites the file
without them. Stale entries never fail the run — the exit code follows severity
and nothing else, and a developer who deletes dead code should not be punished
for it.
$ deadwood check
Unused public items:
src/api.rs:12: pub fn `fresh` is never referenced by any resolved path in this workspace
1 finding(s) in workspace `/path/to/workspace`.
Stale baseline entries in `deadwood-baseline.json` (no longer occur; rerun with --prune-baseline to drop them):
src/old.rs: unused_pub_item `finally_deleted` in `crate::old` (value namespace)
33 finding(s) suppressed by baseline `deadwood-baseline.json`.A stale line names the module and the namespace the entry recorded, when it recorded them, because two entries that differ only in those are two different items and the reader has to know which one to go and find.
Two rules keep the file from lying:
- Writing is explicit. No run without
--write-baselineor--prune-baselinecreates or modifies the file, so a CI job can never quietly accept what it found. - A missing or unparsable baseline is exit 2, never "nothing is baselined" and never "everything is". A typo'd path would otherwise disarm a CI gate silently. The one non-error case is the default location with no file in it — that is a project that has not adopted a baseline, and it behaves exactly like a Deadwood without the feature.
One entry covers every finding that shares its key, so two #[cfg]-alternative
definitions of one item are still suppressed together — which is what should
happen, since they are one item with one fix. Within one package the module path
is shared by every target's crate root, so two binaries or examples each defining
pub const X at crate are one relocation identity as well: if one disappears
and the other appears in the same run, the second pass reads it as a move. That
shape occurs once in the reportable pub definitions of the corpus every phase
measures on, and produces no finding today.
This repository doubles as a composite GitHub Action, so gating a workflow
takes two lines — the exit codes already say everything CI needs (0 clean,
1 denied findings, 2 error):
- uses: actions/checkout@v4
- uses: rlorenzo/deadwood@main # pin a tag once one exists
with:
path: . # workspace to analyze (default `.`)
args: "" # extra arguments for `deadwood check`, e.g. `--json`By default the action builds Deadwood from its own pinned ref, so the ref
your workflow names is the tool version it runs — no published release
required — and a --write-baseline file committed to your repository is
picked up exactly as it is locally. Set version: 1.0.0-beta.1 to install a published
release from crates.io instead. The runner needs stable Rust on PATH,
which every GitHub-hosted runner ships; this repository's own CI runs the
action against itself on every push.
Deadwood needs no configuration, and with no deadwood.toml present it
behaves exactly as described above. The file exists to express what the
analysis cannot infer: which files are not yours to fix, which checks you are
ready to enforce, which crates have consumers Deadwood cannot see, which
manifest entries are load bearing without being named in code, and which
builds — features, targets, tests — you actually care about.
It is looked for by walking up from the analyzed path to the workspace root,
and the nearest one wins; --config PATH overrides the search and fails if
that file is missing. Relative patterns are resolved against the directory
holding the file.
# deadwood.toml — every setting, with its default behavior noted. The values
# shown are an illustrative policy, not the defaults: each block states its own
# default in the comment above it, and an omitted block leaves it in force.
# Files no finding may be reported about. Patterns are `/`-separated globs
# where `*` stays inside one segment, `**` spans any number of them, and `?` is
# one character; a pattern matching a directory covers everything under it.
# Default: nothing is ignored.
ignore = ["crates/*/src/generated/**", "vendor"]
# What each finding kind costs. `deny` reports it and fails the run (exit 1),
# `warn` reports it and exits 0, `off` never reports it at all. The keys are
# the finding kinds as they appear in `--json`.
# Default: `deny` for every kind that reports something to delete — which is
# the pre-config behavior — and `off` for `test_only_item`, which reports a
# visibility to narrow and would otherwise fire on the first run of every
# project that has a `#[cfg(test)]` helper. There is no other exception, and a
# new kind has to state its own default rather than inherit one.
[severity]
dead_file = "deny"
unused_pub_item = "warn"
unused_reexport = "warn"
unused_dependency = "deny"
misplaced_dependency = "deny"
unsatisfiable_cfg = "deny"
test_only_item = "warn" # `off` unless you ask; nothing else defaults to `off`
# (`unused_pub_item` and `unused_reexport` above are `deny` unless a line like
# these turns them down — the two spellings are what the setting is for.)
# Crates and items whose `pub` surface is API rather than leftovers. Deadwood
# only sees consumers inside the workspace, so for a published library this is
# the difference between a usable report and a page of noise.
# Default: nothing is treated as declared API.
[public-api]
# Every `pub` item in these crates. Dashes and underscores are interchangeable.
crates = ["my-library"]
# ...or `crate::module::Item` paths, as globs, for finer control.
items = ["my-app::prelude::*", "my-app::error::**"]
# Manifest entries the dependency checks must never judge — neither whether
# anything names them nor which table they belong in: the ones that are load
# bearing without any code naming them. Matched on the manifest key exactly as
# written, so a renamed entry is listed by its alias.
# Default: every entry is judged.
[dependencies]
# Exempt in every package of the workspace.
allow = ["getrandom", "openssl"]
# Exempt only in the package named.
[dependencies.allow-in]
my-app = ["vendored-native"]
# Which builds to analyze. Every key here *narrows* the analysis, and omitting
# one means "not narrowed" rather than "empty": the default is the union of
# every possibility, which is what makes an absent `[cfg]` section a no-op.
[cfg]
# Feature names to treat as enabled, closed over the features they enable, in
# every package. `#[cfg(feature = "...")]` code behind anything else is not
# analyzed at all.
# Default (key omitted): every feature may be on or off, so every gate holds
# somewhere. `features = []` is different — it is the build with none of them.
features = ["default", "serde"]
# `target_os` values to analyze, which also decide `cfg(unix)`, `cfg(windows)`
# and `target_family`. Other target predicates (`target_arch`, `target_env`,
# ...) are not modelled and are never narrowed.
# Default: every target is possible.
target-os = ["linux", "macos"]
# Whether `#[cfg(test)]` code is part of the build being analyzed. With it on,
# a test is a use, so an item only tests reach is not reported.
# Default: true.
test = true
# Where the baseline file lives, relative to this config file. Omitting the key
# does not mean "no baseline": it means the default location,
# `deadwood-baseline.json` in the workspace root, which may or may not have a
# file in it yet. A path written here and not on disk is an error.
# Default: the default location.
baseline = ".deadwood/baseline.json"Five things are worth knowing about how these behave.
ignore suppresses findings, not evidence. An ignored file is still read,
and the paths in it still count as uses. Generated code that calls your
pub fn is still calling it, and dropping that would make every ignore entry
a source of false positives in the code beside it. The one thing ignore does
reach into is module resolution: a mod declaration pointing at a missing
file the patterns cover is skipped silently instead of warned about, so
ignoring a generated module does not stop Deadwood checking the rest of its
package.
Severity is per kind, and only deny fails the run. A warn finding is
printed with its group marked (warn) and carries "severity": "warn" in the
JSON, so a project can adopt a check as advisory before enforcing it. An off
finding does not exist: it is absent from the output, the JSON, and the count —
which is exactly what test_only_item is until a [severity] entry asks for
it, and why adding that kind changed no output anywhere.
public-api covers unused-pub items and unused re-exports alike, since a
pub use is surface too. An allowlisted dependency entry that is referenced
is not an error — the list means "do not judge this", not "assert this is
unused".
cfg narrows what is analyzed, not what is reported. Code the matrix
leaves out is not read, so it neither defines nor uses anything — and it is not
a dead file either, because nothing reaches it only in the sense that this
build does not contain it. That is the lever's real cost and its real value:
test = false turns a test-only helper into an unused-pub finding, which is
either the question you wanted answered or a page of noise, depending on the
project. That overlaps test_only_item and does not replace it, in either
direction. The matrix takes the tests out of the build, so it also takes
them out of the evidence — a dev-dependency only the tests use becomes an
unused-dependency finding, and a #[cfg(test)]-only file becomes a dead one —
and what it reports is unused_pub_item, whose message says the item is dead.
test_only_item keeps the tests in the build, changes no other check's
answer, and says what to do instead. The matrix has the better recall (it does
not care that an assert_eq! names the item); the kind has the narrower blast
radius and the truer message. The unsatisfiable_cfg finding is the one thing the matrix does not
affect — a gate is judged impossible against every build there could be, so
narrowing the matrix never invents one and never silences one.
baseline names a file, it does not switch a feature on. A run reads the
baseline whether or not the key is present — with it, from where it points;
without it, from the default location if a file is there. What the key changes
is the error contract: a path you wrote down must exist, while the default
location may simply be empty. Note also that ignore and severity = "off"
outrank the baseline, since a finding they remove never exists to be
suppressed — which makes any baseline entry for it stale, and prunable.
Configuration mistakes are hard failures (exit 2), including unknown keys. A
deadwood.toml that quietly does nothing because of a typo is worse than none
at all, so a misspelled key names itself, its file, and the keys that do exist:
$ deadwood check
error: invalid config file `deadwood.toml`: TOML parse error at line 1, column 1
|
1 | ignor = ["vendor"]
| ^^^^^
unknown field `ignor`, expected one of `ignore`, `severity`, `public-api`, `dependencies`, `cfg`, `baseline`From a fresh checkout:
$ cargo build # build
$ cargo test # unit + integration tests
$ scripts/check.sh # full gate: fmt --check, clippy -D warnings, tests
$ scripts/check.sh --fix # apply rustfmt + clippy fixesCI (.github/workflows/ci.yml) runs the same gate on every push and PR. The
toolchain is pinned to stable with clippy and rustfmt via
rust-toolchain.toml; Cargo.lock is committed for reproducible builds.
- Workspace discovery —
cargo metadata --no-depsprovides workspace members, target roots (lib/bin/test/example/bench/build), and the workspace root (src/metadata.rs). - Module-tree resolution — from each target root,
moddeclarations (including nested inline modules and#[path]) are followed to the files they name; everything reached is parsed withsyn, and each file records the module path its items live in (src/modtree.rs). Amodbehind acfgthe configured build matrix rules out is not followed, and neither it nor the files under it can be reported dead. cfgevaluation — each gate is answered against two matrices (src/cfg.rs): the configured one, which decides whether the code is part of the build being analyzed, and every build there could be, which decides whether the gate can hold at all. Items the first rules out are pruned from the AST, so the detectors below simply never see them; gates the second rules out are theunsatisfiable_cfgfindings.- Usage resolution — every target is a crate. For each one, a symbol
table maps its modules to the items they define, the
usealiases they bind, and the globs they import; then every path in every file is resolved from the module it is written in, marking what it names and recording which definition it was written inside (src/resolve.rs). - Reachability — those recorded edges are walked from the root set
(entry points, the linker and compiler exports, a library's public surface,
[public-api], and everything opaque), so an item is alive only when something live names it (src/resolve.rs). The same walk runs a second time over the same edges with the test entry points removed, and what only the first reaches is thetest_only_itemfinding. - Detectors — dead files are
src/**.rsminus the reachable set, the spliced-in set and thecfg-excluded set; unused pub items and re-exports are the definitions nothing live reached, and test-only items the ones only the first walk reached (src/unused.rs); unused dependencies are the manifest entries whose crate name appears nowhere in the package, reachable or not, and misplaced ones are the entries every mention of which lands in code their table does not serve (src/deps.rs). - Configuration —
deadwood.tomlis applied in one pass over the findings, soignoreand[severity]cover every detector identically (src/config.rs);public-api, the dependency allowlist, and thecfgmatrix are consulted by the detectors they belong to. - Baseline — last of all, and after the configuration: recorded findings
are subtracted by kind, file, name, module and namespace, and recorded
entries that matched nothing are reported stale (
src/baseline.rs). - Reporting — grouped text or JSON (
src/report.rs).
Known limitations (tracked, not hidden)
-
Resolution is syntactic, not semantic: method calls (
x.foo()), trait dispatch, and associated items are not resolved. Only free-standing item definitions are ever reported, so this costs findings, never precision. -
Macro input is not expanded, so an identifier inside a macro invocation counts as a use of every workspace item with that name. The same goes for attribute arguments, including paths hidden in strings (
#[serde(with = "crate::codec")]keeps everything incodecalive). Amoddeclaration inside a macro token stream is read — as a claim, not a resolution: the file it names is spared from the dead-file check (tokio'scfg_fs! { pub mod fs; }, serde'scrate_root!, and the invocation idents of a macro whose rules saymod $x, likerustc_target'ssupported_targets!, all count), but the items in such a file are still invisible to resolution: the module path the macro gives them is unknowable without expansion, so they are neither reported nor able to keep anything else alive. -
Lexical scopes are tracked syntactically, so a binding a macro expands to shadows nothing — though an identifier in macro input already counts as a use of every item with that name, so the two errors point the same way. A bare name in pattern position is read as a use whenever it could name a unit struct, a variant or a
const, which costs a finding for the braced struct and type alias it could equally be binding over. -
A glob import that leads outside the workspace makes its module opaque: names not otherwise in scope there count as uses of every item with that name. Globs within the workspace are expanded and hide nothing.
-
cfgevaluation coversfeature,test,target_os,target_family,unixandwindows, andnot/all/anyover those. Every other predicate —target_arch,debug_assertions, acfga build script sets,cfg(accessible(..)), anything reached throughcfg_attr— reads as "could go either way", so the code behind it is analyzed exactly as before. -
Gate evaluation does not track correlation between atoms:
all(feature = "a", not(feature = "a"))reads as satisfiable even though it provably is not. The finding is lost, never invented. -
Under the default matrix
#[cfg(test)]code counts as a use and#[test]functions are roots, so an item only tests reach is not anunused_pub_itemfinding. It may be atest_only_itemone, which isoffby default;[cfg] test = falseasks the same question a blunter way, and the paragraph on it under Configuration is why both exist. -
The test-only claim is narrower than it sounds, in two directions that both cost findings rather than invent them. Anything a consumer could name is out: a library's public surface, whatever a surface item reaches, and anything
[public-api]declares. That still covers everything apub use inner::*;glob or apub use inner::sub;re-export carries out, but by the ordinary route rather than a rule of its own — either form is public surface, so it is a root in both walks like the rest of it. And an opaque mention keeps an item out entirely — a name in macro input is a root, andassert_eq!(thing(), 1)is how most tests name what they test, so one assertion is enough. -
What a
#[cfg(test)] modconfines is test code whichever way the module is written: an entry point inside an inline#[cfg(test)] mod tests { ... }that is not itself#[test]/#[bench]— a#[no_mangle], an#[allow(dead_code)], an#[allow(unused)] use— is a test root exactly as the same code in a#[cfg(test)] mod tests;file is. The gate is read in full, soall(test, feature = "x")confines a module,any(test, unix)does not, and a module nested inside a confined one is confined whatever its own gate says. -
Much of what
test_only_itemreports about a package's ownsrc/is also reported by rustc, asdead_code, in any build that leaves the tests out —cargo build, andcargo clippy --all-targets, which compiles the crate both ways. What rustc cannot report is apubitem in a test, bench or example target, because the only build that compiles one also uses it. The kind is worth the[severity]line where you want that answer in the report with everything else, in JSON, and baselineable; it is not worth turning on expecting to be told something your compiler is not already telling you. -
Reachability follows references, not containment: an item inside a module nothing names is judged on the paths that name it. A module can be reached through a glob, a
pub use, or generated code without ever being named, so reading "unnamed module" as "everything in it is dead" would be a claim about code Deadwood has not seen. -
An
implblock hangs off its self type, and off the trait too where that resolves inside the workspace. For anything else — a foreign self type (impl Trait for Vec<T>), a blanketimpl<T>, a tuple, a reference — there is no definition to hang it off, so what its body names counts unconditionally. That is most of the recall reachability gives up on generic code, deliberately. -
A definition that is not
pubtakes part in the walk like any other, so a private helper only dead code calls stops keeping what it calls alive. Rooting private items would end every cascade at the first one; rustc'sdead_codelint already reaches them where it can see them. -
An unsatisfiable gate is reported where it is written, and only for the outermost gate — an inner
#![cfg(...)]gates the whole file it is in, and nothing below a dead gate is walked. Enum variants and struct fields are not walked either, nor are items inside function bodies. -
A module the matrix excludes takes its whole conventional subtree with it, unread. An orphan file inside that subtree is therefore not reported as dead: Deadwood did not resolve that module tree, and claiming a file is unreachable from a tree it never read is the failure mode it refuses.
-
An
include!is followed when its path is a string literal and the chain of them is no more than eight deep: the file it names is not dead, and neither is anything amodchain from that file reaches. Two forms are not followed, and both cost noise rather than silence — a path only the build knows (include!(concat!(env!("OUT_DIR"), ...))), and one more than eightinclude!s from a crate root. Files they would reach are still reported dead, because a file Deadwood could not read is not evidence that anything beside it is alive. -
A file reached only through an
include!has its items take no part in resolution: nothing in one is reported as an unused public item, and nothing in one keeps another item alive. The file is evidence that it was reached and evidence of nothing else. A file amodchain also reaches is analyzed exactly as it always was — the exemption is for files the module tree would otherwise never have seen, not for a file that happens to be spliced as well. Admitting the items would be a finding population of its own — measured at 132,414 unused public items inwindows-sys-0.61.2alone, against the 10 it reports today. -
A
pubitem with consumers outside the workspace looks identical to a dead one; for library crates, these findings are advisory until the crate or its item paths are listed under[public-api]. Re-exports on a library's public surface are skipped for the same reason, which also means a genuinely dead one there is missed. -
A
pub use inner::*;glob and a namedpub use inner::sub;both put the module's items — and thepubmodules under it — on a library's public surface, and the root set follows both. What neither reaches is a re-export that leads out of the workspace, or one resolution cannot follow: it puts nothing on the surface at all, which costs findings rather than inventing them. A re-export written in a module that is not itself on the surface roots nothing either, since outside code cannot name it to go through it. -
Anything that resolves ambiguously (a name behind two modules, an alias chain we cannot follow) is treated as used.
-
A dependency whose name is a common word (
log,time,bytes) is kept alive by any mention of that word anywhere in the package, including in macro input and doc comments. Findings are lost, never invented. -
A dependency whose lib target name differs from its package name (
md-5builds a lib namedmd5,rustls-webpkione namedwebpki) is matched by the lib name wherever a current lockfile and a cached, offline-resolvable dependency graph exist — any checkout that has built once — and by a workspace member's targets always. On a cold cache the package-name heuristic runs as before, which can report such an entry against code that uses it; building the project once is the fix. -
A dependency named only by the code a derive expands to is kept alive by its companion: a mentioned
X_derive,X_macrosorX_impldeclared besideXcounts as evidence forX, because the expansion names the base crate (#[derive(Serialize)]emitsextern crate serde as _serde;). The price is the mirror image: a genuinely staleXbeside a live companion is missed, never invented. -
A crate renamed by
extern crate real as alias;oruse real as alias;is followed: everyalias::in the crate that declares it counts forreal, so a package that renames one dependency to the name of another —serde_jsondoes exactly this — is not judged against the wrong entry. The rename is scoped to the target that writes it, since a test target is a separate crate that links the dev-dependencies directly.The two spellings bind differently and are followed differently. Only an
extern crate real as alias;written at the crate root enters the extern prelude, so only that one holds for every module of the crate. Ause real as alias;, or anextern crateat the top of a module file likesrc/foo.rs, is an ordinary item binding in its own module, so it is followed within the file that writes it and no further. A rename inside a nestedmodis not followed at all. Getting this wrong invents findings rather than losing them: a crate that renames one dependency to a name another module uses for real would have that second crate's mentions folded away, and the entry reported unused. -
A dependency declared to turn on a feature of a transitive dependency (
getrandom = { features = ["js"] }), to select a vendored native library, or to force feature unification is named by no code and no[features]entry, and is reported. Nothing syntactic separates it from a stale entry, so the answer is intent: list it under[dependencies]indeadwood.toml. -
The dependency check judges the source tree in front of it. A crate unpacked from a published
.cratearchive usually hastests/andbenches/stripped, so the dev-dependencies they used are reported — correctly for that tree, not for the repository it came from. -
One mention through a macro, an attribute, or a doc comment is enough to make an entry unplaceable, so the misplaced-dependency check is much quieter than the unused one. Across the 35 crates in a local registry it reports nothing at all — including after
#[test]and#[bench]started counting as confinements, which moved mentions in two of those crates without moving a finding in any of them. -
A runtime item an attribute macro owns is opaque, as of phase 23: the item is the macro's input, and what survives expansion — the item verbatim, the item confined to a test build the way
#[tokio::test]confines it, or nothing — is unknowable before expansion. That closed the one shape in which the check invented a finding (a[dev-dependencies]entry named only from a#[tokio::test] fnwas reported as belonging in[dependencies], against a manifest that compiles), and it costs what opacity always costs: a[dependencies]entry named only from such a function is not reported as belonging in[dev-dependencies], even when it does.The cost is half the size it looks, and the half that is missing is impossible rather than merely unobserved. An attribute macro has to resolve in the build the item is compiled into, so
#[rstest]in library code withrstestunder[dev-dependencies]is not a case Deadwood mishandles — it is a case that does not compile (error[E0433], verified). That rules out any test-macro crate declared only as a dev-dependency, which is whererstest,test-case,serial_testandproptestare conventionally put. What remains opaque is an attribute macro from a crate the manifest resolves anyway,#[tokio::test]being the common spelling rather than the whole of it. The boundary has one corner read the other way: an unknown single-segment attribute sharing an item with a#[derive(..)]is indistinguishable from that derive's helper (#[serde(..)]beside#[derive(Serialize)]), is read as the inert helper it almost always is, and leaves the item attributed as written — the multi-segment spelling stays a macro, derive or no derive. A test-confining macro in the helper position would still be read as library code; no such spelling has been seen (the known test-confining macros sit on barefns). -
A file that both a
#[cfg(test)]moddeclaration and one no gate confines to a test build reach is attributed to the second, so what it names is judged as library code. One file gets one answer, and this is the direction that misses findings rather than inventing them. An inlinemodtwo declarations in one file reach —cfg-alternatives of each other — is answered the same way, for the same reason. The declaration that decides it need not be ungated:#[cfg(all(not(test), unix))]carries a gate and is still compiled by a build with no tests in it. -
A
[target.'...'.dependencies]table keyed by a bare target triple rather than acfg(...)expression is not modelled, so narrowingtarget-osdoes not reach its entries; they are judged as if always built. -
A baseline entry suppresses every finding that shares its key. The item's module and namespace are both part of that key, so two same-named items in one file are two entries and so are a type and a value of one name in one module. What is left sharing a key is two
#[cfg]-alternative definitions of one item, where covering both with one entry is the right answer — and nothing else, because Rust does not compile two definitions of one name in one module unless the namespaces separate them. Since the key deliberately ignores the line, there is no way to say which occurrence is the new one, and pointing at a baselined line would be a wrong finding rather than a missed one. -
One exception to "and nothing else", and it costs a finding rather than inventing one: a
pub usewhose target Deadwood cannot resolve recordsboth, which overlaps everything. The kind is in the key too, so an unused re-export and an unused item of one name are already two entries whatever the namespace says; where this bites istest_only_item, the one kind reported about both, and a secondpub useof that name in that module. It takes a target outside the workspace, one behind a glob that leads outside it, or a chain of more than eight aliases to get there — and then a third item of that name added to the module later before anything is actually missed. Resolvable targets, which is nearly all of them, record what they bind. -
Moving a file un-baselines the findings in it for the four kinds that name no module: a
dead_file, anunsatisfiable_cfggate site, and aCargo.tomlentry of either dependency kind whose whole package moved. The item kinds survive a move (above); these do not, because there is nothing about them that a move preserves and guessing would suppress a genuinely new finding.--prune-baselinethen--write-baselineis the workaround, at the cost of re-accepting anything else that regressed in between. So is editing the two or three paths by hand, which the format is meant to allow. This is a settled decision rather than a pending one: the alternative was measured in phase 17 and costs a new field on every entry of the one baseline shape that is portable across releases, to convert a noisy failure into a silent one. -
A whole package directory moving defeats the second pass for the item kinds too, not just the four above: an entry's package is resolved by containment against the workspace's manifest directories, and a path in no package resolves to nothing. It takes a workspace with a member outside the root to happen at all — a single-package workspace's package directory is the workspace root — and none of the 35 registry crates the corpus measures on is one.
-
The second pass that survives a move is scoped to the package but not to the target, because no finding carries one and one file can belong to several. Within a package every target's crate root is spelled
crate, so two binaries or examples each definingpub const Xat the root are one identity to it.
Questions, bug reports, and feature requests all go through GitHub issues — for a bug, a small fixture package that reproduces it is the ideal shape (see CONTRIBUTING.md). Conduct in the project's spaces is covered by the code of conduct.
MIT — see LICENSE.