Skip to content

Commit 1ebd343

Browse files
committed
fix(unify): remove source-unused deps and streamline hot-path plumbing
Detect unused deps using both Cargo graph analysis and rustc's `unused_crate_dependencies` diagnostics, so `unify --check`/`unify` catch and remove deps that are resolved but never referenced in source. (#11) - add source unused detection in `UnusedDepFinder` - add `UnusedReason::NotUsedInSource` and report support - pass workspace root into unify analyzer/finder for source checks - short-circuit source checks when no eligible deps exist - replace dynamic JSON value walking with typed diagnostic parsing - pre-index manifest paths as strings to avoid per-message canonicalization - cache canonical root once in `UnifyAnalyzer` - reduce allocation/collect churn in run/changelog/toml formatting paths - add small command builder helpers for git/cargo invocations - update integration tests (issue #11 repro + fixtures using declared deps) - testing: replace ignored git command results with explicit existence checks + fallible calls - testing: remove sleep-based backup/timestamp assumptions in test_unify_undo and test_clean - testing: strengthen JSON assertions in config integration tests from shape-only to value/behavior checks
1 parent a2ec9b1 commit 1ebd343

File tree

20 files changed

+480
-119
lines changed

20 files changed

+480
-119
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,8 @@ cargo rail unify --explain # understand each decision
9393
- **Replaces workspace-hack** — enable `pin_transitives` for cargo-hakari equivalent
9494
- **Configurable** — tune behavior in `rail.toml` (unused removal, feature pruning, sorting, and more).
9595

96+
Unused detection combines resolved-graph analysis with rustc diagnostics (`unused_crate_dependencies`) so deps that are resolved but never referenced are also removed. Optional deps and unconfigured target-gated deps are conservatively skipped.
97+
9698
**Validated impact on real repos:**
9799

98100
| Repository | Crates | Deps Unified | Undeclared Features | MSRV Computed |

docs/config.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ Controls workspace dependency unification behavior. All options are optional wit
106106
| `msrv` | `bool` | `true` | Compute and write MSRV to `[workspace.package].rust-version` (written as `major.minor.patch`). The MSRV is determined by `msrv_source`. |
107107
| `enforce_msrv_inheritance` | `bool` | `false` | Ensure every workspace member inherits MSRV by setting `[package].rust-version = { workspace = true }` in each member's `Cargo.toml`. This makes `[workspace.package].rust-version` actually apply across the workspace. |
108108
| `msrv_source` | `enum` | `"max"` | How to compute the final MSRV:<br>• `"deps"` - Use maximum from dependencies only (original behavior)<br>• `"workspace"` - Preserve existing rust-version, warn if deps need higher<br>• `"max"` - Take max(workspace, deps) - your explicit setting wins if higher |
109-
| `detect_unused` | `bool` | `true` | Detect dependencies declared in manifests but absent from the resolved cargo graph. |
109+
| `detect_unused` | `bool` | `true` | Detect unused dependencies using two signals: (1) declared deps absent from the resolved cargo graph, and (2) rustc `unused_crate_dependencies` diagnostics for deps that resolve but are never referenced in source. Optional deps and deps behind unconfigured target constraints are conservatively skipped. |
110110
| `remove_unused` | `bool` | `true` | Automatically remove unused dependencies during unification. Requires `detect_unused = true`. |
111111
| `prune_dead_features` | `bool` | `true` | Remove features that are never enabled in the resolved dependency graph across all targets. Only prunes empty no-ops (`feature = []`). Features with actual dependencies are preserved. |
112112
| `preserve_features` | `string[]` | `[]` | Features to preserve from dead feature pruning. Supports glob patterns (e.g., `"unstable-*"`, `"bench*"`). Use this to keep features intended for future use or external consumers. |

src/cargo/multi_target_metadata.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -378,13 +378,14 @@ impl MultiTargetMetadata {
378378
// that aren't in the current Cargo.lock, breaking resolution.
379379
// The intersection approach is safe - it only pins features that
380380
// are already enabled everywhere, avoiding new dep introduction.
381-
let common_features: HashSet<String> = features
382-
.values()
383-
.fold(None, |acc: Option<HashSet<String>>, set| match acc {
384-
None => Some(set.clone()),
385-
Some(existing) => Some(existing.intersection(set).cloned().collect()),
386-
})
387-
.unwrap_or_default();
381+
let mut feature_sets = features.values();
382+
let Some(first_set) = feature_sets.next() else {
383+
continue;
384+
};
385+
let mut common_features = first_set.clone();
386+
for set in feature_sets {
387+
common_features.retain(|feature| set.contains(feature));
388+
}
388389

389390
// Get the resolved version (use highest across all targets)
390391
let versions = self.all_versions(dep_name);

src/cargo/unify/unused_finder.rs

Lines changed: 185 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,34 @@
11
//! Unused dependency detection
22
//!
3-
//! Compares declared dependencies (from Cargo.toml) against the resolved
4-
//! cargo graph to find deps that are declared but never actually used.
3+
//! Combines graph-level checks with rustc's source-level diagnostics.
54
65
use crate::cargo::manifest_analyzer::ManifestAnalyzer;
6+
use crate::cargo::manifest_analyzer::{DepKind, DepUsage};
77
use crate::cargo::multi_target_metadata::MultiTargetMetadata;
88
use crate::cargo::unify_types::{MemberEdit, UnusedDep, UnusedReason};
9+
use crate::error::ResultExt;
910
use crate::progress;
11+
use serde::Deserialize;
1012
use std::collections::{HashMap, HashSet};
13+
use std::path::Path;
14+
use std::process::Command;
1115
use std::sync::Arc;
1216

1317
/// Detects unused dependencies in workspace members
1418
pub struct UnusedDepFinder<'a> {
19+
workspace_root: &'a Path,
1520
metadata: &'a MultiTargetMetadata,
1621
manifests: &'a ManifestAnalyzer,
1722
}
1823

1924
impl<'a> UnusedDepFinder<'a> {
2025
/// Create a new unused dependency finder
21-
pub fn new(metadata: &'a MultiTargetMetadata, manifests: &'a ManifestAnalyzer) -> Self {
22-
Self { metadata, manifests }
26+
pub fn new(workspace_root: &'a Path, metadata: &'a MultiTargetMetadata, manifests: &'a ManifestAnalyzer) -> Self {
27+
Self {
28+
workspace_root,
29+
metadata,
30+
manifests,
31+
}
2332
}
2433

2534
/// Detect unused dependencies in workspace members
@@ -31,6 +40,7 @@ impl<'a> UnusedDepFinder<'a> {
3140
/// - Only truly unreferenced deps are flagged
3241
pub fn find(&self) -> Vec<UnusedDep> {
3342
let mut unused = Vec::new();
43+
let source_unused = self.detect_source_unused_deps();
3444

3545
// Pre-compute workspace member names (these are never "unused")
3646
let workspace_member_names: HashSet<String> = self
@@ -77,33 +87,38 @@ impl<'a> UnusedDepFinder<'a> {
7787
.unwrap_or_else(|| dep_key.name.replace('-', "_"))
7888
};
7989
if resolved_deps.contains(&resolved_name) {
80-
continue; // Dep is resolved, definitely used
90+
// Source-level detection catches "declared + resolved but never referenced".
91+
// This solves the common single-crate case where graph-only checks miss dead deps.
92+
for usage in usages {
93+
if self.should_skip_usage(usage, &configured_targets) {
94+
continue;
95+
}
96+
if usage.kind != DepKind::Normal {
97+
continue;
98+
}
99+
let Some(member_unused) = source_unused.get(&member.package_name) else {
100+
continue;
101+
};
102+
if !member_unused.contains(&resolved_name) {
103+
continue;
104+
}
105+
unused.push(UnusedDep {
106+
member: Arc::from(member.package_name.as_str()),
107+
dep_name: Arc::clone(&dep_key.name),
108+
kind: usage.kind,
109+
reason: UnusedReason::NotUsedInSource,
110+
});
111+
}
112+
continue;
81113
}
82114

83115
// Check each usage of this dep (can appear in multiple sections)
84116
for usage in usages {
85-
// === Smart filtering to avoid false positives ===
86-
87-
// Filter 1: Optional deps are ALWAYS feature-gated
88-
// Cargo automatically creates an implicit feature for each optional dependency,
89-
// so code can use `#[cfg(feature = "dep_name")]` even without explicit [features] entries.
90-
// We cannot safely determine if an optional dep is "unused" without analyzing source code
91-
// for cfg attributes, so we conservatively skip all optional deps.
92-
if usage.optional {
117+
if self.should_skip_usage(usage, &configured_targets) {
93118
continue;
94119
}
95120

96-
// Filter 2: Target-specific deps for unconfigured targets
97-
// If a dep has a target constraint (e.g., cfg(windows)) and we don't have
98-
// that target configured, we can't verify if it's used or not.
99121
if let Some(target_cfg) = &usage.target {
100-
if !target_constraint_matches_any(target_cfg, &configured_targets) {
101-
// Target not configured - can't verify, assume it's valid
102-
continue;
103-
}
104-
105-
// Target IS configured but dep still not in resolved graph
106-
// This is genuinely suspicious - flag it with context
107122
unused.push(UnusedDep {
108123
member: Arc::from(member.package_name.as_str()),
109124
dep_name: Arc::clone(&dep_key.name),
@@ -134,6 +149,47 @@ impl<'a> UnusedDepFinder<'a> {
134149
unused
135150
}
136151

152+
/// Conservative skip rules to avoid false positives.
153+
fn should_skip_usage(&self, usage: &DepUsage, configured_targets: &[&str]) -> bool {
154+
// Optional deps may be used under cfg(feature = "...") without explicit references.
155+
if usage.optional {
156+
return true;
157+
}
158+
159+
// If target isn't configured, we cannot verify usage accurately.
160+
if let Some(target_cfg) = &usage.target {
161+
return !target_constraint_matches_any(target_cfg, configured_targets);
162+
}
163+
164+
false
165+
}
166+
167+
/// Detect source-unused crates via rustc's `unused_crate_dependencies` lint.
168+
///
169+
/// This is the only reliable source-level signal for dependencies that are
170+
/// declared/resolved but never referenced in Rust code.
171+
fn detect_source_unused_deps(&self) -> HashMap<String, HashSet<String>> {
172+
if !has_source_check_candidates(&self.manifests) {
173+
return HashMap::new();
174+
}
175+
176+
let manifest_to_member = build_manifest_member_index(&self.manifests.members);
177+
if manifest_to_member.is_empty() {
178+
return HashMap::new();
179+
}
180+
181+
match run_unused_crate_dependency_check(self.workspace_root, &manifest_to_member) {
182+
Ok(map) => map,
183+
Err(error) => {
184+
crate::warn!(
185+
"source-level unused dependency detection failed; falling back to graph-only detection: {}",
186+
error
187+
);
188+
HashMap::new()
189+
}
190+
}
191+
}
192+
137193
/// Generate removal edits for unused dependencies
138194
pub fn generate_removal_edits(&self, unused: &[UnusedDep]) -> HashMap<String, Vec<MemberEdit>> {
139195
let mut edits: HashMap<String, Vec<MemberEdit>> = HashMap::new();
@@ -202,6 +258,112 @@ impl<'a> UnusedDepFinder<'a> {
202258
}
203259
}
204260

261+
fn build_manifest_member_index(members: &[crate::cargo::manifest_analyzer::ParsedManifest]) -> HashMap<String, String> {
262+
let mut index = HashMap::with_capacity(members.len() * 2);
263+
for member in members {
264+
index.insert(member.path.to_string_lossy().into_owned(), member.package_name.clone());
265+
if let Ok(canonical) = member.path.canonicalize() {
266+
index.insert(canonical.to_string_lossy().into_owned(), member.package_name.clone());
267+
}
268+
}
269+
index
270+
}
271+
272+
fn has_source_check_candidates(manifests: &ManifestAnalyzer) -> bool {
273+
manifests.members.iter().any(|member| {
274+
member
275+
.dependencies
276+
.values()
277+
.flatten()
278+
.any(|usage| usage.kind == DepKind::Normal && !usage.optional && usage.target.is_none())
279+
})
280+
}
281+
282+
fn run_unused_crate_dependency_check(
283+
workspace_root: &Path,
284+
manifest_to_member: &HashMap<String, String>,
285+
) -> crate::error::RailResult<HashMap<String, HashSet<String>>> {
286+
let rustflags = std::env::var("RUSTFLAGS").ok();
287+
let lint_flag = "-Wunused-crate-dependencies";
288+
let merged_rustflags = match rustflags {
289+
Some(flags) if flags.split_whitespace().any(|flag| flag == lint_flag) => flags,
290+
Some(flags) => format!("{} {}", flags, lint_flag),
291+
None => lint_flag.to_string(),
292+
};
293+
294+
let output = Command::new("cargo")
295+
.current_dir(workspace_root)
296+
.env("RUSTFLAGS", merged_rustflags)
297+
.args([
298+
"check",
299+
"--workspace",
300+
"--all-targets",
301+
"--all-features",
302+
"--message-format=json",
303+
])
304+
.output()
305+
.with_context(|| format!("running cargo check in {}", workspace_root.display()))?;
306+
307+
let mut by_member: HashMap<String, HashSet<String>> = HashMap::new();
308+
for line in String::from_utf8_lossy(&output.stdout).lines() {
309+
let Ok(message) = serde_json::from_str::<CargoCompilerMessage>(line) else {
310+
continue;
311+
};
312+
if message.reason != "compiler-message" {
313+
continue;
314+
}
315+
if message.message.code.as_ref().and_then(|c| c.code.as_deref()) != Some("unused_crate_dependencies") {
316+
continue;
317+
}
318+
let Some(manifest_path) = message.manifest_path.as_deref() else {
319+
continue;
320+
};
321+
let Some(member_name) = resolve_member_name(manifest_path, manifest_to_member) else {
322+
continue;
323+
};
324+
let Some(crate_name) = parse_unused_crate_name(&message.message.message) else {
325+
continue;
326+
};
327+
328+
by_member
329+
.entry(member_name.to_string())
330+
.or_default()
331+
.insert(crate_name.replace('-', "_"));
332+
}
333+
334+
Ok(by_member)
335+
}
336+
337+
fn resolve_member_name<'a>(manifest_path: &str, manifest_to_member: &'a HashMap<String, String>) -> Option<&'a str> {
338+
manifest_to_member.get(manifest_path).map(String::as_str)
339+
}
340+
341+
fn parse_unused_crate_name(message: &str) -> Option<&str> {
342+
let prefix = "extern crate `";
343+
let start = message.find(prefix)? + prefix.len();
344+
let rest = &message[start..];
345+
let end = rest.find('`')?;
346+
Some(&rest[..end])
347+
}
348+
349+
#[derive(Debug, Deserialize)]
350+
struct CargoCompilerMessage {
351+
reason: String,
352+
manifest_path: Option<String>,
353+
message: CargoDiagnostic,
354+
}
355+
356+
#[derive(Debug, Deserialize)]
357+
struct CargoDiagnostic {
358+
message: String,
359+
code: Option<CargoDiagnosticCode>,
360+
}
361+
362+
#[derive(Debug, Deserialize)]
363+
struct CargoDiagnosticCode {
364+
code: Option<String>,
365+
}
366+
205367
/// Check if a target constraint (cfg expression) matches any configured target
206368
///
207369
/// This is a heuristic check - we look for common patterns in the cfg string

src/cargo/unify_analyzer.rs

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ pub struct UnifyAnalyzer {
3737
cohort_issues: Vec<UnifyIssue>,
3838
/// Workspace root path (for path normalization)
3939
workspace_root: PathBuf,
40+
/// Canonical workspace root path (computed once)
41+
canonical_workspace_root: PathBuf,
4042
}
4143

4244
type FeatureSources = FxHashMap<String, FxHashMap<String, FxHashSet<String>>>;
@@ -64,14 +66,17 @@ impl UnifyAnalyzer {
6466
.collect();
6567
let (config, cohort_issues) =
6668
Self::apply_workspace_member_cohort_policy(base_config, &manifests, &workspace_member_names);
69+
let workspace_root = ctx.workspace_root().to_path_buf();
70+
let canonical_workspace_root = workspace_root.canonicalize().unwrap_or_else(|_| workspace_root.clone());
6771

6872
Ok(Self {
6973
metadata,
7074
manifests,
7175
config,
7276
existing_workspace_deps,
7377
cohort_issues,
74-
workspace_root: ctx.workspace_root().to_path_buf(),
78+
workspace_root,
79+
canonical_workspace_root,
7580
})
7681
}
7782

@@ -239,14 +244,10 @@ impl UnifyAnalyzer {
239244
let absolute_dep_path = member_dir.join(dep_path);
240245

241246
// Canonicalize to resolve .. and symlinks (if path exists)
242-
let canonical_workspace = self
243-
.workspace_root
244-
.canonicalize()
245-
.unwrap_or_else(|_| self.workspace_root.clone());
246247
let canonical_dep = absolute_dep_path.canonicalize().unwrap_or(absolute_dep_path.clone());
247248

248249
// Make relative to workspace root
249-
if let Ok(relative) = canonical_dep.strip_prefix(&canonical_workspace) {
250+
if let Ok(relative) = canonical_dep.strip_prefix(&self.canonical_workspace_root) {
250251
return relative.to_path_buf();
251252
}
252253

@@ -263,13 +264,9 @@ impl UnifyAnalyzer {
263264
fn normalize_workspace_member_path(&self, member_manifest_path: &Path) -> PathBuf {
264265
let member_dir = member_manifest_path.parent().unwrap_or(member_manifest_path);
265266

266-
let canonical_workspace = self
267-
.workspace_root
268-
.canonicalize()
269-
.unwrap_or_else(|_| self.workspace_root.clone());
270267
let canonical_member = member_dir.canonicalize().unwrap_or_else(|_| member_dir.to_path_buf());
271268

272-
if let Ok(relative) = canonical_member.strip_prefix(&canonical_workspace) {
269+
if let Ok(relative) = canonical_member.strip_prefix(&self.canonical_workspace_root) {
273270
return relative.to_path_buf();
274271
}
275272

@@ -698,7 +695,7 @@ impl UnifyAnalyzer {
698695
};
699696

700697
// Detect unused dependencies
701-
let unused_finder = UnusedDepFinder::new(&self.metadata, &self.manifests);
698+
let unused_finder = UnusedDepFinder::new(&self.workspace_root, &self.metadata, &self.manifests);
702699
let unused_deps = if self.config.detect_unused {
703700
progress!("Detecting unused dependencies...");
704701
unused_finder.find()

src/cargo/unify_report.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,6 +140,7 @@ impl UnifyReport {
140140
DepKind::Build => "build",
141141
};
142142
let reason_str = match &ud.reason {
143+
UnusedReason::NotUsedInSource => "unused in source".to_string(),
143144
UnusedReason::NotInResolvedGraph => "not in resolved graph".to_string(),
144145
UnusedReason::TargetConfiguredButNotResolved { target_cfg } => {
145146
format!("target `{}` configured but not resolved", target_cfg)

0 commit comments

Comments
 (0)