use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run_capture};
use crate::release::{self, ReleaseError};
pub(crate) fn run_plan(format: OutputFormat) -> Result<(), CommandError> {
let document = release::load_metadata()?;
let crates = release::discover(&document);
release::validate(&crates)
.map_err(|diagnostics| CommandError::Release(ReleaseError::Validation(diagnostics)))?;
let crate_graph = release::build_crate_graph(&document, &crates);
let unit_graph = release::build_unit_graph(&crate_graph);
let range_findings = release::validate_ranges(&document, &crates, &crate_graph);
if !range_findings.is_empty() {
return Err(CommandError::Release(ReleaseError::Validation(
range_findings,
)));
}
let known_units: std::collections::BTreeSet<String> = crates
.iter()
.filter_map(|c| match &c.metadata {
release::CrateMetadata::Valid(md) if md.publish => md.release_unit.clone(),
_ => None,
})
.collect();
let (fragments, parse_errors) = release::load_fragments_files(std::path::Path::new("changes"))
.map_err(ReleaseError::from)?;
if !parse_errors.is_empty() {
let diagnostics = parse_errors
.into_iter()
.map(|(file, error)| release::Diagnostic {
crate_name: "changes".to_string(),
message: format!("{file}: {error}"),
})
.collect();
return Err(CommandError::Release(ReleaseError::Validation(diagnostics)));
}
let change_report =
release::validate_fragments(&fragments, &known_units).map_err(|errors| {
let diagnostics = errors
.into_iter()
.map(|e| release::Diagnostic {
crate_name: "changes".to_string(),
message: e,
})
.collect();
CommandError::Release(ReleaseError::Validation(diagnostics))
})?;
let commit_sha = read_head_sha()?;
let today = today_date_string()?;
let transaction_id = format!("{today}.01");
let plan = release::build_release_plan(
&document,
&crates,
&change_report,
&unit_graph,
&commit_sha,
&transaction_id,
)
.map_err(|plan_err| CommandError::Release(ReleaseError::Validation(plan_err.diagnostics)))?;
match format {
OutputFormat::Human => print_human(&plan),
OutputFormat::Json => print_json(&plan),
}
}
fn print_human(plan: &release::ReleasePlan) -> Result<(), CommandError> {
println!("Release Plan (proposed — not committed)");
println!(" transaction: {}", plan.transaction_id);
println!(" commit: {}", plan.commit);
println!(
" crates: {} total, {} changed",
plan.total_count(),
plan.changed_count
);
println!();
if plan.is_empty() {
println!("No change fragments declare a bump. The plan is a no-op.");
return Ok(());
}
println!("Publish order (topological):");
let mut entries: Vec<&release::PublishEntry> = plan.entries.values().collect();
entries.sort_by_key(|e| (e.order, e.crate_name.clone()));
for entry in entries {
let kind = match entry.change_kind {
release::ChangeKind::None => "none",
release::ChangeKind::Compatible => "compatible",
release::ChangeKind::Breaking => "breaking",
};
let arrow = if entry.from == entry.to {
format!("{}", entry.to)
} else {
format!("{} → {}", entry.from, entry.to)
};
let marker = if entry.from != entry.to { "*" } else { " " };
println!(
" {marker} {order:>2}. {name:<20} {arrow:<20} [{kind}] ({unit})",
order = entry.order,
name = entry.crate_name,
unit = entry.unit,
);
}
Ok(())
}
fn print_json(plan: &release::ReleasePlan) -> Result<(), CommandError> {
let mut entries: Vec<&release::PublishEntry> = plan.entries.values().collect();
entries.sort_by_key(|e| (e.order, e.crate_name.clone()));
let payload = serde_json::json!({
"transaction_id": plan.transaction_id,
"commit": plan.commit,
"total_count": plan.total_count(),
"changed_count": plan.changed_count,
"entries": entries.iter().map(|e| serde_json::json!({
"crate": e.crate_name,
"unit": e.unit,
"from": e.from.to_string(),
"to": e.to.to_string(),
"change_kind": match e.change_kind {
release::ChangeKind::None => "none",
release::ChangeKind::Compatible => "compatible",
release::ChangeKind::Breaking => "breaking",
},
"order": e.order,
"changed": e.from != e.to,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
fn read_head_sha() -> Result<String, CommandError> {
let spec = ProcessSpec::new("git", std::env::current_dir().unwrap_or_default())
.arg("rev-parse")
.arg("HEAD");
let output = run_capture(&spec)?;
let sha = String::from_utf8_lossy(&output).trim().to_string();
if sha.len() != 40 || !sha.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(CommandError::Release(release::ReleaseError::Validation(
vec![release::Diagnostic {
crate_name: "git".to_string(),
message: format!("HEAD is not a valid 40-char sha: {sha}"),
}],
)));
}
Ok(sha)
}
fn today_date_string() -> Result<String, CommandError> {
let spec = ProcessSpec::new("git", std::env::current_dir().unwrap_or_default())
.arg("log")
.arg("-1")
.arg("--format=%cd")
.arg("--date=short");
let output = run_capture(&spec)?;
let date = String::from_utf8_lossy(&output).trim().to_string();
if date.len() != 10 || date.chars().filter(|c| *c == '-').count() != 2 {
return Err(CommandError::Release(release::ReleaseError::Validation(
vec![release::Diagnostic {
crate_name: "git".to_string(),
message: format!("unexpected date format: {date}"),
}],
)));
}
Ok(date)
}