From 728ff199a05b4ad30e807a7de2bb8151d81874a1 Mon Sep 17 00:00:00 2001 From: Boshen Date: Wed, 15 Jul 2026 23:43:29 +0800 Subject: [PATCH] feat: add `keep-path-dependencies` workspace setting (#533) Some workflows pre-declare a new crate in `[workspace.dependencies]` as a path dependency before any member inherits it, so later commits can depend on it. cargo-shear flagged such entries as unused workspace dependencies. With `keep-path-dependencies = true` under `[workspace.metadata.cargo-shear]`, a `[workspace.dependencies]` entry with `path = "..."` is treated as used as long as a crate exists at the declared path. A stale entry whose crate was deleted or moved is still reported. Default behavior is unchanged. Closes #533 --- README.md | 14 +++ src/manifest.rs | 13 +++ src/package_processor.rs | 14 +++ .../fixtures/unused_workspace_path/Cargo.lock | 11 +++ .../fixtures/unused_workspace_path/Cargo.toml | 7 ++ .../unused_workspace_path/app/Cargo.toml | 8 ++ .../unused_workspace_path/app/src/lib.rs | 1 + .../new-crate/Cargo.toml | 8 ++ .../new-crate/src/lib.rs | 1 + .../unused_workspace_path_kept/Cargo.lock | 11 +++ .../unused_workspace_path_kept/Cargo.toml | 12 +++ .../unused_workspace_path_kept/app/Cargo.toml | 8 ++ .../unused_workspace_path_kept/app/src/lib.rs | 1 + .../new-crate/Cargo.toml | 8 ++ .../new-crate/src/lib.rs | 1 + tests/integration_tests.rs | 93 +++++++++++++++++++ 16 files changed, 211 insertions(+) create mode 100644 tests/fixtures/unused_workspace_path/Cargo.lock create mode 100644 tests/fixtures/unused_workspace_path/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path/app/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path/app/src/lib.rs create mode 100644 tests/fixtures/unused_workspace_path/new-crate/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path/new-crate/src/lib.rs create mode 100644 tests/fixtures/unused_workspace_path_kept/Cargo.lock create mode 100644 tests/fixtures/unused_workspace_path_kept/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path_kept/app/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path_kept/app/src/lib.rs create mode 100644 tests/fixtures/unused_workspace_path_kept/new-crate/Cargo.toml create mode 100644 tests/fixtures/unused_workspace_path_kept/new-crate/src/lib.rs diff --git a/README.md b/README.md index eee586dc..c8d8225c 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,20 @@ An ignore that suppresses nothing is reported as redundant so it can be removed. workspace ignore is considered redundant only when no member needs it — a dependency that is used in one crate but unused in another stays covered by the workspace ignore. +### Keep pre-declared path dependencies + +Some workflows declare a new crate in `[workspace.dependencies]` before any member +depends on it, so later commits can inherit it. To keep such entries: + +```toml +[workspace.metadata.cargo-shear] +keep-path-dependencies = true +``` + +A `[workspace.dependencies]` entry with `path = "..."` is then treated as used as +long as a crate exists at the declared path. A stale entry — one whose crate has +been deleted or moved — is still reported as unused. + ### cargo-hakari `workspace-hack` crates [`cargo-hakari`](https://docs.rs/cargo-hakari) generates a `workspace-hack` crate that declares many dependencies it never imports (to unify Cargo features) and is depended on by every workspace member without being imported. `cargo shear` detects such a crate automatically — via the `### BEGIN HAKARI SECTION` marker in its `Cargo.toml` — and skips both the crate itself and the dependency edges pointing at it, so no `ignored` configuration is needed. diff --git a/src/manifest.rs b/src/manifest.rs index d4812305..e4344df9 100644 --- a/src/manifest.rs +++ b/src/manifest.rs @@ -154,6 +154,7 @@ pub enum Dependency { #[derive(Debug, Deserialize, Clone, Default)] pub struct DependencyDetail { pub package: Option, + pub path: Option, #[serde(default)] pub optional: bool, } @@ -167,6 +168,14 @@ impl Dependency { } } + #[must_use] + pub fn path(&self) -> Option<&str> { + match self { + Self::Simple(_) => None, + Self::Detailed(detail) => detail.path.as_deref(), + } + } + #[must_use] pub const fn optional(&self) -> bool { match self { @@ -194,6 +203,10 @@ pub struct ShearConfig { pub ignored: FxHashSet>, #[serde(default, rename = "ignored-paths")] pub ignored_paths: Vec, + /// Keep `[workspace.dependencies]` path entries whose crate exists on disk, + /// even when no member inherits them. Only read from workspace metadata. + #[serde(default, rename = "keep-path-dependencies")] + pub keep_path_dependencies: bool, } #[derive(Deserialize, Default)] diff --git a/src/package_processor.rs b/src/package_processor.rs index aa39811e..4f43d5a6 100644 --- a/src/package_processor.rs +++ b/src/package_processor.rs @@ -530,6 +530,8 @@ impl PackageProcessor { return result; } + let keep_path_deps = ctx.manifest.workspace.metadata.cargo_shear.keep_path_dependencies; + for (dep, dependency) in &ctx.manifest.workspace.dependencies { let pkg = dependency.get_ref().package().unwrap_or(dep.get_ref()); @@ -543,6 +545,18 @@ impl PackageProcessor { continue; } + // Some workflows pre-declare a path dependency in the workspace root + // before any member uses it (e.g. a freshly created crate that later + // commits depend on). With `keep-path-dependencies`, such an entry is + // kept as long as a crate exists at the declared path — a stale entry + // whose crate was deleted or moved is still flagged. + if keep_path_deps + && let Some(path) = dependency.get_ref().path() + && ctx.root.join(path).join("Cargo.toml").is_file() + { + continue; + } + if ctx.ignored_deps.contains(dep.get_ref()) { let import = dep.get_ref().replace('-', "_"); used_ignores.insert(import); diff --git a/tests/fixtures/unused_workspace_path/Cargo.lock b/tests/fixtures/unused_workspace_path/Cargo.lock new file mode 100644 index 00000000..92252403 --- /dev/null +++ b/tests/fixtures/unused_workspace_path/Cargo.lock @@ -0,0 +1,11 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "app" +version = "0.1.0" + +[[package]] +name = "new-crate" +version = "0.1.0" diff --git a/tests/fixtures/unused_workspace_path/Cargo.toml b/tests/fixtures/unused_workspace_path/Cargo.toml new file mode 100644 index 00000000..7a4ad630 --- /dev/null +++ b/tests/fixtures/unused_workspace_path/Cargo.toml @@ -0,0 +1,7 @@ +[workspace] +resolver = "3" +members = ["app", "new-crate"] + +[workspace.dependencies] +# Unused +new-crate = { path = "new-crate" } diff --git a/tests/fixtures/unused_workspace_path/app/Cargo.toml b/tests/fixtures/unused_workspace_path/app/Cargo.toml new file mode 100644 index 00000000..2687c792 --- /dev/null +++ b/tests/fixtures/unused_workspace_path/app/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "app" +version = "0.1.0" +edition = "2024" + +[lib] +test = false +doctest = false diff --git a/tests/fixtures/unused_workspace_path/app/src/lib.rs b/tests/fixtures/unused_workspace_path/app/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/unused_workspace_path/app/src/lib.rs @@ -0,0 +1 @@ + diff --git a/tests/fixtures/unused_workspace_path/new-crate/Cargo.toml b/tests/fixtures/unused_workspace_path/new-crate/Cargo.toml new file mode 100644 index 00000000..674608fa --- /dev/null +++ b/tests/fixtures/unused_workspace_path/new-crate/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "new-crate" +version = "0.1.0" +edition = "2024" + +[lib] +test = false +doctest = false diff --git a/tests/fixtures/unused_workspace_path/new-crate/src/lib.rs b/tests/fixtures/unused_workspace_path/new-crate/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/unused_workspace_path/new-crate/src/lib.rs @@ -0,0 +1 @@ + diff --git a/tests/fixtures/unused_workspace_path_kept/Cargo.lock b/tests/fixtures/unused_workspace_path_kept/Cargo.lock new file mode 100644 index 00000000..92252403 --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/Cargo.lock @@ -0,0 +1,11 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "app" +version = "0.1.0" + +[[package]] +name = "new-crate" +version = "0.1.0" diff --git a/tests/fixtures/unused_workspace_path_kept/Cargo.toml b/tests/fixtures/unused_workspace_path_kept/Cargo.toml new file mode 100644 index 00000000..129f3216 --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/Cargo.toml @@ -0,0 +1,12 @@ +[workspace] +resolver = "3" +members = ["app", "new-crate"] + +[workspace.dependencies] +# Kept: a crate exists at the declared path +new-crate = { path = "new-crate" } +# Unused: no crate at the declared path +ghost = { path = "ghost" } + +[workspace.metadata.cargo-shear] +keep-path-dependencies = true diff --git a/tests/fixtures/unused_workspace_path_kept/app/Cargo.toml b/tests/fixtures/unused_workspace_path_kept/app/Cargo.toml new file mode 100644 index 00000000..2687c792 --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/app/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "app" +version = "0.1.0" +edition = "2024" + +[lib] +test = false +doctest = false diff --git a/tests/fixtures/unused_workspace_path_kept/app/src/lib.rs b/tests/fixtures/unused_workspace_path_kept/app/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/app/src/lib.rs @@ -0,0 +1 @@ + diff --git a/tests/fixtures/unused_workspace_path_kept/new-crate/Cargo.toml b/tests/fixtures/unused_workspace_path_kept/new-crate/Cargo.toml new file mode 100644 index 00000000..674608fa --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/new-crate/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "new-crate" +version = "0.1.0" +edition = "2024" + +[lib] +test = false +doctest = false diff --git a/tests/fixtures/unused_workspace_path_kept/new-crate/src/lib.rs b/tests/fixtures/unused_workspace_path_kept/new-crate/src/lib.rs new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/tests/fixtures/unused_workspace_path_kept/new-crate/src/lib.rs @@ -0,0 +1 @@ + diff --git a/tests/integration_tests.rs b/tests/integration_tests.rs index eaf49793..86402227 100644 --- a/tests/integration_tests.rs +++ b/tests/integration_tests.rs @@ -2260,6 +2260,99 @@ fn unused_workspace_libname_fix() -> Result<(), Box> { Ok(()) } +// Without `keep-path-dependencies`, an uninherited workspace path dependency is +// flagged like any other unused workspace dependency. +#[test] +fn unused_workspace_path_detection() -> Result<(), Box> { + let (exit_code, output, _temp_dir) = CargoShearRunner::new("unused_workspace_path").run()?; + assert_eq!(exit_code, ExitCode::FAILURE); + + insta::assert_snapshot!(output, @r#" + shear/unused_workspace_dependency + + × unused workspace dependency `new-crate` + ╭─[Cargo.toml:7:1] + 6 │ # Unused + 7 │ new-crate = { path = "new-crate" } + · ────┬──── + · ╰── not used by any workspace member + ╰──── + help: remove this dependency + + shear/summary + + ✗ 1 error + + Advice: + ☞ run with `--fix` to fix 1 issue + ☞ to suppress a dependency issue + ╭─[Cargo.toml:2:12] + 1 │ [package.metadata.cargo-shear] # or [workspace.metadata.cargo-shear] + 2 │ ignored = ["crate-name"] + · ──────┬───── + · ╰── add a crate name here + ╰──── + "#); + + Ok(()) +} + +// #533: with `keep-path-dependencies = true`, the pre-declared `new-crate` path +// dependency is kept because a crate exists at its path, while `ghost` — whose +// path contains no crate — is still flagged. +#[test] +fn unused_workspace_path_kept_detection() -> Result<(), Box> { + let (exit_code, output, _temp_dir) = + CargoShearRunner::new("unused_workspace_path_kept").run()?; + assert_eq!(exit_code, ExitCode::FAILURE); + + insta::assert_snapshot!(output, @r#" + shear/unused_workspace_dependency + + × unused workspace dependency `ghost` + ╭─[Cargo.toml:9:1] + 8 │ # Unused: no crate at the declared path + 9 │ ghost = { path = "ghost" } + · ──┬── + · ╰── not used by any workspace member + 10 │ + ╰──── + help: remove this dependency + + shear/summary + + ✗ 1 error + + Advice: + ☞ run with `--fix` to fix 1 issue + ☞ to suppress a dependency issue + ╭─[Cargo.toml:2:12] + 1 │ [package.metadata.cargo-shear] # or [workspace.metadata.cargo-shear] + 2 │ ignored = ["crate-name"] + · ──────┬───── + · ╰── add a crate name here + ╰──── + "#); + + Ok(()) +} + +// `--fix` must remove the stale `ghost` entry but keep `new-crate`. +#[test] +fn unused_workspace_path_kept_fix() -> Result<(), Box> { + let (exit_code, _output, temp_dir) = CargoShearRunner::new("unused_workspace_path_kept") + .options(CargoShearOptions::with_fix) + .run()?; + assert_eq!(exit_code, ExitCode::FAILURE); + + let manifest = Manifest::from_path(temp_dir.path().join("Cargo.toml"))?; + let workspace = &manifest.workspace.as_ref().unwrap().dependencies; + assert!(workspace.contains_key("new-crate")); + assert!(!workspace.contains_key("ghost")); + + Ok(()) +} + // Without `--check-test-targets`, test/doctest mismatches are silent. #[test] fn test_disabled_with_tests_default_off() -> Result<(), Box> {