Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
13 changes: 13 additions & 0 deletions src/manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ pub enum Dependency {
#[derive(Debug, Deserialize, Clone, Default)]
pub struct DependencyDetail {
pub package: Option<String>,
pub path: Option<String>,
#[serde(default)]
pub optional: bool,
}
Expand All @@ -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 {
Expand Down Expand Up @@ -194,6 +203,10 @@ pub struct ShearConfig {
pub ignored: FxHashSet<Spanned<String>>,
#[serde(default, rename = "ignored-paths")]
pub ignored_paths: Vec<SpannedGlob>,
/// 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)]
Expand Down
14 changes: 14 additions & 0 deletions src/package_processor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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());

Expand All @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions tests/fixtures/unused_workspace_path/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

7 changes: 7 additions & 0 deletions tests/fixtures/unused_workspace_path/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[workspace]
resolver = "3"
members = ["app", "new-crate"]

[workspace.dependencies]
# Unused
new-crate = { path = "new-crate" }
8 changes: 8 additions & 0 deletions tests/fixtures/unused_workspace_path/app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[lib]
test = false
doctest = false
1 change: 1 addition & 0 deletions tests/fixtures/unused_workspace_path/app/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

8 changes: 8 additions & 0 deletions tests/fixtures/unused_workspace_path/new-crate/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "new-crate"
version = "0.1.0"
edition = "2024"

[lib]
test = false
doctest = false
1 change: 1 addition & 0 deletions tests/fixtures/unused_workspace_path/new-crate/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

11 changes: 11 additions & 0 deletions tests/fixtures/unused_workspace_path_kept/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 12 additions & 0 deletions tests/fixtures/unused_workspace_path_kept/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
8 changes: 8 additions & 0 deletions tests/fixtures/unused_workspace_path_kept/app/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "app"
version = "0.1.0"
edition = "2024"

[lib]
test = false
doctest = false
1 change: 1 addition & 0 deletions tests/fixtures/unused_workspace_path_kept/app/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
[package]
name = "new-crate"
version = "0.1.0"
edition = "2024"

[lib]
test = false
doctest = false
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

93 changes: 93 additions & 0 deletions tests/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2260,6 +2260,99 @@ fn unused_workspace_libname_fix() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
Expand Down
Loading