Skip to content

Commit bc09254

Browse files
committed
cargo-rail: fixing the schema/config for the 'undeclared features; resolution/fixing; added tests for it
1 parent 8f6a475 commit bc09254

File tree

8 files changed

+743
-37
lines changed

8 files changed

+743
-37
lines changed

docs/config.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Controls workspace dependency unification behavior. All options are optional wit
109109
| `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. |
110110
| `detect_undeclared_features` | `bool` | `true` | Detect crates that rely on Cargo's feature unification to "borrow" features from other workspace members. These crates will fail when built standalone after unification. Reports as warnings (or auto-fixes if `fix_undeclared_features` is enabled). |
111111
| `fix_undeclared_features` | `bool` | `true` | Auto-fix undeclared feature dependencies by adding missing features to each crate's Cargo.toml. Produces a cleaner graph where standalone builds work correctly. Requires `detect_undeclared_features = true`. |
112+
| `skip_undeclared_patterns` | `string[]` | `["default", "std", "alloc", "*_backend", "*_impl"]` | Patterns for features to skip in undeclared feature detection. Supports glob patterns. Default patterns filter out features that are typically not actionable (standard library features, internal implementation details). |
112113
| `max_backups` | `usize` | `3` | Maximum number of backup archives to keep. Older backups are automatically cleaned up after successful operations. Set to `0` to disable backup creation entirely. |
113114

114115
**Example:**
@@ -123,6 +124,7 @@ prune_dead_features = true
123124
preserve_features = ["future-api", "unstable-*"] # Keep these from pruning
124125
detect_undeclared_features = true # Catch borrowed features
125126
fix_undeclared_features = true # Auto-fix them (default)
127+
skip_undeclared_patterns = ["default", "std", "alloc", "*_backend", "*_impl"] # Features to skip
126128
max_backups = 5
127129
```
128130

@@ -470,6 +472,9 @@ detect_unused = true
470472
remove_unused = true
471473
prune_dead_features = true
472474
preserve_features = [] # Glob patterns: ["unstable-*", "future-api"]
475+
detect_undeclared_features = true # Catch borrowed features
476+
fix_undeclared_features = true # Auto-fix them
477+
skip_undeclared_patterns = ["default", "std", "alloc", "*_backend", "*_impl"]
473478
max_backups = 3
474479
475480
# Version handling

src/cargo/unify_analyzer.rs

Lines changed: 88 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -593,6 +593,15 @@ impl UnifyAnalyzer {
593593
/// unification, standalone builds (cargo test -p <crate>) will fail because
594594
/// the borrowed features are no longer available.
595595
///
596+
/// Key distinctions:
597+
/// - **Workspace policy**: Features in `[workspace.dependencies]` are intentional
598+
/// workspace-level declarations. Members using these don't need to re-declare.
599+
/// - **Member borrowing**: Features that only come from another member are
600+
/// implicit coupling and should be flagged.
601+
/// - **Conditional features**: Features declared via `[features]` table (e.g.,
602+
/// `my-feature = ["dep/feat"]`) count as declared - the author made an
603+
/// intentional choice.
604+
///
596605
/// Example:
597606
/// - crate A declares: `backoff = "0.4"` (no features)
598607
/// - crate B declares: `backoff = { version = "0.4", features = ["tokio"] }`
@@ -602,21 +611,32 @@ impl UnifyAnalyzer {
602611
fn detect_undeclared_features(&self) -> Vec<UndeclaredFeature> {
603612
let mut undeclared = Vec::new();
604613

605-
// Common features that are typically not actionable:
606-
// - "default", "std" - usually implicit
607-
// - Internal features like "*_backend", "*_impl" - implementation details
608-
let should_skip_feature = |f: &str| -> bool {
609-
f == "default" || f == "std" || f == "alloc" || f.ends_with("_backend") || f.ends_with("_impl")
610-
};
614+
// Step 1: Get workspace baseline (features declared in [workspace.dependencies])
615+
// These are workspace policy - members don't need to re-declare them
616+
let workspace_baseline: HashMap<String, HashSet<String>> = self
617+
.existing_workspace_deps
618+
.iter()
619+
.map(|(name, dep)| {
620+
let mut feats: HashSet<String> = dep.features.iter().cloned().collect();
621+
if dep.default_features {
622+
feats.insert("default".to_string());
623+
}
624+
(name.clone(), feats)
625+
})
626+
.collect();
611627

612-
// Build a map of what each member explicitly declares for each dependency
613-
// We want to find cases where member A would need a feature that ONLY comes from member B
628+
// Step 2: Build a map of what each member explicitly declares for each dependency
629+
// Include both unconditional_features AND conditional_features (from [features] table)
630+
// Also track which features each member contributes (for borrowed_from)
614631
let mut member_declared_features: HashMap<String, HashMap<String, HashSet<String>>> = HashMap::new();
615632

616633
for member in &self.manifests.members {
617634
let mut dep_features: HashMap<String, HashSet<String>> = HashMap::new();
618635
for (dep_key, usage) in &member.dependencies {
619636
let mut feats: HashSet<String> = usage.unconditional_features.iter().cloned().collect();
637+
// Include conditional features (from [features] table like `my-feat = ["dep/feature"]`)
638+
// The author made an intentional choice - this counts as declared
639+
feats.extend(usage.conditional_features.iter().cloned());
620640
// If default_features = true (default), add "default"
621641
if usage.default_features {
622642
feats.insert("default".to_string());
@@ -626,49 +646,87 @@ impl UnifyAnalyzer {
626646
member_declared_features.insert(member.package_name.clone(), dep_features);
627647
}
628648

629-
// Compute the unified features (what will end up in workspace.dependencies)
630-
// This is the UNION of all declared features across all members
631-
let mut unified_features: HashMap<String, HashSet<String>> = HashMap::new();
632-
for dep_features in member_declared_features.values() {
649+
// Step 3: Track which members contribute which features (beyond workspace baseline)
650+
// This powers the `borrowed_from` field for transparency
651+
// Structure: dep_name -> feature -> set of member names
652+
let mut feature_sources: HashMap<String, HashMap<String, HashSet<String>>> = HashMap::new();
653+
654+
for (member_name, dep_features) in &member_declared_features {
633655
for (dep_name, features) in dep_features {
634-
unified_features
635-
.entry(dep_name.clone())
636-
.or_default()
637-
.extend(features.iter().cloned());
656+
let baseline = workspace_baseline.get(dep_name);
657+
658+
for feat in features {
659+
// Skip features that are in workspace baseline (workspace policy)
660+
if baseline.is_some_and(|b| b.contains(feat)) {
661+
continue;
662+
}
663+
664+
feature_sources
665+
.entry(dep_name.clone())
666+
.or_default()
667+
.entry(feat.clone())
668+
.or_default()
669+
.insert(member_name.clone());
670+
}
638671
}
639672
}
640673

641-
// Now check: for each member, are there features in unified that it doesn't declare?
642-
// These are features "borrowed" from other workspace members
674+
// Step 4: Find borrowed features for each member
675+
// A feature is borrowed if:
676+
// - It's contributed by OTHER members (not this member)
677+
// - It's NOT in workspace baseline (workspace policy doesn't count as borrowing)
678+
// - It passes the skip filter (configurable via skip_undeclared_patterns)
643679
for member in &self.manifests.members {
644680
for (dep_key, usage) in &member.dependencies {
645-
let Some(unified) = unified_features.get(&*dep_key.name) else {
681+
let Some(feat_sources) = feature_sources.get(&*dep_key.name) else {
646682
continue;
647683
};
648684

649-
// Get declared features from this member's Cargo.toml
685+
// Get this member's declared features (unconditional + conditional)
650686
let mut declared: HashSet<String> = usage.unconditional_features.iter().cloned().collect();
687+
declared.extend(usage.conditional_features.iter().cloned());
651688
if usage.default_features {
652689
declared.insert("default".to_string());
653690
}
654691

655-
// Find features that are unified but not declared by this member
656-
// These are features this member will lose access to after unification
657-
let borrowed: Vec<String> = unified
658-
.difference(&declared)
659-
.filter(|f| !should_skip_feature(f))
660-
.cloned()
661-
.collect();
692+
// Find features this member borrows from others
693+
let mut borrowed_features: Vec<String> = Vec::new();
694+
let mut borrowed_from_members: HashSet<String> = HashSet::new();
695+
696+
for (feat, sources) in feat_sources {
697+
// Skip if this member declares the feature itself
698+
if declared.contains(feat) {
699+
continue;
700+
}
701+
702+
// Skip if configured to skip this feature pattern
703+
if self.config.should_skip_undeclared_feature(feat) {
704+
continue;
705+
}
706+
707+
// This feature is borrowed from other members
708+
borrowed_features.push(feat.clone());
709+
for source in sources {
710+
if source != &member.package_name {
711+
borrowed_from_members.insert(source.clone());
712+
}
713+
}
714+
}
715+
716+
if !borrowed_features.is_empty() {
717+
borrowed_features.sort();
718+
let mut borrowed_from: Vec<String> = borrowed_from_members.into_iter().collect();
719+
borrowed_from.sort();
662720

663-
if !borrowed.is_empty() {
664721
undeclared.push(UndeclaredFeature {
665722
member: member.package_name.clone(),
666723
dep_name: dep_key.name.to_string(),
667-
undeclared_features: borrowed,
724+
undeclared_features: borrowed_features,
668725
declared_features: declared.into_iter().collect(),
669-
resolved_features: unified.iter().cloned().collect(),
726+
resolved_features: feat_sources.keys().cloned().collect(),
670727
dep_kind: usage.kind,
671728
target: usage.target.clone(),
729+
borrowed_from,
672730
});
673731
}
674732
}

src/cargo/unify_types.rs

Lines changed: 89 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,6 +212,9 @@ pub struct UndeclaredFeature {
212212
pub dep_kind: DepKind,
213213
/// Target platform constraint (if in target-specific section)
214214
pub target: Option<String>,
215+
/// Which workspace members provide the borrowed features
216+
/// Shows where the feature is coming from (for transparency)
217+
pub borrowed_from: Vec<String>,
215218
}
216219

217220
// ============================================================================
@@ -482,12 +485,17 @@ impl UnificationPlan {
482485
self.undeclared_features.len()
483486
));
484487
for uf in &self.undeclared_features {
488+
let borrowed_from_str = if uf.borrowed_from.is_empty() {
489+
String::new()
490+
} else {
491+
format!(" (borrowed from {})", uf.borrowed_from.join(", "))
492+
};
485493
s.push_str(&format!(
486-
" - {} in {}: needs features [{}] but only declares [{}]\n",
487-
uf.dep_name,
494+
" - {}/{}: [{}]{}\n",
488495
uf.member,
496+
uf.dep_name,
489497
uf.undeclared_features.join(", "),
490-
uf.declared_features.join(", ")
498+
borrowed_from_str
491499
));
492500
}
493501
s.push_str(" These crates rely on feature unification from other workspace members.\n");
@@ -594,6 +602,7 @@ mod tests {
594602
resolved_features: vec!["default".to_string(), "futures".to_string(), "tokio".to_string()],
595603
dep_kind: DepKind::Normal,
596604
target: None,
605+
borrowed_from: vec!["other-crate".to_string()],
597606
};
598607

599608
assert_eq!(uf.member, "my-crate");
@@ -603,6 +612,7 @@ mod tests {
603612
assert_eq!(uf.resolved_features.len(), 3);
604613
assert_eq!(uf.dep_kind, DepKind::Normal);
605614
assert!(uf.target.is_none());
615+
assert_eq!(uf.borrowed_from.len(), 1);
606616
}
607617

608618
#[test]
@@ -615,6 +625,7 @@ mod tests {
615625
resolved_features: vec!["extra_traits".to_string()],
616626
dep_kind: DepKind::Normal,
617627
target: Some("cfg(unix)".to_string()),
628+
borrowed_from: vec!["unix-crate".to_string()],
618629
};
619630

620631
assert_eq!(uf.target, Some("cfg(unix)".to_string()));
@@ -630,6 +641,7 @@ mod tests {
630641
resolved_features: vec!["rt".to_string(), "macros".to_string()],
631642
dep_kind: DepKind::Dev,
632643
target: None,
644+
borrowed_from: vec!["main-crate".to_string()],
633645
};
634646

635647
assert_eq!(uf.dep_kind, DepKind::Dev);
@@ -746,6 +758,7 @@ mod tests {
746758
resolved_features: vec!["futures".to_string()],
747759
dep_kind: DepKind::Normal,
748760
target: None,
761+
borrowed_from: vec!["other-crate".to_string()],
749762
}],
750763
};
751764

@@ -778,6 +791,7 @@ mod tests {
778791
resolved_features: vec!["futures".to_string()],
779792
dep_kind: DepKind::Normal,
780793
target: None,
794+
borrowed_from: vec!["other-crate".to_string()],
781795
}],
782796
};
783797

@@ -839,12 +853,84 @@ mod tests {
839853
resolved_features: vec!["f1".to_string(), "f2".to_string()],
840854
dep_kind: DepKind::Dev,
841855
target: Some("cfg(windows)".to_string()),
856+
borrowed_from: vec!["source-crate".to_string()],
842857
};
843858

844859
let cloned = uf.clone();
845860
assert_eq!(uf.member, cloned.member);
846861
assert_eq!(uf.dep_name, cloned.dep_name);
847862
assert_eq!(uf.undeclared_features, cloned.undeclared_features);
848863
assert_eq!(uf.target, cloned.target);
864+
assert_eq!(uf.borrowed_from, cloned.borrowed_from);
865+
}
866+
867+
#[test]
868+
fn test_summary_shows_borrowed_from_in_undeclared_warning() {
869+
let plan = UnificationPlan {
870+
workspace_deps: vec![],
871+
member_edits: std::collections::HashMap::new(),
872+
member_paths: std::collections::HashMap::new(),
873+
transitive_pins: vec![],
874+
validation_results: vec![],
875+
issues: vec![],
876+
computed_msrv: None,
877+
duplicates_cleaned: vec![],
878+
pruned_features: vec![],
879+
optional_features: vec![],
880+
version_mismatches: vec![],
881+
unused_deps: vec![],
882+
undeclared_features: vec![UndeclaredFeature {
883+
member: "my-crate".to_string(),
884+
dep_name: "tokio".to_string(),
885+
undeclared_features: vec!["macros".to_string(), "rt".to_string()],
886+
declared_features: vec![],
887+
resolved_features: vec!["macros".to_string(), "rt".to_string()],
888+
dep_kind: DepKind::Normal,
889+
target: None,
890+
borrowed_from: vec!["other-crate".to_string(), "third-crate".to_string()],
891+
}],
892+
};
893+
894+
// No AddFeatures edits = warn mode with borrowed_from
895+
let summary = plan.summary();
896+
assert!(summary.contains("⚠️ Undeclared features detected"));
897+
assert!(summary.contains("my-crate/tokio"));
898+
assert!(summary.contains("macros"));
899+
assert!(summary.contains("rt"));
900+
assert!(summary.contains("(borrowed from other-crate, third-crate)"));
901+
}
902+
903+
#[test]
904+
fn test_summary_borrowed_from_empty_not_shown() {
905+
let plan = UnificationPlan {
906+
workspace_deps: vec![],
907+
member_edits: std::collections::HashMap::new(),
908+
member_paths: std::collections::HashMap::new(),
909+
transitive_pins: vec![],
910+
validation_results: vec![],
911+
issues: vec![],
912+
computed_msrv: None,
913+
duplicates_cleaned: vec![],
914+
pruned_features: vec![],
915+
optional_features: vec![],
916+
version_mismatches: vec![],
917+
unused_deps: vec![],
918+
undeclared_features: vec![UndeclaredFeature {
919+
member: "my-crate".to_string(),
920+
dep_name: "backoff".to_string(),
921+
undeclared_features: vec!["futures".to_string()],
922+
declared_features: vec![],
923+
resolved_features: vec!["futures".to_string()],
924+
dep_kind: DepKind::Normal,
925+
target: None,
926+
borrowed_from: vec![], // Empty - shouldn't show "borrowed from"
927+
}],
928+
};
929+
930+
let summary = plan.summary();
931+
assert!(summary.contains("my-crate/backoff"));
932+
assert!(summary.contains("futures"));
933+
// Should NOT contain "borrowed from" when empty
934+
assert!(!summary.contains("borrowed from"));
849935
}
850936
}

0 commit comments

Comments
 (0)