Skip to content

Commit 335df3e

Browse files
committed
cargo-rail: fixing all-features issues; transitive chains aren't working
1 parent 99220c7 commit 335df3e

File tree

6 files changed

+126
-42
lines changed

6 files changed

+126
-42
lines changed

src/cargo/manifest.rs

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -292,29 +292,38 @@ impl CargoTransform {
292292
for unified in unified_deps {
293293
// Determine which table to write to based on target
294294
let deps_table = if let Some(ref target) = unified.target {
295-
// Platform-specific dependency: write to [target.'<target>'.dependencies]
296-
let target_key = format!("target.'{}'", target);
297-
298-
// Ensure target section exists in workspace
295+
// Platform-specific dependency: write to [workspace.target.'<target>'.dependencies]
296+
// IMPORTANT: We need to create workspace.target (as a table), then target.'cfg(...)' as a subtable
299297
let workspace = doc["workspace"]
300298
.as_table_mut()
301299
.ok_or_else(|| RailError::message("[workspace] is not a table"))?;
302300

303-
if !workspace.contains_key(&target_key) {
304-
workspace[&target_key] = table();
301+
// Ensure [workspace.target] table exists
302+
if !workspace.contains_key("target") {
303+
workspace["target"] = table();
304+
}
305+
306+
let target_table = workspace["target"]
307+
.as_table_mut()
308+
.ok_or_else(|| RailError::message("[workspace.target] is not a table"))?;
309+
310+
// Create the cfg-specific section within target: [workspace.target.'cfg(...)']
311+
if !target_table.contains_key(target) {
312+
target_table[target] = table();
305313
}
306314

307-
let target_section = workspace[&target_key]
315+
let cfg_section = target_table[target]
308316
.as_table_mut()
309-
.ok_or_else(|| RailError::message(format!("[workspace.{}] is not a table", target_key)))?;
317+
.ok_or_else(|| RailError::message(format!("[workspace.target.'{}'] is not a table", target)))?;
310318

311-
if !target_section.contains_key("dependencies") {
312-
target_section["dependencies"] = table();
319+
// Create dependencies section: [workspace.target.'cfg(...)'.dependencies]
320+
if !cfg_section.contains_key("dependencies") {
321+
cfg_section["dependencies"] = table();
313322
}
314323

315-
target_section["dependencies"]
324+
cfg_section["dependencies"]
316325
.as_table_mut()
317-
.ok_or_else(|| RailError::message(format!("[workspace.{}.dependencies] is not a table", target_key)))?
326+
.ok_or_else(|| RailError::message(format!("[workspace.target.'{}'.dependencies] is not a table", target)))?
318327
} else {
319328
// Regular dependency: write to [workspace.dependencies]
320329
let workspace = doc["workspace"]

src/cargo/unify/collector.rs

Lines changed: 69 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ use std::collections::{HashMap, HashSet};
1010
/// Uses RESOLVED features from the dependency graph, not declared features.
1111
/// This ensures we capture features enabled transitively and provides the
1212
/// TRUE feature union across the workspace.
13-
pub fn collect_dependencies(metadata: &WorkspaceMetadata) -> Vec<DependencyInstance> {
13+
///
14+
/// The `use_all_features` parameter indicates whether metadata was collected with --all-features,
15+
/// which affects how we classify unidentified feature sources.
16+
pub fn collect_dependencies(metadata: &WorkspaceMetadata, use_all_features: bool) -> Vec<DependencyInstance> {
1417
let mut instances = Vec::new();
1518

1619
for pkg in metadata.list_crates() {
@@ -40,7 +43,7 @@ pub fn collect_dependencies(metadata: &WorkspaceMetadata) -> Vec<DependencyInsta
4043
};
4144

4245
// Track feature provenance: WHY is each feature enabled?
43-
let feature_provenance = determine_feature_provenance(&features, dep, &pkg.name, metadata);
46+
let feature_provenance = determine_feature_provenance(&features, dep, &pkg.name, metadata, use_all_features);
4447

4548
// Detect if this is a proc-macro crate
4649
// Proc-macros are build-time only and have different optimization strategies
@@ -127,32 +130,45 @@ fn get_member_workspace_deps(manifest_path: &cargo_metadata::camino::Utf8Path) -
127130
/// 1. Direct declaration in member's Cargo.toml
128131
/// 2. Default features
129132
/// 3. Target-specific
130-
/// 4. Transitive or --all-features
133+
/// 4. Transitive or --all-features (depending on use_all_features parameter)
131134
fn determine_feature_provenance(
132135
resolved_features: &[String],
133136
dep: &cargo_metadata::Dependency,
134137
member_name: &str,
135138
metadata: &WorkspaceMetadata,
139+
use_all_features: bool,
136140
) -> HashMap<String, FeatureSource> {
137141
let mut provenance = HashMap::new();
138142

139143
// Get the actual package metadata for this dependency
140144
let dep_pkg = metadata.get_package(&dep.name);
141145

146+
// Build a set of "root" features that could transitively enable other features:
147+
// 1. Features explicitly declared in Cargo.toml
148+
// 2. Default features (if enabled)
149+
let mut root_features = dep.features.to_vec();
150+
if dep.uses_default_features {
151+
root_features.push("default".to_string());
152+
}
153+
142154
for feature in resolved_features {
143-
let source = determine_single_feature_source(feature, dep, member_name, dep_pkg);
155+
let source = determine_single_feature_source(feature, dep, member_name, dep_pkg, &root_features, use_all_features);
144156
provenance.insert(feature.clone(), source);
145157
}
146158

147159
provenance
148160
}
149161

150162
/// Determine the source of a single feature
163+
///
164+
/// `root_features` are the features that were explicitly enabled (from Cargo.toml + default if enabled)
151165
fn determine_single_feature_source(
152166
feature: &str,
153167
dep: &cargo_metadata::Dependency,
154168
member_name: &str,
155169
dep_pkg: Option<&cargo_metadata::Package>,
170+
root_features: &[String],
171+
use_all_features: bool,
156172
) -> FeatureSource {
157173
// 1. Check if declared directly in this member's Cargo.toml
158174
if dep.features.contains(&feature.to_string()) {
@@ -166,7 +182,7 @@ fn determine_single_feature_source(
166182
&& let Some(pkg) = dep_pkg
167183
&& let Some(default_features) = pkg.features.get("default")
168184
{
169-
// Default features can reference other features
185+
// Default features can reference other features directly
170186
if default_features.iter().any(|f| f.trim_start_matches("dep:") == feature) {
171187
return FeatureSource::Default;
172188
}
@@ -179,8 +195,52 @@ fn determine_single_feature_source(
179195
};
180196
}
181197

182-
// 4. Otherwise it's either transitive or from --all-features
183-
// For now, we classify it as AllFeatures since we're using --all-features metadata
184-
// In a future enhancement, we could trace the actual dependency chain
185-
FeatureSource::AllFeatures
198+
// 4. Check if it's enabled transitively through a feature dependency chain
199+
// We need to trace from root_features (explicitly enabled) to this feature
200+
if let Some(pkg) = dep_pkg {
201+
// Try to find a path from any root feature to this feature
202+
for root_feature in root_features {
203+
if let Some(chain) = find_feature_chain(root_feature, feature, pkg) {
204+
return FeatureSource::Transitive { through: chain };
205+
}
206+
}
207+
}
208+
209+
// 5. If metadata was collected with --all-features, classify as such
210+
// Otherwise, it's transitive but we couldn't trace the exact chain
211+
if use_all_features {
212+
FeatureSource::AllFeatures
213+
} else {
214+
// Transitive dependency - exact chain couldn't be determined
215+
FeatureSource::Transitive { through: vec![] }
216+
}
217+
}
218+
219+
/// Find a chain of features from `start` to `target`
220+
///
221+
/// Returns the feature chain if found (e.g., ["perf", "perf-inline"])
222+
fn find_feature_chain(start: &str, target: &str, pkg: &cargo_metadata::Package) -> Option<Vec<String>> {
223+
// Check if start directly enables target
224+
if let Some(feature_deps) = pkg.features.get(start) {
225+
for dep_feature in feature_deps {
226+
let clean_name = dep_feature.trim_start_matches("dep:");
227+
if clean_name == target {
228+
return Some(vec![start.to_string()]);
229+
}
230+
}
231+
232+
// Check if start enables something that enables target (one level deep)
233+
for dep_feature in feature_deps {
234+
let intermediate = dep_feature.trim_start_matches("dep:");
235+
if let Some(intermediate_deps) = pkg.features.get(intermediate) {
236+
for sub_dep in intermediate_deps {
237+
if sub_dep.trim_start_matches("dep:") == target {
238+
return Some(vec![start.to_string(), intermediate.to_string()]);
239+
}
240+
}
241+
}
242+
}
243+
}
244+
245+
None
186246
}

src/cargo/unify/config.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ use std::collections::HashSet;
66
/// Configuration for workspace unification
77
#[derive(Debug, Clone)]
88
pub struct UnifyConfig {
9+
/// Whether metadata was collected with --all-features (default: true)
10+
pub use_all_features: bool,
11+
912
/// Allow renamed dependencies to be unified (default: false)
1013
pub allow_renamed: bool,
1114

@@ -28,6 +31,7 @@ pub struct UnifyConfig {
2831
impl Default for UnifyConfig {
2932
fn default() -> Self {
3033
Self {
34+
use_all_features: true,
3135
allow_renamed: false,
3236
exclude: HashSet::new(),
3337
include: HashSet::new(),
@@ -41,6 +45,7 @@ impl Default for UnifyConfig {
4145
impl From<&RailConfig> for UnifyConfig {
4246
fn from(rail_config: &RailConfig) -> Self {
4347
Self {
48+
use_all_features: rail_config.unify.use_all_features,
4449
allow_renamed: rail_config.unify.allow_renamed,
4550
exclude: rail_config.unify.exclude.iter().cloned().collect(),
4651
include: rail_config.unify.include.iter().cloned().collect(),

src/cargo/unify/issue_detection.rs

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -136,36 +136,36 @@ pub fn detect_issues(
136136
}
137137

138138
// Check if all are target-specific
139+
// IMPORTANT: Cargo does NOT support target-specific dependencies in [workspace.dependencies]
140+
// or [workspace.target.'cfg(...)'] sections. Platform-specific deps must stay in member crates.
141+
// See: https://doc.rust-lang.org/cargo/reference/workspaces.html
139142
if instances.iter().all(|i| i.target.is_some()) {
140143
let targets: Vec<_> = instances.iter().filter_map(|i| i.target.clone()).collect();
141-
142-
// Check if all instances use the SAME target
143144
let unique_targets: HashSet<_> = targets.iter().collect();
144145

145-
if unique_targets.len() == 1 {
146-
// All instances use the same target - we CAN unify this!
147-
// This is NOT an issue, it's a unification opportunity
148-
// Don't return an issue here - let the normal unification flow handle it
149-
// The unified dep can use [target.'cfg(...)'.dependencies]
150-
return None;
151-
}
152-
153-
// Multiple different targets - this is more complex
146+
// Return Info-level issue to inform user that platform-specific deps can't be unified
154147
return Some(UnificationIssue {
155148
dep_name: dep_name.to_string(),
156149
issue_type: IssueType::AllTargetSpecific {
157150
targets: targets.clone(),
158151
},
159152
severity: IssueSeverity::Info, // Just informational
160153
affected_members: instances.iter().map(|i| i.member.clone()).collect(),
161-
suggestion: format!(
162-
"Multiple platform-specific targets detected: {}. Consider per-platform unification.",
163-
unique_targets
164-
.iter()
165-
.map(|t| format!("'{}'", t))
166-
.collect::<Vec<_>>()
167-
.join(", ")
168-
),
154+
suggestion: if unique_targets.len() == 1 {
155+
format!(
156+
"All uses are platform-specific for target {}. Cargo does not support target-specific dependencies in workspace.dependencies - these will remain in member crates.",
157+
unique_targets.iter().next().unwrap()
158+
)
159+
} else {
160+
format!(
161+
"Multiple platform-specific targets detected: {}. Cargo does not support target-specific dependencies in workspace.dependencies - these will remain in member crates.",
162+
unique_targets
163+
.iter()
164+
.map(|t| format!("'{}'", t))
165+
.collect::<Vec<_>>()
166+
.join(", ")
167+
)
168+
},
169169
});
170170
}
171171

src/cargo/unify/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ impl<'a> WorkspaceUnifier<'a> {
7979
/// Analyze workspace and generate unification plan
8080
pub fn analyze(&self) -> RailResult<UnificationPlan> {
8181
// 1. Collect all dependency instances from workspace members
82-
let dep_instances = collector::collect_dependencies(self.metadata);
82+
let dep_instances = collector::collect_dependencies(self.metadata, self.config.use_all_features);
8383

8484
// 2. Group by dependency name
8585
let grouped = collector::group_by_name(dep_instances, self.metadata);

src/cargo/unify/plan.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,16 @@ impl UnificationPlan {
129129
let transitive_chains: Vec<_> = sources
130130
.iter()
131131
.filter_map(|s| match s {
132-
FeatureSource::Transitive { through } => Some(through.join(" → ")),
132+
FeatureSource::Transitive { through } if !through.is_empty() => Some(through.join(" → ")),
133133
_ => None,
134134
})
135135
.collect();
136136

137+
let has_transitive_unknown = sources.iter().any(|s| match s {
138+
FeatureSource::Transitive { through } => through.is_empty(),
139+
_ => false,
140+
});
141+
137142
// Format the provenance
138143
if !direct_members.is_empty() {
139144
output.push_str(&format!(
@@ -149,6 +154,11 @@ impl UnificationPlan {
149154
feature,
150155
transitive_chains.first().unwrap()
151156
));
157+
} else if has_transitive_unknown {
158+
output.push_str(&format!(
159+
" ⚠ \"{}\" - Enabled transitively (chain unknown)\n",
160+
feature
161+
));
152162
} else if has_all_features {
153163
output.push_str(&format!(" ⚠ \"{}\" - From --all-features flag\n", feature));
154164
}

0 commit comments

Comments
 (0)