Skip to content

Commit 5e588ec

Browse files
committed
fix(unify): enforce atomic workspace-member cohorts for non-default
members to prevent Tokio split-source graphs
1 parent 789fcb9 commit 5e588ec

File tree

2 files changed

+105
-22
lines changed

2 files changed

+105
-22
lines changed

src/cargo/unify_analyzer.rs

Lines changed: 35 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ use crate::error::{RailResult, ResultExt};
1919
use crate::progress;
2020
use crate::workspace::WorkspaceContext;
2121
use rustc_hash::{FxHashMap, FxHashSet};
22+
use semver::Version;
2223
use semver::VersionReq;
2324
use std::path::{Path, PathBuf};
2425
use std::sync::Arc;
@@ -294,6 +295,7 @@ impl UnifyAnalyzer {
294295
// Pre-compute workspace member metadata for reuse.
295296
let mut workspace_member_names: FxHashSet<Arc<str>> = FxHashSet::default();
296297
let mut workspace_member_paths: FxHashMap<Arc<str>, PathBuf> = FxHashMap::default();
298+
let mut workspace_member_versions: FxHashMap<Arc<str>, Version> = FxHashMap::default();
297299

298300
// Build member_paths mapping from metadata (manifest file paths)
299301
for pkg in self.metadata.workspace_packages() {
@@ -304,6 +306,7 @@ impl UnifyAnalyzer {
304306
Arc::clone(&member_name),
305307
self.normalize_workspace_member_path(&manifest_path),
306308
);
309+
workspace_member_versions.insert(Arc::clone(&member_name), pkg.version.clone());
307310
member_paths.insert(member_name, manifest_path);
308311
}
309312

@@ -384,30 +387,40 @@ impl UnifyAnalyzer {
384387
// Get version from metadata - but ONLY from direct dependencies of workspace members
385388
// This uses direct_dep_versions() which filters out transitive dependencies
386389
let versions = self.metadata.direct_dep_versions(&dep_key.name);
387-
if versions.is_empty() {
388-
continue; // Not a direct dependency of any workspace member
389-
}
390390

391-
// Get unique versions across all targets
392-
let unique_versions: FxHashSet<_> = versions.values().collect();
393-
394-
// Always use highest version (cargo's resolver already picks highest compatible)
395-
// When targets resolve to different versions, we unify to highest
396-
let version = match unique_versions.iter().max() {
397-
Some(max) if unique_versions.len() > 1 => {
398-
// Multiple versions found across targets - use highest
399-
// This is the "silent win" - we unify duplicates automatically
400-
let mut versions_found: Vec<Arc<str>> = unique_versions.iter().map(|v| Arc::from(v.to_string())).collect();
401-
versions_found.sort();
402-
duplicates_cleaned.push(DuplicateCleanup {
403-
dep_name: Arc::clone(&dep_key.name),
404-
versions_found,
405-
selected_version: Arc::from(max.to_string()),
406-
});
407-
max
391+
// Workspace-member deps must be unifyable even when Cargo metadata excludes
392+
// non-default members from resolved direct-dependency traversal.
393+
let version = if versions.is_empty() {
394+
if workspace_member_names.contains(&dep_key.name) {
395+
match workspace_member_versions.get(&dep_key.name) {
396+
Some(v) => v,
397+
None => continue,
398+
}
399+
} else {
400+
continue; // Not a direct dependency of any workspace member
401+
}
402+
} else {
403+
// Get unique versions across all targets
404+
let unique_versions: FxHashSet<_> = versions.values().collect();
405+
406+
// Always use highest version (cargo's resolver already picks highest compatible)
407+
// When targets resolve to different versions, we unify to highest
408+
match unique_versions.iter().max() {
409+
Some(max) if unique_versions.len() > 1 => {
410+
// Multiple versions found across targets - use highest
411+
// This is the "silent win" - we unify duplicates automatically
412+
let mut versions_found: Vec<Arc<str>> = unique_versions.iter().map(|v| Arc::from(v.to_string())).collect();
413+
versions_found.sort();
414+
duplicates_cleaned.push(DuplicateCleanup {
415+
dep_name: Arc::clone(&dep_key.name),
416+
versions_found,
417+
selected_version: Arc::from(max.to_string()),
418+
});
419+
*max
420+
}
421+
Some(version) => *version,
422+
None => continue, // Empty set - shouldn't happen given versions check above
408423
}
409-
Some(version) => version,
410-
None => continue, // Empty set - shouldn't happen given versions check above
411424
};
412425

413426
// Construct version requirement - preserve exact pins if configured

tests/integration/test_unify.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1017,6 +1017,76 @@ exclude = ["tokio"]
10171017
Ok(())
10181018
}
10191019

1020+
#[test]
1021+
fn test_unify_workspace_member_cohort_with_non_default_members_still_unifies() -> Result<()> {
1022+
let workspace = TestWorkspace::new_named("non-default-member-cohort")?;
1023+
1024+
// default member: excluded from the member-dependency cohort
1025+
workspace.add_crate("root-default", "0.1.0", &[])?;
1026+
1027+
// non-default member cohort:
1028+
// edge-a -> edge-b -> edge-c
1029+
workspace.add_crate(
1030+
"edge-a",
1031+
"0.1.0",
1032+
&[("edge-b", r#"{ path = "../edge-b", version = "0.1.0" }"#)],
1033+
)?;
1034+
workspace.add_crate(
1035+
"edge-b",
1036+
"0.1.0",
1037+
&[("edge-c", r#"{ path = "../edge-c", version = "0.1.0" }"#)],
1038+
)?;
1039+
workspace.add_crate("edge-c", "0.1.0", &[])?;
1040+
1041+
// Restrict default-members so edge-* crates are often absent from resolved metadata's
1042+
// direct-dependency traversal. Cohort logic must still unify edge-b/edge-c atomically.
1043+
let root_manifest = workspace.path.join("Cargo.toml");
1044+
let mut root = std::fs::read_to_string(&root_manifest)?;
1045+
root = root.replace(
1046+
"resolver = \"2\"\n",
1047+
"resolver = \"2\"\ndefault-members = [\"crates/root-default\"]\n",
1048+
);
1049+
std::fs::write(&root_manifest, root)?;
1050+
1051+
workspace.commit("Add non-default workspace member cohort")?;
1052+
1053+
let output = run_cargo_rail(&workspace.path, &["rail", "unify"])?;
1054+
assert!(
1055+
output.status.success(),
1056+
"unify should succeed.\nstdout:\n{}\nstderr:\n{}",
1057+
String::from_utf8_lossy(&output.stdout),
1058+
String::from_utf8_lossy(&output.stderr)
1059+
);
1060+
1061+
let workspace_toml = std::fs::read_to_string(workspace.path.join("Cargo.toml"))?;
1062+
assert!(
1063+
workspace_toml.contains("edge-b = {") && workspace_toml.contains("path = \"crates/edge-b\""),
1064+
"edge-b should be unified as workspace member despite non-default-members metadata limits.\nCargo.toml:\n{}",
1065+
workspace_toml
1066+
);
1067+
assert!(
1068+
workspace_toml.contains("edge-c = {") && workspace_toml.contains("path = \"crates/edge-c\""),
1069+
"edge-c should be unified as workspace member despite non-default-members metadata limits.\nCargo.toml:\n{}",
1070+
workspace_toml
1071+
);
1072+
1073+
let edge_a_toml = std::fs::read_to_string(workspace.path.join("crates/edge-a/Cargo.toml"))?;
1074+
assert!(
1075+
edge_a_toml.contains("edge-b = { workspace = true"),
1076+
"edge-a should inherit edge-b from workspace.\nCargo.toml:\n{}",
1077+
edge_a_toml
1078+
);
1079+
1080+
let edge_b_toml = std::fs::read_to_string(workspace.path.join("crates/edge-b/Cargo.toml"))?;
1081+
assert!(
1082+
edge_b_toml.contains("edge-c = { workspace = true"),
1083+
"edge-b should inherit edge-c from workspace.\nCargo.toml:\n{}",
1084+
edge_b_toml
1085+
);
1086+
1087+
Ok(())
1088+
}
1089+
10201090
// Config Options: include, pin_transitives, include_renamed
10211091

10221092
/// Test include config forces specific dependencies to be included

0 commit comments

Comments
 (0)