feat: add cargo-unused-deps - #102
Conversation
`cargo udeps` resolves the crate graph and asks which declared dependencies go unreferenced, so a `[workspace.dependencies]` entry that no member inherits is invisible to it -- it never enters the graph at all. That blind spot accumulated 48 stale entries before PR #99 swept them out by hand, with udeps green throughout. Adds the design doc for a new sibling gate that closes it, named to match the existing `cargo-ensure-no-cyclic-deps` and `cargo-ensure-no-default-features` check tools. The rule is manifest-only: an entry is unused when no workspace member declares it with `workspace = true`, across dependencies, dev- and build-dependencies and their `[target.'cfg(...)']` forms, in both the inline and dotted spellings. That keeps the gate free of false positives and cheap enough for the text/metadata tier -- no compilation, no toolchain pin, no network. cargo-shear was evaluated and rejected as the vehicle: it does implement an unused-workspace-dependency diagnostic, but derives it from static source-usage analysis, so its verdict inherits that analysis's macro-expansion blind spots -- it reports eight entries here that are inherited and genuinely used through macro arguments. Design only; no crate skeleton and no anvil wiring yet. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
`members` globs `crates/*`, so the new crate directory -- which holds a design doc and no manifest yet -- was read as a workspace member with an unreadable `Cargo.toml`. Every cargo invocation failed at metadata time, which is why the whole check suite went red on a docs-only change. The design-docs-first workflow lands the design before the code, so the gap between doc and manifest is expected rather than accidental. Excludes the directory until the crate lands, at which point the entry goes away. `Cargo.lock` is unaffected: the excluded directory contributes no package. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Codecov Report✅ All modified and coverable lines are covered by tests. ❌ Your project status has failed because the head coverage (97.5%) is below the target coverage (100.0%). You can increase the head coverage or adjust the target coverage. Additional details and impacted files@@ Coverage Diff @@
## main #102 +/- ##
======================================
Coverage 97.5% 97.5%
======================================
Files 300 304 +4
Lines 68538 68862 +324
======================================
+ Hits 66876 67197 +321
- Misses 1662 1665 +3
Flags with carried forward coverage won't be shown. Click here to find out more. ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
The design doc alone cannot live under `crates/`: `members` globs `crates/*`, so a directory without a manifest breaks `cargo metadata`, and `cargo sort --workspace` walks the same glob itself -- it ignores the workspace `exclude` that would otherwise paper over it, and 2.1.4 (pinned, and the latest release) has no ignore flag. So the crate lands as a skeleton and the design doc keeps its final path. Following `automation`, the crate is `publish = false` -- there is nothing worth releasing until the implementation exists -- and carries the documented `min-lines-percent = 0.0` coverage opt-out, since a crate with no executable code produces no instrumented regions and would otherwise be graded NO DATA. The implementation change removes both. Replaces the workspace `exclude` added in the previous commit. Verified locally: cargo metadata, cargo sort --check --check-format, clippy -D warnings, rustdoc -D warnings, fmt --check, cargo heather, ensure-no-default-features, ensure-no-cyclic-deps, and cargo-spellcheck all pass, and `cargo anvil --dry-run` reports nothing to write. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Fills in the skeleton with the check itself: read the workspace root's `[workspace.dependencies]`, enumerate members with `cargo metadata --no-deps`, and report every catalog entry that no member declares with `workspace = true`. `--fix` removes them through `toml_edit`, carrying a removed entry's comments to the next survivor so a group header keeps labeling the group it introduces. Addresses review feedback on the design: a manifest with no `[workspace]` table is no longer an error. cargo-anvil manages single-crate repositories too, and a generated recipe runs the same command everywhere, so a hard error would make the check unusable in exactly the repositories that never had the problem. It now reports the situation and succeeds -- the property holds vacuously -- with `--require-workspace` restoring the strict reading for callers that know they are pointing at a workspace root. Two unreachable paths were removed rather than left uncovered: `remove` takes the catalog table for granted (callers only fix a catalog they already read entries from) and the write-failure context is formatted eagerly instead of in a closure no test can portably reach. The package is at 100% line and function coverage. Verified end to end against this repository: it reports all 70 catalog entries as inherited, flags an injected entry, and `--fix` removes it and restores the manifest byte for byte. The crate is now publishable, so the skeleton's `publish = false` and its coverage opt-out are gone. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
🤖 Clawpilot here! Posted automatically by Clawpilot (an AI agent), not by a human. Please verify before acting. Agreed, and changed — the missing- Your reasoning is the deciding one: the generated anvil recipe runs the same command in every repo it manages, including single-crate ones. A hard error there would fail exactly the repositories that never had the problem, and each would need a local opt-out to get back to green. Succeeding is also the honest answer — "no catalog entry goes uninherited" holds vacuously when there is no catalog. Implemented as you suggested, with the switch inverted so the lenient reading is the default:
The rest of the crate is implemented in the same push ( Leaving this thread unresolved for you to close. |
|
`cargo mutants` caught a real gap: deleting the `!` in `remove`'s `else if !carried.is_empty()` survived the suite. With that mutation the carry-forward never fires, but the pending comments are not lost -- the trailing-block path still appends them after the last surviving entry, so the comment remains somewhere in the file and a `contains` assertion stays green while the comment has silently left its group. The test now pins the position: the carried comment must precede the next surviving entry's own decor. That is the property the code exists to provide, and it fails under the mutation. 43 mutants, 41 caught, 2 unviable, 0 missed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses seven review comments. `--fix` no longer truncates the workspace root in place. It writes a temporary file in the manifest's own directory and renames it over the original -- the house pattern from cargo-anvil -- so an interrupted run cannot leave the one file that breaks every other tool in the repo truncated. Before the rename the manifest is re-read and compared against the bytes that were parsed: `cargo metadata` runs in between as a child process, and an editor save landing in that window now aborts the fix instead of being silently overwritten. An empty catalog no longer skips the stale allow-list report. That is the boundary where *every* allowed name suppresses nothing, so it is exactly where the documented contract mattered most. Carried comments are now reported. The carry-forward cannot tell a group header from a note about one specific dependency, so a note about a removed entry lands on the next survivor and reads as if it were about that one -- worse than dropping it, because a dropped comment is visible in the diff and a wrong attribution is not. The relocation is printed on stderr, naming the source entries, the target, and the number of comment lines, and the hazard is documented in the design doc and crate docs. Tests for the two behaviours whose absence was noted: `--fix` keeps an allowed entry while removing the others (the one failure mode that destroys user data rather than printing something wrong), and member globs plus `exclude` follow Cargo, which is the reason the tool shells out to `cargo metadata` at all. The design doc no longer describes the anvil wiring as done; it is a follow-up, because anvil installs pinned tools from crates.io and the crate is unreleased. Adds the three artifacts `scripts/add-crate.ps1` would have produced: the crate's `CHANGELOG.md` scaffold, the root README crates entry, and the root CHANGELOG index entry. Only the scaffold is written -- release tooling owns changelog content. 100% line and function coverage; 53 mutants, 50 caught, 3 unviable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The new `write_back` unit tests do real filesystem work in a temp directory, and the anvil miri leg runs lib unit tests under filesystem isolation, so `mkdir` came back unsupported and `anvil-miri` failed. Guards the module with `#[cfg(not(miri))]`, the same way every other filesystem-touching test in this repo is guarded. Coverage is unaffected: the coverage run does not use miri. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Addresses two review comments; both reproduced against the built binary
before changing anything.
**Replacing by rename brought the temp file's identity with it.**
`NamedTempFile` creates its file at mode 0600 (confirmed in
tempfile-3.20.0 `src/file/imp/unix.rs:24`), and a rename carries the
source mode rather than inheriting the target's, so on Unix a 0644
workspace root came back owner-only after every successful --fix -- a
change git does not track. `persist` also replaced a symlinked manifest
with a regular file, where the previous in-place write followed the link.
Now the manifest's permissions are read up front and applied to the
replacement before the rename, and the path is canonicalized first so the
rename lands on the real file. Both choices are stated in the doc comment.
**The carry report could claim moves that never happened.** With a dotted
last survivor (`serde.version = "1"`) the append is skipped -- only a
plain value has a suffix -- but the `Carry` was pushed regardless, so
stderr claimed the comment had been carried onto `serde` while it was
absent from the output. Reproduced exactly as described. `onto` is now
derived from the operation that actually placed the text, so the
unplaceable cases report a drop, and the message no longer asserts a
reason ("every entry was removed") that is false for the dotted case.
**Comments on removed sub-table entries vanished unreported.** For
`[workspace.dependencies.name]` the decor lives on the table, not the
key, so `comments_of` saw an empty prefix and no `Carry` was recorded.
`decor_prefix` now reads both.
Tests: dotted-last-survivor drop, sub-table carry, and the stderr
assertion the trailing-removal path was missing. Adds symlink terms to
`.spelling`.
100% line and function coverage; 54 mutants, 52 caught, 2 unviable.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Both reported cases reproduced against the built binary first. `toml_edit` keeps an entry's leading comments in one of three places: on the key for a plain value, on the table for a `[workspace.dependencies.name]` sub-table, and -- measured, not assumed -- on the *first inner key* for a dotted `name.version = "1"`. The previous push read the table slot but always wrote the key slot, so a sub-table survivor had its own comment rendered twice: `--fix` put a line into the manifest that the user never wrote. A dotted survivor was worse in the other direction -- setting its outer key decor renders nothing, so the carried comment was lost while stderr still claimed it had been carried. `leading_comments` and `prepend_comments` now resolve the same slot, so what is read is what is written. That removes the duplication, and it also lets a dotted survivor carry the text properly rather than dropping it, which is better than the reported failure mode required. The same lookup fixes a third case neither comment covered: a removed *dotted* entry's comment lived on the inner key, so it used to vanish with no `Carry` recorded at all -- the exact contract violation the reporting exists to prevent. It is now carried and reported. Trailing removals are unchanged: carried text has to land after the final survivor and only a plain value has a suffix, so a sub-table survivor there still drops the comments -- reported, not silent. Also corrects the `Carry::onto` doc, which still described `None` as meaning the table was emptied. 100% line and function coverage; 60 mutants, 58 caught, 2 unviable. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 14 out of 15 changed files in this pull request and generated no new comments.
Suppressed comments (3)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-ensure-no-unused-workspace-deps/src/fix.rs:16
Outcome::carriesincludes both comment blocks that are successfully carried onto a survivor and comment blocks that are ultimately dropped (onto: None). The current field doc says "moved", which is misleading given theDropped ...reporting andCarry::ontosemantics.
This issue also appears in the following locations of the same file:
- line 20
- line 36
/// Comment blocks that moved off a removed entry.
crates/cargo-ensure-no-unused-workspace-deps/src/fix.rs:23
Carryrecords both successful carries and drops (onto: None), so the doc comment "Only recorded when comments actually moved" is incorrect. It’s still true thatfromis never empty, but the condition is that at least one comment line existed on removed entries (carried or dropped).
/// Comments that belonged to removed entries and had to go somewhere else.
///
/// Only recorded when comments actually moved, so `from` is never empty.
///
crates/cargo-ensure-no-unused-workspace-deps/src/fix.rs:36
Carry::linesis also used when comments are dropped (onto: None), so describing them as lines that "moved" is inaccurate.
/// How many comment lines moved.
|
martin-kolinek Would it be worth expanding the scope of this tool to completely replace cargo-udeps? So simultaneously check whether each project has superfluous dependencies, and then whether the workspace has superfluous dependencies? |
|
Martin Taillefer (@geeknoid), I'm not a huge fan of reimplementing existing tools unless we can fix specific issues with those tools - do we have issues with cargo-udeps? I thought we're pretty happy with how it works. |
Sander Saares (sandersaares)
left a comment
There was a problem hiding this comment.
[Copilot speaking]
Published 29 findings. One finding follows up on an existing discussion thread.
See diagnostics
| Diagnostic | Value |
|---|---|
| Cache | Hit |
`cargo-ensure-no-unused-workspace-deps` names one check. The tool is meant to grow into full unused-dependency validation, and "workspace" is the wrong word for something whose expensive half is per-crate. Renaming now is free -- the crate is unreleased, nothing pins it, and no anvil recipe references it -- and stops being free the moment it publishes, so it is better done before this lands than in a follow-up. Mechanical throughout: crate directory, package name, binary, subcommand (`cargo unused-deps`), the clap variant, the doc-attribute asset URLs, the `CARGO_BIN_EXE_` handle in tests, and Cargo.lock. The manifest metadata key follows the crate name to `[workspace.metadata.unused-deps]`, matching how cargo-coverage-gate drops the `cargo-` prefix for its key. The root README and CHANGELOG entries move to the end of their lists, because the new name sorts after cargo-heather rather than beside the other `ensure-no-` gates. Description now says what the crate does rather than what one check does: it finds unused dependencies, starting with uninherited `[workspace.dependencies]` entries. Verified: build, 30 tests, clippy -D warnings, fmt, cargo-sort, license headers, repo-wide spellcheck, README regeneration check, `cargo anvil --dry-run` clean, and `cargo unused-deps` still reports all 70 catalog entries inherited. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The tool’s description currently overpromises scope and the allow-list parsing silently ignores invalid config values, which can lead to confusing behavior for users.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/15 changed files
- Comments generated: 3
- Review effort level: Lite
# Conflicts: # .spelling
The branch had fallen 40 commits behind main and conflicted in `.spelling`, where both sides had appended words. Resolved as the union. Merging main also brought a newer cargo-anvil, which now tracks the new crate: `cargo anvil` adds its `anvil-lints` region to `.anvil.lock`, and `Cargo.lock` picks up the version-qualified `toml_edit` entry main introduced. `cargo anvil --dry-run` is clean afterwards. Verified on the merged branch: build, 30 tests, clippy -D warnings, fmt and repo-wide spellcheck. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The new crate’s documentation overstates --fix concurrency guarantees and the allow-list parsing silently ignores malformed entries, which should be addressed for correct user-facing behavior.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
Suppressed comments (1)
crates/cargo-unused-deps/src/detect.rs:74
allowed = [...]parsing silently drops any non-string entries viafilter_map(Value::as_str). A typo likeallowed = [123]would be ignored without warning, causing confusing CI behavior (the allow-list appears configured, but nothing is allowed). Consider validating that every entry is a string and returning a clear error when the config is malformed (similar to other manifest parsing in this repo, e.g. cargo-anvil’s[workspace].membersparsing errors on non-string entries in crates/cargo-anvil/src/workspace.rs:129-141).
.and_then(|config| config.get("allowed"))
.and_then(Item::as_array)
.map(|names| names.iter().filter_map(Value::as_str).map(ToOwned::to_owned).collect())
.unwrap_or_default();
- Files reviewed: 14/16 changed files
- Comments generated: 1
- Review effort level: Lite
- Error on non-string [workspace.metadata.unused-deps] allowed entries instead of silently ignoring them. - Soften the --fix concurrency wording to match the guard the code has. - Narrow the crate and workspace README descriptions to the catalog check. - Encode the positive carried-comment count in the type (NonZeroUsize). - Document the comment-preservation boundary: leading decor is carried, a same-line trailing comment leaves with the entry it annotates. - Use inheritance terminology consistently instead of 'used'. - Make count-dependent diagnostics grammatical for a single item. - Add rationale comments for the help palette, the declaration-key rule, the subcommand argument the tests inject, and the fixture metadata. - Import std::fs and toml_edit::RawString at module scope. - Make the design doc's CI section state one consistent (unwired) state. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Condense rustdoc first paragraphs so the rendered summary is one line. - Reserve 'workspace root' for the directory; name the root manifest where the file is meant. - Distinguish unused catalog entries from stale allow-list entries. - Mark the CLI synopsis as text rather than bash, and name the checks in the composition claim instead of counting table rows. - Point the design's CI section at anvil's check catalog and name the group and impact bucket it will use. - Cover the accumulation of comments from consecutive removed entries. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟡 Changes recommended
The allow-list parser silently ignores a present-but-wrong-type allowed value instead of reporting a configuration error, which can cause unexpected behavior in CI.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Review details
- Files reviewed: 14/16 changed files
- Comments generated: 1
- Review effort level: Lite
There was a problem hiding this comment.
🟢 Approval recommended
The implementation, documentation, and integration tests align on the stated rule/edge cases and no concrete correctness or contract issues were found in the reviewed diffs.
Review details
- Files reviewed: 14/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
A present but non-array 'allowed' key was read as an absent one, so a configuration that does not work was indistinguishable from one that allows nothing. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
🟢 Approval recommended
The implementation, tests, and design doc appear consistent; only a minor error-context wording improvement was identified.
Review details
Suppressed comments (1)
Previously missed (1) — in code that hasn't changed since the last review.
crates/cargo-unused-deps/src/lib.rs:300
- The error context for creating the temp file is currently
failed to write <manifest>, which is misleading when the failure is actually "could not create a temp file" (e.g., permissions / read-only directory). Using a creation-specific context here makes the failure clearer and easier to act on.
- Files reviewed: 14/16 changed files
- Comments generated: 0 new
- Review effort level: Lite
WhatAdds
cargo-unused-deps: a cargo subcommand that fails when a[workspace.dependencies]entry is inherited by no workspace member, plus its design doc.-docs/design/README.md— the design.-src/— detection,--fix, allow-list. Integration tests cover Cargo-resolved membership, every inheritance form, the allow-list and its stale entries,--fixcomment carrying and dropping, and the error paths; 100% line and function coverage.Work item: 7790070.## Whycargo udepsresolves the crate graph and asks which declared dependencies go unused. A[workspace.dependencies]entry that no member inherits never enters that graph, so it is invisible to udeps — and tocargo machete, for the same structural reason.That blind spot accumulated 48 stale entries in this repo before #99 swept them out by hand, with udeps green throughout. Per review feedback on #99, the check belongs in cargo-anvil so every repo using it benefits, rather than living as a one-off script.## The ruleAn entry is unused when no workspace member declares it withworkspace = true— acrossdependencies,dev-dependencies,build-dependenciesand their[target.'cfg(…)']forms, in both the inline and dotted spellings.Manifest-only, so the gate has no false positives and needs no compilation, toolchain pin, or network. It slots into themodifiedtier next toensure-no-cyclic-depsandensure-no-default-features, and composes with udeps without overlap:| Question | Answered by ||---|---|| Is this catalog entry inherited by any member? | this tool || Is an inherited dependency actually referenced in code? |udeps|## Prior art evaluated-cargo-sheardoes implement ashear/unused_workspace_dependencydiagnostic, and it does catch the case — verified by injecting an unused entry into this repo's root manifest. But it derives the verdict from static source-usage analysis, so it inherits that analysis's macro-expansion blind spots: it reports 8 entries here (rustdoc-types-v50..v57) that are inherited and genuinely used, viagenerate_version_support!("50", rustdoc_types_v50). It also skips the check entirely for single-member workspaces. Its other diagnostics remain independently interesting — separate decision.-cargo-unused-workspace-depson crates.io does exactly this, but is one release from Sept 2025 with no commits since — not something to pin as an anvil dependency.## Points to review1.--fixis in scope, unlike the siblingensure-no-default-features("the tool reports; the human edits"). Rationale: removing an entry nobody inherits is mechanical and lossless —Cargo.lockis unaffected by construction — unlike deciding which features to keep. The comment-carrying rules are specified normatively, including the empty-table case.2. Members come fromcargo metadata --no-depsrather than re-derivingmembers/globs/excludetextually. Costs a subprocess, but any disagreement with Cargo that drops a member is a false positive.3. Allowlist lives in[workspace.metadata.…] allowed = […], not a CLI flag, because the generated recipe invokes the tool with a fixed argument list.## Not in this PRThe anvil wiring (versions.justpin,tools.justinstall/validate,checks/recipe,pr-fastgroup,checks.mdcatalog row). Anvil installs pinned tools from crates.io, so the wiring has nothing to pin until the crate's first release; design §7 says so explicitly.🤖 Authored by Clawpilot (an AI agent), not by a human.