use crate::cargo::{ManifestWriter, UnifyAnalyzer, UnifyReport};
use crate::commands::common::{UnifyOutputFormat, format_preview_list};
use crate::error::{RailError, RailResult};
use crate::mutation::{self, MutationAction, MutationRisk, MutationTrace};
use crate::progress;
use crate::workspace::WorkspaceContext;
use std::fmt::Write as _;
use std::path::PathBuf;
struct UnifyTextSink {
buffer: Option<String>,
}
impl UnifyTextSink {
fn new(capture: bool) -> Self {
Self {
buffer: capture.then_some(String::new()),
}
}
fn push_line(&mut self, args: std::fmt::Arguments<'_>) {
if let Some(ref mut buf) = self.buffer {
use std::fmt::Write as _;
let _ = buf.write_fmt(args);
buf.push('\n');
} else {
println!("{}", args);
}
}
fn finish(self) -> Option<String> {
self.buffer
}
}
macro_rules! outln {
($sink:expr $(,)?) => {{
$sink.push_line(format_args!(""));
}};
($sink:expr, $($arg:tt)*) => {{
$sink.push_line(format_args!($($arg)*));
}};
}
fn write_output(content: &str, output_file: Option<&PathBuf>) -> RailResult<()> {
use std::io::Write as _;
let needs_trailing_newline = !content.ends_with('\n');
match output_file {
Some(path) => {
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(path)
.map_err(|e| RailError::message(format!("failed to open '{}': {}", path.display(), e)))?;
file
.write_all(content.as_bytes())
.map_err(|e| RailError::message(format!("failed to write '{}': {}", path.display(), e)))?;
if needs_trailing_newline {
file
.write_all(b"\n")
.map_err(|e| RailError::message(format!("failed to write '{}': {}", path.display(), e)))?;
}
progress!("output: {}", path.display());
Ok(())
}
None => {
print!("{}", content);
if needs_trailing_newline {
println!();
}
Ok(())
}
}
}
fn format_quoted_list(values: &[impl AsRef<str>]) -> String {
let mut out = String::with_capacity(values.iter().map(|v| v.as_ref().len()).sum::<usize>() + values.len() * 4 + 2);
out.push('[');
for (idx, value) in values.iter().enumerate() {
if idx > 0 {
out.push_str(", ");
}
out.push('"');
out.push_str(value.as_ref());
out.push('"');
}
out.push(']');
out
}
fn dependency_section<'a>(dep_kind: &crate::cargo::DepKind, target: Option<&'a str>) -> std::borrow::Cow<'a, str> {
use std::borrow::Cow;
match (dep_kind, target) {
(crate::cargo::DepKind::Dev, None) => Cow::Borrowed("[dev-dependencies]"),
(crate::cargo::DepKind::Build, None) => Cow::Borrowed("[build-dependencies]"),
(_, None) => Cow::Borrowed("[dependencies]"),
(crate::cargo::DepKind::Dev, Some(t)) => Cow::Owned(format!("[target.'{}'.dev-dependencies]", t)),
(crate::cargo::DepKind::Build, Some(t)) => Cow::Owned(format!("[target.'{}'.build-dependencies]", t)),
(_, Some(t)) => Cow::Owned(format!("[target.'{}'.dependencies]", t)),
}
}
fn mutation_targets(
plan: &crate::cargo::UnificationPlan,
workspace_root: &std::path::Path,
msrv_write_needed: bool,
) -> Vec<String> {
let mut targets = Vec::new();
if !plan.workspace_deps.is_empty() || !plan.transitive_pins.is_empty() || msrv_write_needed {
targets.push("Cargo.toml".to_string());
}
let mut members: Vec<String> = plan
.member_edits
.keys()
.filter_map(|member| {
plan.member_paths.get(member).map(|path| {
let relative = if path.is_absolute() {
path.strip_prefix(workspace_root).unwrap_or(path)
} else {
path.strip_prefix(std::path::Path::new(".")).unwrap_or(path)
};
crate::utils::path_to_git_format(relative)
})
})
.collect();
members.sort();
targets.extend(members);
targets
}
fn blocked_issue_lines(plan: &crate::cargo::UnificationPlan) -> Vec<String> {
plan
.issues
.iter()
.filter(|issue| issue.severity == crate::cargo::IssueSeverity::Error)
.map(|issue| format!("{}: {}", issue.dep_name, issue.message))
.collect()
}
fn write_compact_summary(
sink: &mut UnifyTextSink,
plan: &crate::cargo::UnificationPlan,
workspace_root: &std::path::Path,
msrv_write_needed: bool,
has_changes: bool,
) {
outln!(sink, "unify");
outln!(sink);
outln!(sink, "changed:");
let workspace_dep_names: Vec<String> = plan.workspace_deps.iter().map(|dep| dep.name.to_string()).collect();
if workspace_dep_names.is_empty()
&& plan.member_edit_count() == 0
&& plan.transitive_pins.is_empty()
&& !msrv_write_needed
&& plan.pruned_features.is_empty()
&& plan.unused_deps.is_empty()
{
outln!(sink, " none");
} else {
if !workspace_dep_names.is_empty() {
outln!(
sink,
" workspace deps ({}): {}",
workspace_dep_names.len(),
format_preview_list(&workspace_dep_names, 8)
);
}
let members_affected = plan.member_edits.len();
if plan.member_edit_count() > 0 {
outln!(
sink,
" member edits: {} across {} crate(s)",
plan.member_edit_count(),
members_affected
);
}
if !plan.transitive_pins.is_empty() {
let pin_names: Vec<String> = plan.transitive_pins.iter().map(|pin| pin.name.to_string()).collect();
outln!(
sink,
" transitive pins ({}): {}",
pin_names.len(),
format_preview_list(&pin_names, 8)
);
}
let undeclared_fix_count: usize = plan
.member_edits
.values()
.flat_map(|edits| edits.iter())
.filter_map(|edit| match edit {
crate::cargo::MemberEdit::AddFeatures { features_to_add, .. } => Some(features_to_add.len()),
_ => None,
})
.sum();
if undeclared_fix_count > 0 {
let crates_fixed = plan
.member_edits
.values()
.filter(|edits| {
edits
.iter()
.any(|edit| matches!(edit, crate::cargo::MemberEdit::AddFeatures { .. }))
})
.count();
outln!(
sink,
" undeclared-feature fixes: {} feature(s) across {} crate(s)",
undeclared_fix_count,
crates_fixed
);
}
if !plan.unused_deps.is_empty() {
outln!(sink, " unused deps removed: {}", plan.unused_deps.len());
}
if !plan.pruned_features.is_empty() {
outln!(sink, " dead features pruned: {}", plan.pruned_features.len());
}
if msrv_write_needed && let Some(msrv) = plan.computed_msrv.as_ref() {
outln!(
sink,
" rust-version: {}.{}.{}",
msrv.version.major,
msrv.version.minor,
msrv.version.patch
);
}
}
outln!(sink);
outln!(sink, "will mutate:");
let targets = mutation_targets(plan, workspace_root, msrv_write_needed);
if targets.is_empty() {
outln!(sink, " none");
} else {
outln!(sink, " {}", format_preview_list(&targets, 8));
}
outln!(sink);
outln!(sink, "blocked:");
let blocked = blocked_issue_lines(plan);
if blocked.is_empty() {
outln!(sink, " none");
} else {
for line in blocked.iter().take(5) {
outln!(sink, " {}", line);
}
if blocked.len() > 5 {
outln!(sink, " ... +{} more", blocked.len() - 5);
}
}
outln!(sink);
outln!(sink, "next:");
if !blocked.is_empty() {
outln!(sink, " cargo rail unify --check --explain");
} else if has_changes {
outln!(sink, " cargo rail unify");
outln!(sink, " cargo rail unify --check --show-diff");
outln!(sink, " cargo rail unify --check --explain");
} else {
outln!(sink, " cargo rail unify --check --explain");
}
}
fn decision_reasons_to_json(reasons: &[crate::cargo::UnifyDecisionReason]) -> Vec<serde_json::Value> {
reasons
.iter()
.map(|reason| {
serde_json::json!({
"code": reason.code.as_str(),
"summary": &*reason.summary,
"features": reason.features.iter().map(|value| &**value).collect::<Vec<_>>(),
"members": reason.members.iter().map(|value| &**value).collect::<Vec<_>>(),
"borrowed_from": reason.borrowed_from.iter().map(|value| &**value).collect::<Vec<_>>(),
})
})
.collect()
}
fn dependency_decisions_to_json(plan: &crate::cargo::UnificationPlan) -> Vec<serde_json::Value> {
plan
.dependency_decisions
.iter()
.map(|decision| {
serde_json::json!({
"dep_name": &*decision.dep_name,
"subject": decision.subject.as_str(),
"member": decision.member.as_deref(),
"target": decision.target.as_deref(),
"reasons": decision_reasons_to_json(&decision.reasons),
})
})
.collect()
}
pub fn run_unify_analyze(
ctx: &WorkspaceContext,
show_diff: bool,
explain: bool,
format: UnifyOutputFormat,
output: Option<&PathBuf>,
) -> RailResult<()> {
let json = format.is_json();
if json {
crate::output::set_json_mode(true);
}
let analyzer = UnifyAnalyzer::new(ctx)?;
let plan = analyzer.analyze()?;
let msrv_write_needed = if let Some(msrv) = plan.computed_msrv.as_ref() {
workspace_msrv_write_needed(ctx.workspace_root(), &msrv.version)?
} else {
false
};
let has_changes = plan.has_planned_changes(msrv_write_needed);
if json {
let (actions, risks, trace) = build_unify_mutation_parts(&plan, msrv_write_needed, false, true, output);
let mutation_plan = if ctx.has_git() {
Some(mutation::build_plan(
ctx,
"unify",
actions.clone(),
risks.clone(),
trace.clone(),
)?)
} else {
None
};
let mut canonical_actions = actions;
canonical_actions.sort_by(|a, b| {
a.code
.cmp(&b.code)
.then_with(|| a.target.cmp(&b.target))
.then_with(|| a.detail.cmp(&b.detail))
});
let mut reason_codes: Vec<String> = mutation_plan
.as_ref()
.map(|plan| {
plan
.trace
.iter()
.map(|t| t.code.clone())
.chain(plan.risks.iter().map(|r| r.code.clone()))
.collect()
})
.unwrap_or_else(|| {
trace
.iter()
.map(|t| t.code.clone())
.chain(risks.iter().map(|r| r.code.clone()))
.collect()
});
reason_codes.sort();
reason_codes.dedup();
let payload = serde_json::json!({
"command": "unify",
"check": true,
"msrv_write_needed": msrv_write_needed,
"has_changes": has_changes,
"workspace_deps": plan.workspace_deps.iter().map(|d| {
let features: Vec<&str> = d.features.iter().map(|f| &**f).collect();
serde_json::json!({
"name": &*d.name,
"version": d.version_req,
"features": features,
})
}).collect::<Vec<_>>(),
"summary": {
"workspace_deps_count": plan.workspace_deps.len(),
"member_edits_count": plan.member_edit_count(),
"members_affected": plan.member_edits.len(),
"transitive_pins_count": plan.transitive_pins.len(),
"duplicates_unified": plan.duplicates_cleaned.len(),
"dead_features_pruned": plan.pruned_features.len(),
"optional_features_detected": plan.optional_features.len(),
"version_mismatches": plan.version_mismatches.len(),
"unused_deps": plan.unused_deps.len(),
},
"has_blocking_issues": plan.has_blocking_issues(),
"issues": plan.issues.iter().map(|i| serde_json::json!({
"kind": format!("{:?}", i.kind),
"dep_name": &*i.dep_name,
"severity": format!("{:?}", i.severity),
"message": &*i.message,
})).collect::<Vec<_>>(),
"dependency_decisions": dependency_decisions_to_json(&plan),
"action_plan": canonical_actions,
"reason_codes": reason_codes,
"mutation_plan_available": mutation_plan.is_some(),
"mutation_plan": mutation_plan,
});
let exit_code = if plan.has_blocking_issues() {
2
} else if has_changes {
1
} else {
0
};
let result = if plan.has_blocking_issues() {
"failed"
} else if has_changes {
"pending_changes"
} else {
"success"
};
let output_json = crate::output::machine_json_envelope("unify", "check", result, exit_code, payload);
let rendered =
serde_json::to_string_pretty(&output_json).map_err(|e| RailError::message(format!("JSON error: {}", e)))?;
write_output(&rendered, output)?;
if plan.has_blocking_issues() {
return Err(RailError::message("blocking issues prevent unification"));
}
if has_changes {
return Err(RailError::CheckHasPendingChanges);
}
return Ok(());
}
let mut sink = UnifyTextSink::new(output.is_some());
write_compact_summary(&mut sink, &plan, ctx.workspace_root(), msrv_write_needed, has_changes);
if explain {
display_explain(&mut sink, &plan);
}
if show_diff && has_changes {
outln!(sink);
outln!(sink, "planned changes:");
outln!(sink);
if let Some(msrv) = plan.computed_msrv.as_ref()
&& msrv_write_needed
{
outln!(sink, "[workspace.package]:");
outln!(
sink,
" rust-version = \"{}.{}.{}\"",
msrv.version.major,
msrv.version.minor,
msrv.version.patch
);
outln!(sink);
}
if !plan.workspace_deps.is_empty() || !plan.transitive_pins.is_empty() {
outln!(sink, "[workspace.dependencies]:");
for dep in &plan.workspace_deps {
outln!(sink, " + {} = \"{}\"", dep.name, dep.version_req);
if !dep.features.is_empty() {
let mut features = dep.features.clone();
features.sort();
outln!(sink, " features = {}", format_quoted_list(&features));
}
}
for pin in &plan.transitive_pins {
outln!(sink, " + {} = \"{}\" # transitive pin", pin.name, pin.version);
if !pin.features.is_empty() {
let mut features = pin.features.clone();
features.sort();
outln!(sink, " features = {}", format_quoted_list(&features));
}
}
outln!(sink);
}
let mut members: Vec<_> = plan.member_edits.keys().collect();
members.sort();
for member in members {
let edits = &plan.member_edits[member];
if edits.is_empty() {
continue;
}
let path = plan
.member_paths
.get(member)
.map(|p| p.display().to_string())
.unwrap_or_else(|| member.to_string());
outln!(sink, "{}:", path);
for edit in edits {
match edit {
crate::cargo::MemberEdit::UseWorkspace {
dep_name,
dep_kind,
target,
local_features,
is_optional,
} => {
let section = dependency_section(dep_kind, target.as_deref());
let mut line = String::with_capacity(64 + section.len() + dep_name.len());
let _ = write!(line, " {} {} -> workspace = true", section, dep_name);
if !local_features.is_empty() {
let mut features = local_features.clone();
features.sort();
let _ = write!(line, ", features = {}", format_quoted_list(&features));
}
if *is_optional {
line.push_str(", optional = true");
}
outln!(sink, "{}", line);
}
crate::cargo::MemberEdit::RemoveDep {
dep_name,
dep_kind,
target,
} => {
let section = dependency_section(dep_kind, target.as_deref());
outln!(sink, " {} {} -> REMOVE (unused)", section, dep_name);
}
crate::cargo::MemberEdit::RemoveFeature { feature_name } => {
outln!(sink, " [features] {} -> REMOVE (dead/empty)", feature_name);
}
crate::cargo::MemberEdit::AddFeatures {
dep_name,
dep_kind,
target,
features_to_add,
} => {
let section = dependency_section(dep_kind, target.as_deref());
let mut sorted_features = features_to_add.clone();
sorted_features.sort();
outln!(
sink,
" {} {} -> ADD features {}",
section,
dep_name,
format_quoted_list(&sorted_features)
);
}
crate::cargo::MemberEdit::EnforceMsrvInheritance => {
outln!(sink, " [package] rust-version = {{ workspace = true }}");
}
}
}
outln!(sink);
}
}
let failed_validations: Vec<_> = plan.validation_results.iter().filter(|v| !v.success).collect();
if !failed_validations.is_empty() {
eprintln!();
crate::error!("validation errors:");
for val in failed_validations {
eprintln!(" {}: {}", val.target, val.error.as_deref().unwrap_or("unknown"));
}
}
if plan.has_blocking_issues() {
eprintln!();
crate::error!("blocking issues prevent unification");
if let Some(content) = sink.finish() {
write_output(&content, output)?;
}
return Err(RailError::message("blocking issues prevent unification"));
} else if has_changes {
if let Some(content) = sink.finish() {
write_output(&content, output)?;
}
return Err(RailError::CheckHasPendingChanges);
} else {
outln!(sink);
outln!(sink, "status: no changes");
}
if let Some(content) = sink.finish() {
write_output(&content, output)?;
}
Ok(())
}
pub fn run_unify_apply(
ctx: &WorkspaceContext,
backup: bool,
no_report: bool,
report_path: Option<std::path::PathBuf>,
plan_path: Option<std::path::PathBuf>,
) -> RailResult<()> {
use crate::backup::{BackupManager, BackupMetadata};
use std::path::PathBuf;
let analyzer = UnifyAnalyzer::new(ctx)?;
let plan = analyzer.analyze()?;
let msrv_write_needed = if let Some(msrv) = plan.computed_msrv.as_ref() {
workspace_msrv_write_needed(ctx.workspace_root(), &msrv.version)?
} else {
false
};
if plan.has_blocking_issues() {
crate::error!("blocking issues prevent unification:");
for issue in &plan.issues {
if issue.severity == crate::cargo::IssueSeverity::Error {
eprintln!(" {}: {}", issue.dep_name, issue.message);
}
}
return Err(crate::error::RailError::message("blocking issues prevent unification"));
}
if !plan.has_planned_changes(msrv_write_needed) {
println!("nothing to unify");
return Ok(());
}
let expected_mutation_plan =
build_unify_mutation_plan(ctx, &plan, msrv_write_needed, backup, no_report, report_path.as_ref())?;
let mutation_plan = if let Some(path) = plan_path.as_ref() {
let from_file = mutation::read_plan_file(path)?;
if from_file.contract_version != mutation::MUTATION_CONTRACT_VERSION {
return Err(RailError::with_help(
format!(
"unsupported mutation plan contract version: {} (expected {})",
from_file.contract_version,
mutation::MUTATION_CONTRACT_VERSION
),
"regenerate the plan using the current cargo-rail version".to_string(),
));
}
if !from_file.operation_id.starts_with("unify-") {
return Err(RailError::with_help(
format!("plan '{}' is not a unify plan", path.display()),
"use a plan generated from 'cargo rail unify --check -f json'".to_string(),
));
}
mutation::validate_pre_apply(ctx, &from_file)?;
if from_file.inputs_fingerprint != expected_mutation_plan.inputs_fingerprint {
return Err(RailError::with_help(
"provided plan does not match current requested unify operation".to_string(),
"regenerate the plan and re-run apply with --plan".to_string(),
));
}
from_file
} else {
mutation::validate_pre_apply(ctx, &expected_mutation_plan)?;
expected_mutation_plan
};
let plan_receipt = mutation::write_receipt(
ctx.workspace_root(),
"unify",
"plan",
"planned",
mutation_plan.clone(),
vec![MutationTrace::new(
"UNIFY_PLAN_CREATED",
"created deterministic unify mutation plan",
)],
)?;
progress!("receipt: {}", plan_receipt.display());
let backup_manager = BackupManager::new(ctx.workspace_root());
let is_first_run = !backup_manager.has_backups();
let should_backup = backup || is_first_run;
let mut created_backup_id: Option<String> = None;
if should_backup {
if is_first_run && !backup {
progress!("creating backup (first run)...");
} else {
progress!("creating backup...");
}
let mut files_to_backup = Vec::with_capacity(1 + plan.member_edits.len());
if !plan.workspace_deps.is_empty() || !plan.transitive_pins.is_empty() || msrv_write_needed {
files_to_backup.push(PathBuf::from("Cargo.toml"));
}
if !plan.transitive_pins.is_empty() {
let host_path = transitive_pins_host_manifest_path(ctx)?;
if let Ok(rel_path) = host_path.strip_prefix(ctx.workspace_root()) {
if rel_path != std::path::Path::new("Cargo.toml") {
files_to_backup.push(rel_path.to_path_buf());
}
}
}
for member_name in plan.member_edits.keys() {
if let Some(manifest_path) = plan.member_paths.get(member_name)
&& let Ok(rel_path) = manifest_path.strip_prefix(ctx.workspace_root())
{
files_to_backup.push(rel_path.to_path_buf());
}
}
let metadata = BackupMetadata::new("cargo rail unify");
let max_backups = ctx.config.as_ref().map(|c| c.unify.max_backups).unwrap_or(3);
let backup_id = backup_manager.create_backup(&files_to_backup, metadata, max_backups)?;
progress!("backup: {}", backup_id);
created_backup_id = Some(backup_id);
}
let sort_mode = ctx.config.as_ref().map(|c| c.unify.sort_dependencies).unwrap_or(true);
let writer = ManifestWriter::new().with_dependency_sort(sort_mode);
if !plan.workspace_deps.is_empty() {
progress!("writing [workspace.dependencies]...");
writer.write_workspace_deps(&ctx.workspace_root().join("Cargo.toml"), &plan.workspace_deps)?;
}
progress!("updating {} members...", plan.member_edits.len());
for (member_name, edits) in &plan.member_edits {
let member_path = plan
.member_paths
.get(member_name)
.ok_or_else(|| crate::error::RailError::message(format!("member path not found: {}", member_name)))?;
for edit in edits {
match edit {
crate::cargo::MemberEdit::UseWorkspace {
dep_name,
dep_kind,
target,
local_features,
is_optional,
} => {
writer.update_member(
member_path,
dep_name,
*dep_kind,
target.as_deref(),
if local_features.is_empty() {
None
} else {
Some(local_features.as_slice())
},
*is_optional,
)?;
}
crate::cargo::MemberEdit::RemoveDep {
dep_name,
dep_kind,
target,
} => {
writer.remove_dep(member_path, dep_name, *dep_kind, target.as_deref())?;
}
crate::cargo::MemberEdit::RemoveFeature { feature_name } => {
writer.remove_feature(member_path, feature_name)?;
}
crate::cargo::MemberEdit::AddFeatures {
dep_name,
dep_kind,
target,
features_to_add,
} => {
writer.add_features(member_path, dep_name, *dep_kind, target.as_deref(), features_to_add)?;
}
crate::cargo::MemberEdit::EnforceMsrvInheritance => {
writer.enforce_member_msrv_inheritance(member_path)?;
}
}
}
}
if !plan.transitive_pins.is_empty() {
progress!("pinning {} transitives...", plan.transitive_pins.len());
progress!(" adding to [workspace.dependencies]...");
writer.write_transitive_workspace_deps(&ctx.workspace_root().join("Cargo.toml"), &plan.transitive_pins)?;
let host_path = transitive_pins_host_manifest_path(ctx)?;
let host_dir = host_path.parent().unwrap_or(&host_path);
let relative_path = host_dir.strip_prefix(ctx.workspace_root()).unwrap_or(host_dir);
if relative_path != std::path::Path::new("") && host_path != ctx.workspace_root().join("Cargo.toml") {
progress!(" host: {}", relative_path.display());
}
writer.add_transitive_pins(&host_path, &plan.transitive_pins)?;
}
if let Some(ref msrv) = plan.computed_msrv {
if let Some(ref warning) = msrv.warning {
crate::warn!("{}", warning);
}
if msrv_write_needed {
progress!(
"writing rust-version = \"{}.{}.{}\"...",
msrv.version.major,
msrv.version.minor,
msrv.version.patch
);
writer.write_workspace_msrv(&ctx.workspace_root().join("Cargo.toml"), &msrv.version)?;
}
}
if !no_report {
let actual_report_path = report_path.unwrap_or_else(|| {
ctx
.workspace_root()
.join("target")
.join("cargo-rail")
.join("unify-report.md")
});
UnifyReport::write_to_file(&plan, &actual_report_path)?;
progress!("report: {}", actual_report_path.display());
}
println!(
"\nunified {} dependencies across {} members",
plan.workspace_deps.len(),
plan.member_edits.len()
);
if !plan.transitive_pins.is_empty() {
println!(" {} transitives pinned", plan.transitive_pins.len());
}
if !plan.duplicates_cleaned.is_empty() {
println!(" {} duplicates resolved", plan.duplicates_cleaned.len());
}
if !plan.pruned_features.is_empty() {
println!(" {} dead features pruned (empty no-ops)", plan.pruned_features.len());
}
if !plan.optional_features.is_empty() {
println!(
" {} optional features detected (user-facing, preserved)",
plan.optional_features.len()
);
}
let features_fixed: usize = plan
.member_edits
.values()
.flat_map(|edits| edits.iter())
.filter_map(|e| match e {
crate::cargo::MemberEdit::AddFeatures { features_to_add, .. } => Some(features_to_add.len()),
_ => None,
})
.sum();
if features_fixed > 0 {
let crates_fixed: std::collections::HashSet<_> = plan
.member_edits
.iter()
.filter(|(_, edits)| {
edits
.iter()
.any(|e| matches!(e, crate::cargo::MemberEdit::AddFeatures { .. }))
})
.map(|(name, _)| name)
.collect();
println!(
" {} undeclared features fixed across {} crates",
features_fixed,
crates_fixed.len()
);
}
if let Some(ref msrv) = plan.computed_msrv {
use crate::cargo::MsrvSourceUsed;
let source_desc = match msrv.source_used {
MsrvSourceUsed::Deps => format!(
"from deps: {}",
msrv.contributors.first().unwrap_or(&"unknown".to_string())
),
MsrvSourceUsed::Workspace => "preserved from workspace".to_string(),
MsrvSourceUsed::MaxWorkspace => "from workspace (higher than deps)".to_string(),
MsrvSourceUsed::MaxDeps => format!(
"from deps: {}",
msrv.contributors.first().unwrap_or(&"unknown".to_string())
),
};
println!(
" rust-version = {}.{}.{} ({})",
msrv.version.major, msrv.version.minor, msrv.version.patch, source_desc
);
}
println!("\nnext: cargo check && cargo test");
if let Some(backup_id) = created_backup_id {
println!("undo: cargo rail unify undo (backup: {})", backup_id);
}
let apply_receipt = mutation::write_receipt(
ctx.workspace_root(),
"unify",
"apply",
"applied",
mutation_plan,
vec![
MutationTrace::new("UNIFY_APPLY_STARTED", "started applying unify plan"),
MutationTrace::new("UNIFY_APPLY_COMPLETED", "completed unify apply"),
],
)?;
progress!("receipt: {}", apply_receipt.display());
Ok(())
}
pub fn run_unify_undo(workspace_root: &std::path::Path, list: bool, backup_id: Option<String>) -> RailResult<()> {
use crate::backup::BackupManager;
let backup_manager = BackupManager::new(workspace_root);
if list {
let backups = backup_manager.list_backups()?;
if backups.is_empty() {
println!("no backups found");
return Ok(());
}
println!("backups:\n");
for (i, backup) in backups.iter().enumerate() {
let marker = if i == 0 { " (latest)" } else { "" };
println!(" {}{}", backup.id, marker);
println!(" {}", backup.metadata.timestamp);
println!(" {} files", backup.metadata.files_modified.len());
}
println!("\nrestore with: cargo rail unify undo [--backup-id <id>]");
return Ok(());
}
let target_backup_id = if let Some(id) = backup_id {
id
} else {
match backup_manager.get_latest_backup()? {
Some(backup) => backup.id,
None => {
return Err(crate::error::RailError::with_help(
"no backups found",
"run 'cargo rail unify undo --list' to see available backups",
));
}
}
};
backup_manager.restore_backup(&target_backup_id)?;
Ok(())
}
fn display_explain(sink: &mut UnifyTextSink, plan: &crate::cargo::UnificationPlan) {
use std::collections::BTreeMap;
outln!(sink);
outln!(sink, "=== Explanation ===");
outln!(sink);
if !plan.dependency_decisions.is_empty() {
outln!(sink, "Dependency decisions:");
outln!(sink);
for decision in &plan.dependency_decisions {
match (&decision.member, &decision.target) {
(Some(member), Some(target)) => outln!(
sink,
" {} [{}:{} @ {}]",
decision.dep_name,
decision.subject.as_str(),
member,
target
),
(Some(member), None) => outln!(
sink,
" {} [{}:{}]",
decision.dep_name,
decision.subject.as_str(),
member
),
(None, _) => outln!(sink, " {} [{}]", decision.dep_name, decision.subject.as_str()),
}
for reason in &decision.reasons {
outln!(sink, " - {}: {}", reason.code.as_str(), reason.summary);
if !reason.features.is_empty() {
outln!(sink, " features: {}", format_preview_list(&reason.features, 10));
}
if !reason.members.is_empty() {
outln!(sink, " members: {}", format_preview_list(&reason.members, 10));
}
if !reason.borrowed_from.is_empty() {
outln!(
sink,
" borrowed from: {}",
format_preview_list(&reason.borrowed_from, 10)
);
}
}
outln!(sink);
}
}
if !plan.unused_deps.is_empty() {
outln!(sink, "Unused dependencies flagged for removal:");
outln!(sink);
for unused in &plan.unused_deps {
outln!(sink, " {} in {}", unused.dep_name, unused.member);
outln!(sink, " reason: {:?}", unused.reason);
outln!(sink);
}
}
if !plan.pruned_features.is_empty() {
outln!(sink, "Dead features pruned:");
outln!(sink);
for pruned in &plan.pruned_features {
outln!(sink, " [features].{} in {}", pruned.feature_name, pruned.crate_name);
outln!(sink, " reason: empty feature (no dependencies, no sub-features)");
outln!(sink);
}
}
if !plan.issues.is_empty() {
outln!(sink, "Issues detected:");
outln!(sink);
let mut by_severity: BTreeMap<String, Vec<_>> = BTreeMap::new();
for issue in &plan.issues {
let severity = format!("{:?}", issue.severity);
by_severity.entry(severity).or_default().push(issue);
}
for (severity, issues) in &by_severity {
outln!(sink, " {} ({}):", severity, issues.len());
for issue in issues {
outln!(sink, " {}: {}", issue.dep_name, issue.message);
}
outln!(sink);
}
}
let has_feature_fixes = plan
.dependency_decisions
.iter()
.any(|decision| decision.subject == crate::cargo::UnifyDecisionSubject::UndeclaredFeatureFix);
if !plan.undeclared_features.is_empty() && !has_feature_fixes {
outln!(sink, "Undeclared features detected:");
outln!(sink);
for uf in &plan.undeclared_features {
outln!(sink, " {} in {}", uf.dep_name, uf.member);
outln!(sink, " undeclared: [{}]", uf.undeclared_features.join(", "));
if !uf.borrowed_from.is_empty() {
outln!(sink, " borrowed from: {}", uf.borrowed_from.join(", "));
}
outln!(
sink,
" reason: features enabled via resolver but not declared in Cargo.toml"
);
outln!(sink);
}
}
if plan.workspace_deps.is_empty()
&& plan.member_edits.is_empty()
&& plan.transitive_pins.is_empty()
&& plan.unused_deps.is_empty()
{
outln!(sink, "No unification opportunities found.");
outln!(sink);
outln!(sink, "Possible reasons:");
outln!(sink, " - Dependencies are already unified");
outln!(sink, " - Dependencies have incompatible versions (see issues above)");
outln!(sink, " - Dependencies are excluded via [unify].exclude config");
outln!(sink, " - Dependencies are renamed (use include_renamed = true)");
outln!(sink, " - Single-use dependencies (not shared across crates)");
}
}
fn is_virtual_workspace(workspace_root: &std::path::Path) -> bool {
use std::fs;
let root_manifest = workspace_root.join("Cargo.toml");
let Ok(content) = fs::read_to_string(&root_manifest) else {
return false;
};
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return false;
};
doc.contains_key("workspace") && !doc.contains_key("package")
}
fn workspace_msrv_write_needed(workspace_root: &std::path::Path, msrv: &semver::Version) -> RailResult<bool> {
use std::fs;
let root_manifest = workspace_root.join("Cargo.toml");
let Ok(content) = fs::read_to_string(&root_manifest) else {
return Ok(true);
};
let Ok(doc) = content.parse::<toml_edit::DocumentMut>() else {
return Ok(true);
};
let desired = format!("{}.{}.{}", msrv.major, msrv.minor, msrv.patch);
let current = doc
.get("workspace")
.and_then(|ws| ws.get("package"))
.and_then(|pkg| pkg.get("rust-version"))
.and_then(|v| v.as_str());
Ok(current != Some(desired.as_str()))
}
fn transitive_pins_host_manifest_path(ctx: &WorkspaceContext) -> RailResult<std::path::PathBuf> {
let transitive_host_setting = ctx.config.as_ref().map(|c| &c.unify.transitive_host);
let is_root_host = matches!(
transitive_host_setting,
None | Some(crate::config::TransitiveFeatureHost::Root)
);
if is_root_host && is_virtual_workspace(ctx.workspace_root()) {
let members = ctx.graph.workspace_members();
if members.is_empty() {
return Err(RailError::with_help(
"transitive_host = \"root\" is incompatible with virtual workspaces".to_string(),
"Virtual workspaces cannot have [dev-dependencies]. Set transitive_host to a workspace member path in your rail.toml:\n \
[unify]\n \
transitive_host = \"crates/some-crate\""
.to_string(),
));
}
let first_member = &members[0];
if let Some(pkg) = ctx.cargo.get_package(first_member) {
let member_path = pkg
.manifest_path
.parent()
.ok_or_else(|| RailError::message(format!("Invalid manifest path: {}", pkg.manifest_path)))?;
return Ok(member_path.join("Cargo.toml").into_std_path_buf());
}
return Err(RailError::message("Failed to find a suitable transitive host member"));
}
Ok(match transitive_host_setting {
Some(crate::config::TransitiveFeatureHost::Path(p)) => ctx.workspace_root().join(p).join("Cargo.toml"),
_ => ctx.workspace_root().join("Cargo.toml"),
})
}
fn build_unify_mutation_plan(
ctx: &WorkspaceContext,
plan: &crate::cargo::UnificationPlan,
msrv_write_needed: bool,
backup_enabled: bool,
no_report: bool,
report_path: Option<&std::path::PathBuf>,
) -> RailResult<mutation::MutationPlan> {
let (actions, risks, trace) =
build_unify_mutation_parts(plan, msrv_write_needed, backup_enabled, no_report, report_path);
mutation::build_plan(ctx, "unify", actions, risks, trace)
}
fn build_unify_mutation_parts(
plan: &crate::cargo::UnificationPlan,
msrv_write_needed: bool,
backup_enabled: bool,
no_report: bool,
report_path: Option<&std::path::PathBuf>,
) -> (Vec<MutationAction>, Vec<MutationRisk>, Vec<MutationTrace>) {
let mut actions = Vec::with_capacity(8); let mut risks = Vec::with_capacity(4);
let mut trace = Vec::with_capacity(8);
if !plan.workspace_deps.is_empty() {
actions.push(MutationAction::new(
"WRITE_WORKSPACE_DEPS",
"Cargo.toml:[workspace.dependencies]",
Some(format!("{} dependencies", plan.workspace_deps.len())),
));
}
if !plan.member_edits.is_empty() {
actions.push(MutationAction::new(
"APPLY_MEMBER_EDITS",
"workspace member manifests",
Some(format!("{} member(s)", plan.member_edits.len())),
));
}
if !plan.transitive_pins.is_empty() {
actions.push(MutationAction::new(
"APPLY_TRANSITIVE_PINS",
"transitive host manifest",
Some(format!("{} pinned dependencies", plan.transitive_pins.len())),
));
}
if msrv_write_needed {
actions.push(MutationAction::new(
"WRITE_WORKSPACE_MSRV",
"Cargo.toml:[workspace.package.rust-version]",
None,
));
}
if backup_enabled {
actions.push(MutationAction::new("CREATE_BACKUP", "target/cargo-rail/backups", None));
}
if !no_report {
let target = report_path
.map(|p| p.display().to_string())
.unwrap_or_else(|| "target/cargo-rail/unify-report.md".to_string());
actions.push(MutationAction::new("WRITE_REPORT", target, None));
}
let error_count = plan
.issues
.iter()
.filter(|issue| issue.severity == crate::cargo::IssueSeverity::Error)
.count();
if error_count > 0 {
risks.push(MutationRisk::new(
"BLOCKING_ISSUES",
"high",
format!("{} blocking issue(s) detected", error_count),
));
}
if !plan.transitive_pins.is_empty() {
risks.push(MutationRisk::new(
"TRANSITIVE_PIN_SIDE_EFFECTS",
"medium",
"transitive pinning mutates host dev-dependencies",
));
}
trace.push(MutationTrace::new(
"UNIFY_ANALYSIS_COMPLETE",
format!(
"planned {} workspace dep(s), {} member edit(s), {} transitive pin(s)",
plan.workspace_deps.len(),
plan.member_edit_count(),
plan.transitive_pins.len()
),
));
if !plan.workspace_deps.is_empty() {
trace.push(MutationTrace::new(
"UNIFY_VERSION_DECISIONS",
format!(
"resolved versions for {} workspace dependency entries",
plan.workspace_deps.len()
),
));
}
let feature_edit_count: usize = plan
.member_edits
.values()
.flat_map(|edits| edits.iter())
.filter(|edit| {
matches!(
edit,
crate::cargo::MemberEdit::AddFeatures { .. } | crate::cargo::MemberEdit::RemoveFeature { .. }
)
})
.count();
if feature_edit_count > 0 {
trace.push(MutationTrace::new(
"UNIFY_FEATURE_DECISIONS",
format!("planned {} feature-level member edit(s)", feature_edit_count),
));
}
if msrv_write_needed || plan.computed_msrv.is_some() {
trace.push(MutationTrace::new(
"UNIFY_MSRV_DECISIONS",
format!(
"msrv evaluated: computed={}, write_needed={}",
plan.computed_msrv.is_some(),
msrv_write_needed
),
));
}
if !plan.transitive_pins.is_empty() {
trace.push(MutationTrace::new(
"UNIFY_TRANSITIVE_DECISIONS",
format!("planned {} transitive pin decision(s)", plan.transitive_pins.len()),
));
}
(actions, risks, trace)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cargo::{
DepKind, UnificationPlan, UnifyDecision, UnifyDecisionCode, UnifyDecisionReason, UnifyDecisionSubject,
};
use rustc_hash::FxHashMap;
use std::sync::Arc;
fn arc(value: &str) -> Arc<str> {
Arc::from(value)
}
fn empty_plan() -> UnificationPlan {
UnificationPlan {
workspace_deps: Vec::new(),
member_edits: FxHashMap::default(),
member_paths: FxHashMap::default(),
transitive_pins: Vec::new(),
validation_results: Vec::new(),
issues: Vec::new(),
computed_msrv: None,
duplicates_cleaned: Vec::new(),
pruned_features: Vec::new(),
optional_features: Vec::new(),
version_mismatches: Vec::new(),
unused_deps: Vec::new(),
undeclared_features: Vec::new(),
dependency_decisions: Vec::new(),
}
}
#[test]
fn test_dependency_decisions_to_json_includes_reason_codes() {
let mut plan = empty_plan();
plan.dependency_decisions.push(UnifyDecision {
dep_name: arc("tokio"),
subject: UnifyDecisionSubject::UndeclaredFeatureFix,
member: Some(arc("crate-b")),
target: Some(arc("cfg(unix)")),
reasons: vec![UnifyDecisionReason {
code: UnifyDecisionCode::UndeclaredFeatureFix,
summary: arc("Added missing features locally."),
features: vec![arc("macros")],
members: vec![arc("crate-b")],
borrowed_from: vec![arc("crate-a")],
}],
});
let json = dependency_decisions_to_json(&plan);
assert_eq!(json.len(), 1);
assert_eq!(json[0]["dep_name"], "tokio");
assert_eq!(json[0]["subject"], "undeclared_feature_fix");
assert_eq!(json[0]["member"], "crate-b");
assert_eq!(json[0]["target"], "cfg(unix)");
assert_eq!(json[0]["reasons"][0]["code"], "undeclared_feature_fix");
assert_eq!(json[0]["reasons"][0]["features"][0], "macros");
assert_eq!(json[0]["reasons"][0]["borrowed_from"][0], "crate-a");
}
#[test]
fn test_display_explain_renders_dependency_decisions() {
let mut plan = empty_plan();
plan.dependency_decisions.extend([
UnifyDecision {
dep_name: arc("serde"),
subject: UnifyDecisionSubject::WorkspaceDependency,
member: None,
target: None,
reasons: vec![
UnifyDecisionReason {
code: UnifyDecisionCode::FeatureIntersection,
summary: arc("Used intersection to keep only shared features."),
features: vec![arc("derive")],
members: vec![arc("crate-a"), arc("crate-b")],
borrowed_from: Vec::new(),
},
UnifyDecisionReason {
code: UnifyDecisionCode::ExactPinWarnCaret,
summary: arc("Converted exact pin to ^1.0.200."),
features: Vec::new(),
members: vec![arc("crate-a"), arc("crate-b")],
borrowed_from: Vec::new(),
},
],
},
UnifyDecision {
dep_name: arc("tokio"),
subject: UnifyDecisionSubject::WorkspaceDependency,
member: None,
target: None,
reasons: vec![UnifyDecisionReason {
code: UnifyDecisionCode::CohortEnforced,
summary: arc("Enforced atomic workspace-member cohort."),
features: Vec::new(),
members: vec![arc("tokio"), arc("tokio-stream"), arc("tokio-util")],
borrowed_from: Vec::new(),
}],
},
UnifyDecision {
dep_name: arc("windows-sys"),
subject: UnifyDecisionSubject::TransitivePin,
member: None,
target: None,
reasons: vec![UnifyDecisionReason {
code: UnifyDecisionCode::TransitivePin,
summary: arc("Pinned transitively to stabilize target-specific feature resolution."),
features: vec![arc("Win32_Foundation")],
members: Vec::new(),
borrowed_from: Vec::new(),
}],
},
UnifyDecision {
dep_name: arc("tokio"),
subject: UnifyDecisionSubject::UndeclaredFeatureFix,
member: Some(arc("crate-c")),
target: None,
reasons: vec![UnifyDecisionReason {
code: UnifyDecisionCode::UndeclaredFeatureFix,
summary: arc("Added missing features to stop borrowed resolver state."),
features: vec![arc("macros")],
members: vec![arc("crate-c")],
borrowed_from: vec![arc("crate-a")],
}],
},
]);
plan.member_edits.insert(
arc("crate-c"),
vec![crate::cargo::MemberEdit::AddFeatures {
dep_name: arc("tokio"),
dep_kind: DepKind::Normal,
target: None,
features_to_add: vec![arc("macros")],
}],
);
let mut sink = UnifyTextSink::new(true);
display_explain(&mut sink, &plan);
let output = sink.finish().expect("captured output");
assert!(output.contains("Dependency decisions:"));
assert!(output.contains("intersection: Used intersection to keep only shared features."));
assert!(output.contains("exact_pin_warn_caret: Converted exact pin to ^1.0.200."));
assert!(output.contains("cohort_enforced: Enforced atomic workspace-member cohort."));
assert!(output.contains("transitive_pin: Pinned transitively to stabilize target-specific feature resolution."));
assert!(output.contains("undeclared_feature_fix: Added missing features to stop borrowed resolver state."));
assert!(output.contains("borrowed from: crate-a"));
}
}