Skip to main content

cargo_unused_deps/
lib.rs

1// Copyright (c) Microsoft Corporation.
2// Licensed under the MIT License.
3
4//! A Cargo subcommand that finds unused dependencies.
5#![doc(html_logo_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-unused-deps/logo.png")]
6#![doc(
7    html_favicon_url = "https://media.githubusercontent.com/media/microsoft/ox-tools/refs/heads/main/crates/cargo-unused-deps/favicon.ico"
8)]
9//!
10//! It answers three questions that usually take two other tools and still leave
11//! a gap:
12//!
13//! - **Catalog.** Which `[workspace.dependencies]` entries does no member
14//!   inherit? Inheritance is written in the manifest, so this needs no compiler
15//!   and cannot produce a false positive.
16//! - **Unused.** Which declared dependencies did no compiled unit load?
17//! - **Misplaced.** Which `[dependencies]` entries only development units load,
18//!   and therefore belong in `[dev-dependencies]`?
19//!
20//! The last two are answered by rustc itself, through the
21//! `unused_crate_dependencies` lint, aggregated across every unit of a package:
22//! a dependency is unused only when every unit that had it in scope said so.
23//! Doctests are included, which no other tool manages -- rustdoc discards the
24//! compiler's output for them, so this binary stands in for the compiler rustdoc uses
25//! and keeps a copy.
26//!
27//! # Requirements
28//!
29//! Package-level checks require a nightly toolchain because compiling doctests
30//! without running them is unstable. The catalog-only invocation with no package
31//! selector is manifest-only and runs on stable.
32//!
33//! # Usage
34//!
35//! Run every check across a Cargo workspace:
36//!
37//! ```bash
38//! cargo +nightly unused-deps --workspace
39//! ```
40//!
41//! Restrict the compiled evidence the way cargo does, which is what lets an
42//! impact-scoped pipeline pass its own package list straight through:
43//!
44//! ```bash
45//! cargo +nightly unused-deps --package my-crate --package other-crate
46//! ```
47//!
48//! Run only the workspace-global catalog check by omitting package selection:
49//!
50//! ```bash
51//! cargo unused-deps
52//! ```
53//!
54//! Remove the catalog entries nobody inherits:
55//!
56//! ```bash
57//! cargo +nightly unused-deps --fix
58//! ```
59//!
60//! `--manifest-path` points at an explicit workspace root, defaulting to the
61//! `Cargo.toml` in the current directory. A manifest with no `[workspace]` table
62//! declares no catalog, so that check passes with a note while the rest still
63//! run; `--require-workspace` turns it into an error instead.
64//!
65//! Package selection scopes the compiled evidence only. The catalog check always
66//! reads every member, because "no member inherits this entry" is only true if
67//! every member was consulted. With no `--package` or `--workspace`, no package
68//! is compiled and only that catalog check runs.
69//!
70//! # Configuration
71//!
72//! A dependency kept on purpose is exempted in the workspace manifest:
73//!
74//! ```toml
75//! [workspace.metadata.unused-deps]
76//! allowed = ["kept-on-purpose"]
77//!
78//! [package.metadata.unused-deps]
79//! allowed = ["package-local-side-effect"]
80//! ```
81//!
82//! A workspace-level `allowed` name declared by neither the catalog nor any
83//! member is reported as stale without failing the run. The lists are also the
84//! answer for a dependency linked for its side effects and never named -- an
85//! allocator or `-sys` shim -- where "unused" is literally true and
86//! operationally wrong.
87//!
88//! # Fixing
89//!
90//! `--fix` covers the catalog only. It replaces the manifest atomically -- a
91//! temporary file in the same directory, renamed over the original, carrying the
92//! permissions of the manifest it replaces and following a symlinked manifest to
93//! its target. Before replacement it rechecks workspace membership and every
94//! manifest input; a change detected there aborts the write. This narrows but
95//! cannot close the final comparison-to-rename race.
96//!
97//! Comments on a removed entry are carried to the next surviving entry, which
98//! keeps a group header attached to the group it introduces. A note about one
99//! specific dependency is indistinguishable from such a header, so every move
100//! is reported on stderr: check that carried text still describes the entry it
101//! landed on. Comments that cannot be placed -- the removal emptied the table,
102//! or left a trailing survivor with nothing to append to -- are reported as
103//! dropped.
104//!
105//! Removing a dependency a crate declares is not automated: the evidence is
106//! strong enough to fail a build and ask a human, not strong enough to edit code
107//! paths nobody compiled.
108//!
109//! # Installation
110//!
111//! ```bash
112//! cargo install cargo-unused-deps
113//! ```
114//!
115//! # Example output
116//!
117//! ```text
118//! ✅ All 70 workspace dependencies in Cargo.toml are inherited by one of 10 members.
119//! ❌ Found 1 dependency problem:
120//!
121//!   my-crate [dependencies] once_cell: no compiled unit loaded it.
122//!       remove it from [dependencies], or preserve that scope while narrowing where it is declared.
123//! ```
124
125#![cfg_attr(coverage_nightly, feature(coverage_attribute))]
126#![cfg_attr(docsrs, feature(doc_cfg))]
127
128mod detect;
129mod doctests;
130mod evidence;
131mod fix;
132mod verdict;
133
134use std::collections::BTreeSet;
135use std::ffi::OsString;
136use std::fs;
137use std::io::Write;
138use std::path::{Path, PathBuf};
139use std::process::ExitCode;
140
141use anyhow::{Context, Result, bail, ensure};
142use cargo_metadata::MetadataCommand;
143use clap::builder::Styles;
144use clap::builder::styling::{AnsiColor, Effects};
145use clap::{Parser, Subcommand};
146use tempfile::NamedTempFile;
147
148use crate::detect::{Catalog, ManifestInput, Section, WorkspaceCatalog};
149use crate::fix::Carry;
150use crate::verdict::Verdict;
151
152// Deliberately identical to the palette of the repository's other styled Cargo
153// subcommands, so help output looks the same whichever one the user reaches for.
154const CLAP_STYLES: Styles = Styles::styled()
155    .header(AnsiColor::Green.on_default().effects(Effects::BOLD))
156    .usage(AnsiColor::Green.on_default().effects(Effects::BOLD))
157    .literal(AnsiColor::Cyan.on_default().effects(Effects::BOLD))
158    .placeholder(AnsiColor::Cyan.on_default());
159
160/// Cargo subcommand to find unused and misplaced dependencies.
161#[derive(Parser, Debug)]
162#[command(bin_name = "cargo", version, about, author)]
163#[command(styles = CLAP_STYLES)]
164struct Cli {
165    #[command(subcommand)]
166    command: Commands,
167}
168
169/// The subcommand token Cargo passes through, and the options that follow it.
170///
171/// Cargo invokes a custom subcommand as `cargo-unused-deps unused-deps ...`, so
172/// the tool parses the repeated name as a nested subcommand and takes the check's
173/// options from that level rather than from the top-level parser.
174#[derive(Subcommand, Debug)]
175enum Commands {
176    /// Find unused dependencies: uninherited catalog entries, dead declarations
177    /// and misplaced ones
178    #[command(version, display_name = "cargo-unused-deps")]
179    UnusedDeps {
180        /// Path to the workspace root Cargo.toml
181        #[arg(long, default_value = "Cargo.toml", value_name = "PATH")]
182        manifest_path: PathBuf,
183
184        /// Exact package selector to gather compile evidence for. Repeatable
185        #[arg(short = 'p', long = "package", value_name = "SPEC")]
186        packages: Vec<String>,
187
188        /// Gather compile evidence for every workspace member
189        #[arg(long, conflicts_with = "packages")]
190        workspace: bool,
191
192        /// Exclude a member from a --workspace run. Repeatable
193        #[arg(long, value_name = "SPEC", requires = "workspace")]
194        exclude: Vec<String>,
195
196        /// Run only the named checks
197        #[arg(long = "check", value_name = "NAME", value_enum)]
198        checks: Vec<Check>,
199
200        /// Remove the unused entries instead of only reporting them
201        #[arg(long)]
202        fix: bool,
203
204        /// Treat a manifest with no [workspace] table as an error
205        #[arg(long)]
206        require_workspace: bool,
207    },
208}
209
210/// The checks a run can perform.
211#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
212enum Check {
213    /// Declared dependencies no unit loaded.
214    Unused,
215
216    /// Normal dependencies only development units use.
217    Misplaced,
218}
219
220impl Check {
221    /// Whether `selected` asks for this check. An empty selection means all.
222    fn wanted(self, selected: &[Self]) -> bool {
223        selected.is_empty() || selected.contains(&self)
224    }
225}
226
227/// Entry point: either the check, or the compiler shim rustdoc invokes.
228///
229/// rustdoc calls a `--test-builder` as a bare rustc, with no flag of ours to
230/// key on, so shim mode is signaled by the capture-file variable this tool
231/// sets on the doctest build it starts.
232///
233/// # Errors
234///
235/// Returns whatever the selected mode returns.
236//
237// The shim branch runs in rustdoc's child process. Its behavior is covered by
238// the doctest integration cases, but that child does not contribute a profile
239// to the parent coverage run.
240#[cfg_attr(coverage_nightly, coverage(off))]
241pub fn dispatch(args: &[OsString]) -> Result<ExitCode> {
242    if std::env::var_os(evidence::WRAPPER_VAR).is_some() {
243        return evidence::wrapper(&args[1..]);
244    }
245    if std::env::var_os(doctests::RUSTDOC_WRAPPER_VAR).is_some() {
246        return doctests::rustdoc_wrapper(&args[1..]);
247    }
248    if let Some(capture) = std::env::var_os(doctests::CAPTURE_VAR) {
249        let subcommand = args.get(1).map(OsString::as_os_str);
250        if subcommand != Some(SUBCOMMAND.as_ref()) {
251            return doctests::shim(&args[1..], Path::new(&capture));
252        }
253    }
254
255    run()
256}
257
258/// The cargo subcommand this binary answers to.
259const SUBCOMMAND: &str = "unused-deps";
260
261/// Parse the Cargo subcommand arguments and execute the requested checks.
262///
263/// Returns [`ExitCode::SUCCESS`] when every catalog entry is inherited or
264/// allowed and the selected package checks find no unused or misplaced
265/// declarations. Under `--fix`, successfully removing uninherited catalog
266/// entries satisfies the catalog half. Returns [`ExitCode::FAILURE`] for any
267/// remaining finding. Returning an exit code (rather than calling
268/// `std::process::exit`) lets `main` unwind normally so the process terminates
269/// through the standard runtime path -- important under coverage
270/// instrumentation, where an abrupt exit can skip the profile flush.
271///
272/// # Errors
273///
274/// Returns an error if a manifest cannot be read or parsed, workspace members
275/// cannot be enumerated, compiler or doctest evidence collection fails, package
276/// selectors are invalid, or a fixed manifest cannot be written back.
277fn run() -> Result<ExitCode> {
278    let cli = Cli::parse();
279    let Commands::UnusedDeps {
280        manifest_path,
281        packages,
282        workspace,
283        exclude,
284        checks,
285        fix,
286        require_workspace,
287    } = cli.command;
288    let manifest_path = workspace_manifest_of(&manifest_path)?;
289
290    let package_context = if package_checks_selected(&packages, workspace) {
291        let selection = PackageSelection {
292            packages,
293            workspace,
294            exclude,
295        };
296        let workspace = workspace_of(&manifest_path)?;
297        let selected = selection.resolve(&workspace.packages)?;
298        Some((selection.flags(), workspace, selected))
299    } else {
300        None
301    };
302
303    // The catalog question is workspace-global and always runs. Package
304    // selection applies only to checks backed by compile evidence.
305    let mut failed = catalog_check(&manifest_path, fix, require_workspace)? != ExitCode::SUCCESS;
306
307    if let Some((selection_flags, workspace, selected)) = package_context {
308        failed |= source_checks(&manifest_path, &selection_flags, &workspace, &selected, &checks)?;
309    }
310
311    Ok(if failed { ExitCode::FAILURE } else { ExitCode::SUCCESS })
312}
313
314/// Whether the caller requested compiler-backed package checks.
315///
316/// Mutating this predicate in either direction makes a selector-free catalog
317/// run compile the entire workspace, which is an unbounded timeout rather than
318/// a useful mutation-test result. CLI integration tests cover both outcomes.
319#[mutants::skip]
320fn package_checks_selected(packages: &[String], workspace: bool) -> bool {
321    !packages.is_empty() || workspace
322}
323
324/// Resolve any member manifest to the root manifest Cargo associates with it.
325fn workspace_manifest_of(manifest_path: &Path) -> Result<PathBuf> {
326    let manifest = detect::read_manifest(manifest_path)?;
327    if matches!(detect::catalog(&manifest)?, Catalog::Workspace(_)) {
328        return Ok(manifest_path.to_path_buf());
329    }
330
331    let supplied = manifest_path
332        .canonicalize()
333        .context(format!("failed to resolve {}", manifest_path.display()))?;
334    let package_dir = supplied
335        .parent()
336        .expect("a canonical manifest file path always has a parent directory");
337
338    for ancestor in package_dir.ancestors().skip(1) {
339        let candidate = ancestor.join("Cargo.toml");
340        if !candidate.is_file() {
341            continue;
342        }
343        let document = detect::read_manifest(&candidate)?;
344        if !matches!(detect::catalog(&document)?, Catalog::Workspace(_)) {
345            continue;
346        }
347        if members_of(&candidate)?.iter().any(|member| member == &supplied) {
348            return Ok(candidate);
349        }
350    }
351
352    Ok(manifest_path.to_path_buf())
353}
354
355/// Cargo-style package selection for compiler-backed checks.
356struct PackageSelection {
357    /// Explicit package names or exact `name@version` specs.
358    packages: Vec<String>,
359    /// Whether every workspace package is selected.
360    workspace: bool,
361    /// Packages removed from a workspace selection.
362    exclude: Vec<String>,
363}
364
365impl PackageSelection {
366    /// Flags forwarded to Cargo after the same selectors are resolved locally.
367    fn flags(&self) -> Vec<OsString> {
368        let mut flags = Vec::new();
369        for package in &self.packages {
370            flags.push(OsString::from("--package"));
371            flags.push(OsString::from(package));
372        }
373        if self.workspace {
374            flags.push(OsString::from("--workspace"));
375        }
376        for excluded in &self.exclude {
377            flags.push(OsString::from("--exclude"));
378            flags.push(OsString::from(excluded));
379        }
380        flags
381    }
382
383    /// Resolve the explicitly selected roots against workspace metadata.
384    fn resolve(&self, packages: &[verdict::Package]) -> Result<BTreeSet<PathBuf>> {
385        let mut selected = if self.workspace {
386            packages.iter().map(|package| package.manifest_path.clone()).collect()
387        } else {
388            resolve_selectors(packages, &self.packages, false)?
389        };
390
391        for excluded in resolve_selectors(packages, &self.exclude, true)? {
392            selected.remove(&excluded);
393        }
394
395        Ok(selected)
396    }
397}
398
399/// Resolve exact package names and `name@version` specs.
400fn resolve_selectors(packages: &[verdict::Package], selectors: &[String], allow_missing: bool) -> Result<BTreeSet<PathBuf>> {
401    let mut resolved = BTreeSet::new();
402    for selector in selectors {
403        let matches: Vec<&verdict::Package> = packages
404            .iter()
405            .filter(|package| selector == &package.name || selector == &format!("{}@{}", package.name, package.version))
406            .collect();
407        match matches.as_slice() {
408            [] if allow_missing => {}
409            [] => bail!("package selector `{selector}` did not match any workspace member"),
410            [package] => {
411                resolved.insert(package.manifest_path.clone());
412            }
413            _ => bail!("package selector `{selector}` matched more than one workspace member; qualify it with @version"),
414        }
415    }
416    Ok(resolved)
417}
418
419/// The checks that read compile evidence: unused declarations and misplaced ones.
420///
421/// Returns whether anything was found.
422fn source_checks(
423    manifest_path: &Path,
424    selection_flags: &[OsString],
425    workspace: &Workspace,
426    selected: &BTreeSet<PathBuf>,
427    checks: &[Check],
428) -> Result<bool> {
429    let plain_target_dir = workspace.evidence_target_dir.path().join("plain");
430    let all_target_dir = workspace.evidence_target_dir.path().join("all-targets");
431    // Two passes: default targets first, where a report can only have come from
432    // a target's single plain unit, then everything.
433    let plain = evidence::gather(manifest_path, selection_flags, &plain_target_dir, false)?;
434    let all = evidence::gather(manifest_path, selection_flags, &all_target_dir, true)?;
435
436    // Doctest evidence can only ever spare a dependency, never accuse one, so
437    // it is gathered lazily: judge first without it, then compile the doctests
438    // of only those packages that produced a finding, and judge again. On a
439    // large workspace that is the difference between a handful of doctest
440    // builds and one per package.
441    let mut candidates = verdict::judge(
442        &workspace.packages,
443        selected,
444        &plain,
445        &all,
446        &doctests::DoctestEvidence::default(),
447        &workspace.allowed,
448    );
449    candidates.retain(|finding| {
450        // Doctests can spare unused normal/dev declarations and can turn an
451        // unused normal declaration into a misplaced one. They cannot affect
452        // build dependencies or a declaration already proven misplaced.
453        finding.verdict == Verdict::Unused
454            && finding.section != Section::Build
455            && (Check::Unused.wanted(checks) || finding.section == Section::Normal)
456    });
457    let doctests = doctest_evidence(manifest_path, workspace, &candidates)?;
458
459    let findings = verdict::judge(&workspace.packages, selected, &plain, &all, &doctests, &workspace.allowed);
460
461    let wanted: Vec<&verdict::Finding> = findings
462        .iter()
463        .filter(|finding| match finding.verdict {
464            Verdict::Unused => Check::Unused.wanted(checks),
465            Verdict::Misplaced => Check::Misplaced.wanted(checks),
466        })
467        .collect();
468
469    if wanted.is_empty() {
470        println!(
471            "✅ No selected dependency problems found in {} {}.",
472            selected.len(),
473            if selected.len() == 1 { "package" } else { "packages" }
474        );
475        return Ok(false);
476    }
477
478    report_findings(&wanted);
479
480    Ok(true)
481}
482
483/// Compile the doctests of the packages a finding was raised against.
484///
485/// Nothing else needs them: a doctest can only reveal that a dependency *is*
486/// used, so a package with no findings has nothing a doctest could change.
487fn doctest_evidence(manifest_path: &Path, workspace: &Workspace, candidates: &[verdict::Finding]) -> Result<doctests::DoctestEvidence> {
488    let mut evidence = doctests::DoctestEvidence::default();
489    if candidates.is_empty() {
490        return Ok(evidence);
491    }
492
493    let shim = std::env::current_exe().context("failed to locate this executable to use as the doctest shim")?;
494    let accused: BTreeSet<&Path> = candidates.iter().map(|finding| finding.manifest_path.as_path()).collect();
495
496    for package in &workspace.packages {
497        // Only a library target can have doctests; asking cargo for the
498        // doctests of a bin-only package is an error, not an empty answer.
499        if package.has_doctests && accused.contains(package.manifest_path.as_path()) {
500            let selector = format!("{}@{}", package.name, package.version);
501            let found = doctests::gather_package(
502                manifest_path,
503                &selector,
504                &workspace.evidence_target_dir.path().join("doctests"),
505                &shim,
506            )?;
507            evidence.insert(package.manifest_path.clone(), found);
508        }
509    }
510
511    Ok(evidence)
512}
513
514/// Report every source-level finding, grouped the way a reader fixes them.
515fn report_findings(findings: &[&verdict::Finding]) {
516    eprintln!(
517        "❌ Found {} dependency {}:\n",
518        findings.len(),
519        if findings.len() == 1 { "problem" } else { "problems" }
520    );
521
522    for finding in findings {
523        let section = match finding.section {
524            Section::Normal => "dependencies",
525            Section::Development => "dev-dependencies",
526            Section::Build => "build-dependencies",
527        };
528        let table = dependency_table(finding.target.as_deref(), section);
529
530        match finding.verdict {
531            Verdict::Unused => {
532                eprintln!("  {} {table} {}: no compiled unit loaded it.", finding.package, finding.name);
533                eprintln!("      remove it from {table}, or preserve that scope while narrowing where it is declared.");
534            }
535            Verdict::Misplaced => {
536                let destination = dependency_table(finding.target.as_deref(), "dev-dependencies");
537                eprintln!("  {} {table} {}: only development units load it.", finding.package, finding.name);
538                eprintln!("      move it to {destination}.");
539            }
540        }
541
542        eprintln!("      {}", finding.manifest_path.display());
543    }
544}
545
546/// Render an unconditional or target-specific dependency table.
547fn dependency_table(target: Option<&str>, section: &str) -> String {
548    target.map_or_else(|| format!("[{section}]"), |target| format!("[target.'{target}'.{section}]"))
549}
550
551/// The catalog check: `[workspace.dependencies]` entries no member inherits.
552fn catalog_check(manifest_path: &Path, fix: bool, require_workspace: bool) -> Result<ExitCode> {
553    let original = detect::read_manifest_text(manifest_path)?;
554    let mut manifest = detect::parse_manifest(&original, manifest_path)?;
555
556    let catalog = match detect::catalog(&manifest)? {
557        Catalog::Workspace(catalog) => catalog,
558        Catalog::NotAWorkspace => {
559            if require_workspace {
560                eprintln!("❌ {} has no [workspace] table.", manifest_path.display());
561                return Ok(ExitCode::FAILURE);
562            }
563
564            eprintln!(
565                "ℹ️ {} has no [workspace] table; there is no dependency catalog to check.",
566                manifest_path.display()
567            );
568            return Ok(ExitCode::SUCCESS);
569        }
570    };
571
572    if catalog.declared.is_empty() && catalog.allowed.is_empty() {
573        println!("✅ {} declares no workspace dependencies.", manifest_path.display());
574        return Ok(ExitCode::SUCCESS);
575    }
576
577    let members = members_of(manifest_path)?;
578    let inheritance = detect::inherited(&members)?;
579    let (unused, stale) = detect::partition(&catalog, &inheritance.keys, &inheritance.declarations);
580
581    report_stale(&stale);
582
583    if catalog.declared.is_empty() {
584        println!("✅ {} declares no workspace dependencies.", manifest_path.display());
585        return Ok(ExitCode::SUCCESS);
586    }
587
588    if unused.is_empty() {
589        report_clean(manifest_path, &catalog, &inheritance.keys, members.len());
590        return Ok(ExitCode::SUCCESS);
591    }
592
593    if !fix {
594        report_unused(manifest_path, &unused);
595        return Ok(ExitCode::FAILURE);
596    }
597
598    let outcome = fix::remove(&mut manifest, &unused);
599    verify_members_unchanged(manifest_path, &members)?;
600    write_back(manifest_path, &original, &inheritance.inputs, &manifest.to_string())?;
601
602    println!(
603        "🧹 Removed {} unused workspace {} from {}.",
604        outcome.removed,
605        entries(outcome.removed),
606        manifest_path.display()
607    );
608    report_carries(&outcome.carries);
609
610    Ok(ExitCode::SUCCESS)
611}
612
613/// Replace `manifest_path` with `contents`.
614///
615/// The replacement is atomic, and happens only if every manifest input still
616/// holds what detection read.
617///
618/// The workspace root manifest is the one file whose loss breaks every other
619/// tool in the repository, so it is never truncated in place: the replacement
620/// is written to a temporary file in the same directory and renamed over the
621/// original, which is atomic on one filesystem. `cargo metadata` and member
622/// scanning run between the reads and the write, so that window is wide enough
623/// for an editor to save into either the root or a member manifest -- hence the
624/// unchanged-input guards.
625///
626/// Replacing a file by rename brings the temporary file's identity with it, so
627/// two properties an in-place write would have kept are restored deliberately:
628///
629/// - **Permissions.** A temporary file is created owner-only, and a rename
630///   carries its mode rather than inheriting the target's, so the manifest's
631///   own permissions are read first and applied to the replacement. Without
632///   that, a world-readable manifest silently comes back owner-only, which git
633///   does not track and the next differently-owned reader discovers the hard
634///   way.
635/// - **Symlinks.** A symlinked manifest is resolved first, so the rename lands
636///   on the file the link points at and the indirection survives. Replacing the
637///   link itself would quietly turn it into a regular file.
638fn write_back(manifest_path: &Path, original: &str, member_inputs: &[ManifestInput], contents: &str) -> Result<()> {
639    // Eagerly formatted rather than built in `with_context` closures: those
640    // closures only run on failures no test can force portably.
641    let resolve_failure = format!("failed to resolve {}", manifest_path.display());
642    let read_failure = format!("failed to re-read {} before writing it", manifest_path.display());
643    let metadata_failure = format!("failed to read the permissions of {}", manifest_path.display());
644    let write_failure = format!("failed to write {}", manifest_path.display());
645    let permissions_failure = format!("failed to apply the permissions of {} to its replacement", manifest_path.display());
646    let persist_failure = format!("failed to replace {}", manifest_path.display());
647
648    // Follow a symlinked manifest through to its target, the way an in-place
649    // write would have.
650    let target = fs::canonicalize(manifest_path).context(resolve_failure)?;
651
652    let current = fs::read_to_string(&target).context(read_failure)?;
653    ensure!(
654        current == original,
655        "{} changed on disk while the check was running; not writing",
656        manifest_path.display()
657    );
658
659    for input in member_inputs {
660        let current = fs::read_to_string(&input.path).context(format!("failed to re-read {} before writing", input.path.display()))?;
661        ensure!(
662            current == input.contents,
663            "{} changed on disk while the check was running; not writing",
664            input.path.display()
665        );
666    }
667
668    let permissions = fs::metadata(&target).context(metadata_failure)?.permissions();
669    let directory = target
670        .parent()
671        .expect("a canonicalized file path always names a file, so it always has a parent directory");
672
673    // Same directory as the manifest, so the rename stays on one filesystem.
674    let mut staged = NamedTempFile::new_in(directory).context(write_failure.clone())?;
675    staged.write_all(contents.as_bytes()).context(write_failure)?;
676    staged.as_file().set_permissions(permissions).context(permissions_failure)?;
677    staged.persist(&target).context(persist_failure)?;
678
679    Ok(())
680}
681
682/// Manifest paths of every workspace member, as Cargo resolves them.
683///
684/// Deferring to `cargo metadata` rather than re-deriving `members`, its globs
685/// and `exclude` keeps this in step with Cargo itself; a member missed here
686/// would look like an entry nobody inherits.
687fn members_of(manifest_path: &Path) -> Result<Vec<PathBuf>> {
688    let metadata = MetadataCommand::new()
689        .manifest_path(manifest_path)
690        .no_deps()
691        .exec()
692        .context(format!("failed to enumerate the workspace members of {}", manifest_path.display()))?;
693
694    Ok(metadata
695        .workspace_packages()
696        .into_iter()
697        .map(|package| package.manifest_path.clone().into_std_path_buf())
698        .collect())
699}
700
701/// Re-resolve workspace membership and refuse a fix when its input set changed.
702fn verify_members_unchanged(manifest_path: &Path, expected: &[PathBuf]) -> Result<()> {
703    let current = members_of(manifest_path)?;
704    let expected: BTreeSet<&Path> = expected.iter().map(PathBuf::as_path).collect();
705    let current: BTreeSet<&Path> = current.iter().map(PathBuf::as_path).collect();
706
707    ensure!(
708        current == expected,
709        "workspace membership changed while the check was running; not writing"
710    );
711
712    Ok(())
713}
714
715/// Report workspace allow-list entries declared nowhere.
716fn report_stale(stale: &[String]) {
717    for name in stale {
718        eprintln!("⚠️ '{name}' is allowed but is not declared by the workspace or any member; the allow-list entry can be removed.");
719    }
720}
721
722/// The workspace as the source-level checks need it.
723struct Workspace {
724    /// Every member, with what it declares.
725    packages: Vec<verdict::Package>,
726
727    /// Names exempted workspace-wide.
728    allowed: BTreeSet<String>,
729
730    /// Where the evidence build writes, kept apart from the shared cache
731    /// because the lint flag changes the build fingerprint.
732    evidence_target_dir: tempfile::TempDir,
733}
734
735/// Read every member's declarations, the workspace allow-list, and where to build.
736fn workspace_of(manifest_path: &Path) -> Result<Workspace> {
737    let metadata = MetadataCommand::new()
738        .manifest_path(manifest_path)
739        .no_deps()
740        .exec()
741        .context(format!("failed to enumerate the workspace members of {}", manifest_path.display()))?;
742
743    let mut packages = Vec::new();
744    for package in metadata.workspace_packages() {
745        let path = package.manifest_path.clone().into_std_path_buf();
746        let document = detect::read_manifest(&path)?;
747
748        packages.push(verdict::Package {
749            name: package.name.to_string(),
750            version: package.version.to_string(),
751            declared: detect::declared_dependencies(&document),
752            allowed: detect::package_allowed(&document)?,
753            has_doctests: package.targets.iter().any(|target| {
754                target.kind.iter().any(|kind| {
755                    matches!(
756                        kind.to_string().as_str(),
757                        "lib" | "rlib" | "dylib" | "cdylib" | "staticlib" | "proc-macro"
758                    )
759                })
760            }),
761            manifest_path: path,
762        });
763    }
764
765    let root = detect::read_manifest(manifest_path)?;
766    let allowed = match detect::catalog(&root)? {
767        Catalog::Workspace(catalog) => catalog.allowed,
768        Catalog::NotAWorkspace => BTreeSet::new(),
769    };
770
771    let evidence_root = metadata.target_directory.into_std_path_buf().join("unused-deps");
772    fs::create_dir_all(&evidence_root).context(format!("failed to create {}", evidence_root.display()))?;
773    let evidence_target_dir = tempfile::Builder::new()
774        .prefix("run-")
775        .tempdir_in(&evidence_root)
776        .context(format!("failed to create an evidence directory under {}", evidence_root.display()))?;
777
778    Ok(Workspace {
779        packages,
780        allowed,
781        evidence_target_dir,
782    })
783}
784
785/// Report comments that moved off a removed entry.
786///
787/// A group header and a note about one specific dependency have identical
788/// decor, so carrying the note onto the next entry makes it read as if it were
789/// about that one. Naming what moved and where puts the reviewer of the `--fix`
790/// diff on the right lines.
791fn report_carries(carries: &[Carry]) {
792    for carry in carries {
793        let sources = carry.from.join("', '");
794        match carry.onto.as_ref() {
795            Some(onto) => eprintln!(
796                "⚠️ Carried {} comment {} from '{sources}' onto '{onto}'; check that the text still describes '{onto}'.",
797                carry.lines,
798                lines(carry.lines.get())
799            ),
800            None => eprintln!(
801                "⚠️ Dropped {} comment {} from '{sources}': no surviving entry could carry the comments.",
802                carry.lines,
803                lines(carry.lines.get())
804            ),
805        }
806    }
807}
808
809/// Report a catalog in which every entry is inherited or allowed.
810fn report_clean(manifest_path: &Path, catalog: &WorkspaceCatalog, inherited: &BTreeSet<String>, members: usize) {
811    let declared = catalog.declared.len();
812    let covered = catalog.declared.iter().filter(|name| inherited.contains(name.as_str())).count();
813
814    let qualifier = if declared == covered { "" } else { " or explicitly allowed" };
815
816    println!(
817        "✅ {}: every workspace dependency is inherited by a workspace member{qualifier} (declared: {declared}, members: {members}).",
818        manifest_path.display()
819    );
820}
821
822/// Report the entries no member inherits.
823fn report_unused(manifest_path: &Path, unused: &[String]) {
824    eprintln!(
825        "❌ Found {} unused workspace {} in {}:\n",
826        unused.len(),
827        entries(unused.len()),
828        manifest_path.display()
829    );
830    for name in unused {
831        eprintln!("  - {name}");
832    }
833    eprintln!("\nRe-run with --fix to remove what is listed above.");
834}
835
836/// Pluralize `dependency` for `count`.
837fn entries(count: usize) -> &'static str {
838    if count == 1 { "dependency" } else { "dependencies" }
839}
840
841/// Pluralize `line` for `count`.
842fn lines(count: usize) -> &'static str {
843    if count == 1 { "line" } else { "lines" }
844}
845
846#[cfg(test)]
847mod selection_tests {
848    use std::collections::BTreeSet;
849    use std::ffi::OsString;
850    use std::path::PathBuf;
851
852    use super::{Check, PackageSelection, resolve_selectors, verdict};
853
854    #[test]
855    fn check_filtering_selects_only_requested_findings() {
856        assert!(Check::Unused.wanted(&[]));
857        assert!(Check::Unused.wanted(&[Check::Unused]));
858        assert!(!Check::Unused.wanted(&[Check::Misplaced]));
859    }
860
861    #[test]
862    fn package_selection_forwards_every_cargo_flag() {
863        let selection = PackageSelection {
864            packages: vec!["alpha@1.0.0".to_owned()],
865            workspace: false,
866            exclude: Vec::new(),
867        };
868        assert_eq!(selection.flags(), [OsString::from("--package"), OsString::from("alpha@1.0.0")]);
869
870        let workspace = PackageSelection {
871            packages: Vec::new(),
872            workspace: true,
873            exclude: vec!["beta@1.0.0".to_owned()],
874        };
875        assert_eq!(
876            workspace.flags(),
877            [
878                OsString::from("--workspace"),
879                OsString::from("--exclude"),
880                OsString::from("beta@1.0.0"),
881            ]
882        );
883    }
884
885    #[test]
886    fn ambiguous_package_names_require_a_version() {
887        let packages = [("one", "1.0.0"), ("two", "2.0.0")].map(|(path, version)| verdict::Package {
888            name: "same".to_owned(),
889            version: version.to_owned(),
890            manifest_path: PathBuf::from(path),
891            declared: Vec::new(),
892            allowed: BTreeSet::new(),
893            has_doctests: false,
894        });
895
896        let error = resolve_selectors(&packages, &["same".to_owned()], false).expect_err("ambiguous selectors must fail");
897        assert!(error.to_string().contains("matched more than one workspace member"));
898    }
899
900    #[test]
901    fn missing_exclusions_are_ignored_locally_for_cargo_to_warn_about() {
902        assert!(
903            resolve_selectors(&[], &["optional".to_owned()], true)
904                .expect("missing excludes are allowed")
905                .is_empty()
906        );
907    }
908}
909
910#[cfg(test)]
911// Miri runs with filesystem isolation, and these tests need real files in a
912// real temp directory.
913#[cfg(not(miri))]
914#[cfg_attr(coverage_nightly, coverage(off))]
915mod tests {
916    use std::fs;
917
918    use tempfile::TempDir;
919
920    use super::{verify_members_unchanged, write_back};
921
922    /// The unchanged-input guard cannot be driven from an integration test: the
923    /// window it protects is between the read and the write of a single run, so
924    /// forcing a change inside it would mean racing a child process. Exercised
925    /// directly instead.
926    #[test]
927    fn write_back_replaces_a_manifest_that_is_unchanged() {
928        let dir = TempDir::new().expect("failed to create temp dir");
929        let path = dir.path().join("Cargo.toml");
930        fs::write(&path, "original").expect("failed to seed the manifest");
931
932        write_back(&path, "original", &[], "replacement").expect("an unchanged manifest is replaced");
933
934        assert_eq!(fs::read_to_string(&path).expect("failed to read back"), "replacement");
935    }
936
937    #[test]
938    fn write_back_refuses_a_manifest_that_changed_under_it() {
939        let dir = TempDir::new().expect("failed to create temp dir");
940        let path = dir.path().join("Cargo.toml");
941        fs::write(&path, "edited by someone else").expect("failed to seed the manifest");
942
943        let error = write_back(&path, "original", &[], "replacement").expect_err("a changed manifest is refused");
944
945        assert!(
946            error.to_string().contains("changed on disk while the check was running"),
947            "unexpected error: {error}"
948        );
949        assert_eq!(
950            fs::read_to_string(&path).expect("failed to read back"),
951            "edited by someone else",
952            "the competing edit must survive"
953        );
954    }
955
956    #[test]
957    fn write_back_refuses_a_member_manifest_that_changed_under_it() {
958        let dir = TempDir::new().expect("failed to create temp dir");
959        let root = dir.path().join("Cargo.toml");
960        let member = dir.path().join("member.toml");
961        fs::write(&root, "original").expect("failed to seed the root manifest");
962        fs::write(&member, "edited by someone else").expect("failed to seed the member manifest");
963        let inputs = [crate::detect::ManifestInput {
964            path: member,
965            contents: "member original".to_owned(),
966        }];
967
968        let error = write_back(&root, "original", &inputs, "replacement").expect_err("a changed member manifest is refused");
969
970        assert!(
971            error.to_string().contains("changed on disk while the check was running"),
972            "unexpected error: {error}"
973        );
974        assert_eq!(
975            fs::read_to_string(&root).expect("failed to read back"),
976            "original",
977            "the root manifest must not be replaced"
978        );
979    }
980
981    #[test]
982    fn write_back_refuses_a_member_manifest_that_disappeared() {
983        let dir = TempDir::new().expect("failed to create temp dir");
984        let root = dir.path().join("Cargo.toml");
985        let missing = dir.path().join("missing.toml");
986        fs::write(&root, "original").expect("failed to seed the root manifest");
987        let inputs = [crate::detect::ManifestInput {
988            path: missing.clone(),
989            contents: "member original".to_owned(),
990        }];
991
992        let error = write_back(&root, "original", &inputs, "replacement").expect_err("an unreadable member manifest is refused");
993
994        assert!(
995            error
996                .to_string()
997                .contains(&format!("failed to re-read {} before writing", missing.display())),
998            "unexpected error: {error}"
999        );
1000        assert_eq!(
1001            fs::read_to_string(&root).expect("failed to read back"),
1002            "original",
1003            "the root manifest must not be replaced"
1004        );
1005    }
1006
1007    #[test]
1008    fn membership_comparison_ignores_order() {
1009        let dir = TempDir::new().expect("failed to create temp dir");
1010        let root = dir.path().join("Cargo.toml");
1011        let alpha = dir.path().join("alpha");
1012        let beta = dir.path().join("beta");
1013        fs::create_dir_all(alpha.join("src")).expect("failed to create alpha");
1014        fs::create_dir_all(beta.join("src")).expect("failed to create beta");
1015        fs::write(&root, "[workspace]\nmembers = [\"alpha\", \"beta\"]\nresolver = \"2\"\n").expect("failed to write root manifest");
1016        for (path, name) in [(&alpha, "alpha"), (&beta, "beta")] {
1017            fs::write(
1018                path.join("Cargo.toml"),
1019                format!("[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"),
1020            )
1021            .expect("failed to write member manifest");
1022            fs::write(path.join("src/lib.rs"), "").expect("failed to write member source");
1023        }
1024        let expected = vec![beta.join("Cargo.toml"), alpha.join("Cargo.toml")];
1025
1026        verify_members_unchanged(&root, &expected).expect("member order is not significant");
1027    }
1028
1029    #[test]
1030    fn membership_comparison_rejects_a_new_member() {
1031        let dir = TempDir::new().expect("failed to create temp dir");
1032        let root = dir.path().join("Cargo.toml");
1033        let member = dir.path().join("member");
1034        fs::create_dir_all(member.join("src")).expect("failed to create member");
1035        fs::write(&root, "[workspace]\nmembers = [\"*\"]\nresolver = \"2\"\n").expect("failed to write root manifest");
1036        fs::write(member.join("Cargo.toml"), "[package]\nname = \"member\"\nversion = \"0.1.0\"\n")
1037            .expect("failed to write member manifest");
1038        fs::write(member.join("src/lib.rs"), "").expect("failed to write member source");
1039
1040        let error = verify_members_unchanged(&root, &[]).expect_err("a newly matched member must invalidate the fix");
1041
1042        assert!(
1043            error
1044                .to_string()
1045                .contains("workspace membership changed while the check was running"),
1046            "unexpected error: {error}"
1047        );
1048    }
1049}