Skip to content

Commit 4275890

Browse files
committed
cargo-rail: a few fixes to the 'unify' command; MSRV feature integrated. Extensive testing
1 parent 16e6dfd commit 4275890

File tree

12 files changed

+203
-21
lines changed

12 files changed

+203
-21
lines changed

.gitignore

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,7 @@ AGENTS.md
2222

2323
# Documentation
2424
docs/*
25-
audit.md
26-
fixes.md
27-
fixes_split.md
28-
fixes_cdtwm.md
25+
TODO.md
2926

3027
# Cargo-Rail (Testing)
3128
*rail.toml

src/cargo/manifest_analyzer.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -377,10 +377,13 @@ impl ManifestAnalyzer {
377377
..Default::default()
378378
};
379379

380-
// Check for renamed package
380+
// Check for renamed package (only when package name differs from dependency key)
381381
if let Some(pkg) = table.get("package").and_then(|v| v.as_str()) {
382-
parsed.renamed_from = Some(dep_name.to_string());
383-
parsed.actual_name = Some(pkg.to_string());
382+
// Only flag as renamed when there's actual renaming, not redundant package fields
383+
if pkg != dep_name {
384+
parsed.renamed_from = Some(dep_name.to_string());
385+
parsed.actual_name = Some(pkg.to_string());
386+
}
384387
}
385388

386389
// Parse version - this is the DECLARED version from the manifest

src/cargo/manifest_ops.rs

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,9 @@ pub fn write_toml_file(path: &Path, doc: &DocumentMut) -> RailResult<()> {
9090
pub fn build_dep_entry(dep: &UnifiedDep) -> Item {
9191
// Simple case: just version, no features, defaults enabled, no path
9292
if dep.features.is_empty() && dep.default_features && dep.path.is_none() {
93-
return Item::Value(Value::from(dep.version_req.to_string()));
93+
let mut value = Value::from(dep.version_req.to_string());
94+
value.decor_mut().set_suffix(" #unified");
95+
return Item::Value(value);
9496
}
9597

9698
// Complex case: use inline table
@@ -118,7 +120,11 @@ pub fn build_dep_entry(dep: &UnifiedDep) -> Item {
118120
table.insert("features", build_feature_array(&dep.features));
119121
}
120122

121-
Item::Value(Value::InlineTable(table))
123+
// Add #unified comment marker
124+
let mut value = Value::InlineTable(table);
125+
value.decor_mut().set_suffix(" #unified");
126+
127+
Item::Value(value)
122128
}
123129

124130
/// Build a workspace-inherited dependency entry
@@ -132,7 +138,7 @@ pub fn build_dep_entry(dep: &UnifiedDep) -> Item {
132138
///
133139
/// # Returns
134140
///
135-
/// `{ workspace = true }` with optional fields
141+
/// `{ workspace = true }` with optional fields and `#unified` comment marker
136142
pub fn build_workspace_dep_entry(local_features: Option<Vec<String>>, is_optional: bool) -> Item {
137143
let mut table = InlineTable::new();
138144
table.insert("workspace", Value::from(true));
@@ -149,7 +155,11 @@ pub fn build_workspace_dep_entry(local_features: Option<Vec<String>>, is_optiona
149155
table.insert("optional", Value::from(true));
150156
}
151157

152-
Item::Value(Value::InlineTable(table))
158+
// Add #unified comment marker to track what was modified by unify
159+
let mut value = Value::InlineTable(table);
160+
value.decor_mut().set_suffix(" #unified");
161+
162+
Item::Value(value)
153163
}
154164

155165
/// Build a transitive dependency entry for pinning
@@ -167,7 +177,11 @@ pub fn build_transitive_entry(features: &[String]) -> Item {
167177
table.insert("features", build_feature_array(features));
168178
}
169179

170-
Item::Value(Value::InlineTable(table))
180+
// Add #unified comment marker
181+
let mut value = Value::InlineTable(table);
182+
value.decor_mut().set_suffix(" #unified");
183+
184+
Item::Value(value)
171185
}
172186

173187
// ============================================================================

src/cargo/manifest_writer.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,4 +166,32 @@ impl ManifestWriter {
166166
fn dep_kind_to_section(&self, dep_kind: DepKind) -> &'static str {
167167
manifest_ops::dep_kind_to_section(dep_kind)
168168
}
169+
170+
/// Write MSRV (rust-version) to workspace manifest
171+
///
172+
/// Writes to [workspace.package].rust-version so that members can inherit it
173+
/// via `rust-version.workspace = true`
174+
pub fn write_workspace_msrv(&self, workspace_toml_path: &Path, msrv: &semver::Version) -> RailResult<()> {
175+
// Read workspace Cargo.toml
176+
let mut doc = manifest_ops::read_toml_file(workspace_toml_path)?;
177+
178+
// Ensure [workspace] section exists
179+
manifest_ops::ensure_section(&mut doc, "workspace").context("Failed to create [workspace] section")?;
180+
181+
// Get or create [workspace.package] section
182+
let ws_package = manifest_ops::get_or_create_table(&mut doc, "workspace.package")
183+
.context("Failed to create [workspace.package]")?;
184+
185+
// Format MSRV as "major.minor" (standard rust-version format)
186+
let msrv_str = format!("{}.{}", msrv.major, msrv.minor);
187+
188+
// Insert or update rust-version
189+
ws_package.insert("rust-version", toml_edit::value(&msrv_str));
190+
191+
// Format and write
192+
self.formatter.format_manifest(&mut doc)?;
193+
manifest_ops::write_toml_file(workspace_toml_path, &doc)?;
194+
195+
Ok(())
196+
}
169197
}

src/cargo/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ pub mod unify_analyzer;
1717
pub use cargo_transform::{CargoTransform, TransformContext};
1818
pub use manifest_analyzer::{DepKey, DepKind, DepUsage, ManifestAnalyzer};
1919
pub use manifest_writer::ManifestWriter;
20-
pub use multi_target_metadata::{FragmentedTransitive, MultiTargetMetadata};
20+
pub use multi_target_metadata::{ComputedMsrv, FragmentedTransitive, MultiTargetMetadata};
2121
pub use unify_analyzer::{
2222
IssueSeverity, MemberEdit, UnificationPlan, UnifiedDep, UnifyAnalyzer, UnifyIssue, UnifyReport, ValidationResult,
2323
};

src/cargo/multi_target_metadata.rs

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,57 @@ impl MultiTargetMetadata {
285285

286286
transitives
287287
}
288+
289+
/// Compute the workspace MSRV from all resolved dependencies
290+
///
291+
/// The MSRV is the maximum `rust-version` across all dependencies in the
292+
/// resolved graph. This ensures the workspace can build all its deps.
293+
///
294+
/// Returns `None` if no dependencies specify rust-version.
295+
pub fn compute_msrv(&self) -> Option<ComputedMsrv> {
296+
let mut max_version: Option<Version> = None;
297+
let mut contributors: Vec<String> = Vec::new();
298+
let mut deps_with_msrv = 0;
299+
let mut seen_packages: HashSet<String> = HashSet::new();
300+
301+
// Iterate through all packages in the resolved graph
302+
for metadata in self.cache.values() {
303+
for pkg in &metadata.packages {
304+
// Skip if we've already processed this package (may appear in multiple targets)
305+
let pkg_key = format!("{}@{}", pkg.name, pkg.version);
306+
if seen_packages.contains(&pkg_key) {
307+
continue;
308+
}
309+
seen_packages.insert(pkg_key);
310+
311+
// Check if this package has rust-version specified
312+
if let Some(ref rust_version) = pkg.rust_version {
313+
deps_with_msrv += 1;
314+
315+
match &max_version {
316+
None => {
317+
max_version = Some(rust_version.clone());
318+
contributors = vec![pkg.name.to_string()];
319+
}
320+
Some(current_max) => {
321+
if rust_version > current_max {
322+
max_version = Some(rust_version.clone());
323+
contributors = vec![pkg.name.to_string()];
324+
} else if rust_version == current_max {
325+
contributors.push(pkg.name.to_string());
326+
}
327+
}
328+
}
329+
}
330+
}
331+
}
332+
333+
max_version.map(|version| ComputedMsrv {
334+
version,
335+
contributors,
336+
deps_with_msrv,
337+
})
338+
}
288339
}
289340

290341
/// A transitive dependency with fragmented features across targets
@@ -304,3 +355,14 @@ impl FragmentedTransitive {
304355
self.feature_sets.len()
305356
}
306357
}
358+
359+
/// Result of MSRV computation from dependency graph
360+
#[derive(Debug, Clone)]
361+
pub struct ComputedMsrv {
362+
/// The computed MSRV (maximum of all deps' rust-version)
363+
pub version: Version,
364+
/// Dependencies that contributed to the MSRV (those with the highest rust-version)
365+
pub contributors: Vec<String>,
366+
/// Total number of deps with rust-version specified
367+
pub deps_with_msrv: usize,
368+
}

src/cargo/unify_analyzer.rs

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
66
use crate::cargo::{
77
manifest_analyzer::{DepKind, ExistingWorkspaceDep, ManifestAnalyzer, parse_existing_workspace_deps},
8-
multi_target_metadata::MultiTargetMetadata,
8+
multi_target_metadata::{ComputedMsrv, MultiTargetMetadata},
99
};
1010
use crate::config::UnifyConfig;
1111
use crate::error::RailResult;
@@ -101,6 +101,8 @@ pub struct UnificationPlan {
101101
pub validation_results: Vec<ValidationResult>,
102102
/// Issues detected during analysis
103103
pub issues: Vec<UnifyIssue>,
104+
/// Computed MSRV from dependency graph (if msrv = true in config)
105+
pub computed_msrv: Option<ComputedMsrv>,
104106
}
105107

106108
impl UnificationPlan {
@@ -189,6 +191,26 @@ impl UnificationPlan {
189191
}
190192
}
191193

194+
// Show computed MSRV if available
195+
if let Some(ref msrv) = self.computed_msrv {
196+
s.push_str(&format!(
197+
"\nComputed MSRV: {} (from {} deps with rust-version)\n",
198+
msrv.version, msrv.deps_with_msrv
199+
));
200+
if !msrv.contributors.is_empty() {
201+
let contributors_str = if msrv.contributors.len() > 3 {
202+
format!(
203+
"{}, ... ({} total)",
204+
msrv.contributors[..3].join(", "),
205+
msrv.contributors.len()
206+
)
207+
} else {
208+
msrv.contributors.join(", ")
209+
};
210+
s.push_str(&format!(" Contributors: {}\n", contributors_str));
211+
}
212+
}
213+
192214
s
193215
}
194216
}
@@ -486,6 +508,14 @@ impl UnifyAnalyzer {
486508
Vec::new()
487509
};
488510

511+
// Compute MSRV if enabled
512+
let computed_msrv = if self.config.msrv {
513+
println!("Computing MSRV from dependency graph...");
514+
self.metadata.compute_msrv()
515+
} else {
516+
None
517+
};
518+
489519
// Run validation
490520
let validation_results = self.validate_targets()?;
491521

@@ -496,6 +526,7 @@ impl UnifyAnalyzer {
496526
transitive_pins,
497527
validation_results,
498528
issues,
529+
computed_msrv,
499530
})
500531
}
501532

src/commands/unify.rs

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -121,10 +121,17 @@ pub fn run_unify_apply(
121121
return Ok(());
122122
}
123123

124-
// Create backup if requested
125-
if backup {
126-
println!("📦 Creating backup...");
127-
let backup_manager = BackupManager::new(ctx.workspace_root());
124+
// Create backup if requested OR if this is the first unify run (safety feature)
125+
let backup_manager = BackupManager::new(ctx.workspace_root());
126+
let is_first_run = !backup_manager.has_backups();
127+
let should_backup = backup || is_first_run;
128+
129+
if should_backup {
130+
if is_first_run && !backup {
131+
println!("📦 Creating safety backup (first unify run in this workspace)...");
132+
} else {
133+
println!("📦 Creating backup...");
134+
}
128135

129136
// Collect all files that will be modified
130137
let mut files_to_backup = Vec::new();
@@ -207,9 +214,22 @@ pub fn run_unify_apply(
207214
writer.add_transitive_pins(&host_path, &plan.transitive_pins)?;
208215
}
209216

217+
// Write computed MSRV to workspace manifest if enabled
218+
if let Some(ref msrv) = plan.computed_msrv {
219+
println!(
220+
"📋 Writing rust-version = \"{}.{}\" to [workspace.package]...",
221+
msrv.version.major, msrv.version.minor
222+
);
223+
writer.write_workspace_msrv(&ctx.workspace_root().join("Cargo.toml"), &msrv.version)?;
224+
}
225+
210226
// Generate report
211227
println!("📄 Generating report...");
212-
let report_path = ctx.workspace_root().join(".cargo-rail").join("unify-report.md");
228+
let report_path = ctx
229+
.workspace_root()
230+
.join("target")
231+
.join("cargo-rail")
232+
.join("unify-report.md");
213233
UnifyReport::write_to_file(&plan, &report_path)?;
214234
println!(" Report saved to: {}", report_path.display());
215235

@@ -220,6 +240,14 @@ pub fn run_unify_apply(
220240
if !plan.transitive_pins.is_empty() {
221241
println!(" - {} transitives pinned", plan.transitive_pins.len());
222242
}
243+
if let Some(ref msrv) = plan.computed_msrv {
244+
println!(
245+
" - MSRV set to {}.{} (from {})",
246+
msrv.version.major,
247+
msrv.version.minor,
248+
msrv.contributors.first().unwrap_or(&"unknown".to_string())
249+
);
250+
}
223251

224252
println!("\nNext steps:");
225253
println!(" 1. Run 'cargo check' to verify everything compiles");

src/config.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,12 @@ pub struct UnifyConfig {
7171
/// Older backups are automatically cleaned up after successful unify operations
7272
#[serde(default = "default_max_backups")]
7373
pub max_backups: usize,
74+
75+
/// Compute and write MSRV to workspace manifest? (default: false)
76+
/// When enabled, cargo-rail computes the maximum rust-version from all
77+
/// resolved dependencies and writes it to [workspace.package].rust-version
78+
#[serde(default)]
79+
pub msrv: bool,
7480
}
7581

7682
fn default_max_backups() -> usize {
@@ -173,6 +179,7 @@ impl Default for UnifyConfig {
173179
exclude: Vec::new(),
174180
include: Vec::new(),
175181
max_backups: default_max_backups(),
182+
msrv: false,
176183
}
177184
}
178185
}

src/graph/core.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,8 @@ impl WorkspaceGraph {
195195
}
196196

197197
// Add edges between workspace members only
198+
// IMPORTANT: Skip dev-dependencies as they don't affect publish order
199+
// (dev-deps can have cycles, including self-references for feature-gated test utils)
198200
for (from_name, &from_subgraph_idx) in &name_to_subgraph_idx {
199201
let from_graph_idx = self.name_to_node[from_name];
200202

@@ -208,7 +210,11 @@ impl WorkspaceGraph {
208210
&& let Some(edge) = self.graph.find_edge(from_graph_idx, neighbor_graph_idx)
209211
{
210212
let kind = *self.graph.edge_weight(edge).unwrap();
211-
subgraph.add_edge(from_subgraph_idx, to_subgraph_idx, kind);
213+
// Skip dev-dependencies - they don't affect publish order and can have cycles
214+
// Also skip self-references (crate depending on itself for test features)
215+
if kind != DependencyKind::Development && from_name != &neighbor_node.name {
216+
subgraph.add_edge(from_subgraph_idx, to_subgraph_idx, kind);
217+
}
212218
}
213219
}
214220
}

0 commit comments

Comments
 (0)