use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run_capture};
use crate::release::{self, ReleaseError};
const CHANGES_DIR: &str = "changes";
const TRANSACTIONS_DIR: &str = "release/transactions";
pub(crate) fn run_prepare(format: OutputFormat, dry_run: bool) -> 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: 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(Path::new(CHANGES_DIR)).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 = resolve_transaction_id(&today)?;
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)))?;
if plan.is_empty() {
match format {
OutputFormat::Human => {
println!("No change fragments declare a bump. Nothing to prepare.");
}
OutputFormat::Json => {
println!(
"{}",
serde_json::json!({
"prepared": false,
"reason": "no change fragments",
"transaction_id": transaction_id,
"commit": commit_sha,
})
);
}
}
return Ok(());
}
let plan_toml = release::serialize_plan(&plan);
let plan_path = format!("{TRANSACTIONS_DIR}/{transaction_id}.toml");
let mut edits: Vec<ManifestEditRecord> = Vec::new();
for entry in plan.entries.values() {
if entry.from == entry.to {
continue;
}
let manifest_path = find_manifest_path(&document, &entry.crate_name);
let manifest_path = match manifest_path {
Some(p) => p,
None => {
return Err(CommandError::Release(ReleaseError::Validation(vec![
release::Diagnostic {
crate_name: entry.crate_name.clone(),
message: "manifest path not found in cargo metadata".to_string(),
},
])));
}
};
let target = entry.to.to_string();
if dry_run {
edits.push(ManifestEditRecord {
crate_name: entry.crate_name.clone(),
path: manifest_path.display().to_string(),
from: entry.from.to_string(),
to: target.clone(),
kind: "version".to_string(),
});
} else {
let manifest_text =
std::fs::read_to_string(&manifest_path).map_err(ReleaseError::from)?;
let result = release::edit_package_version(&manifest_text, &target).map_err(|e| {
ReleaseError::Validation(vec![release::Diagnostic {
crate_name: entry.crate_name.clone(),
message: format!("{e}"),
}])
})?;
if result.changed {
std::fs::write(&manifest_path, &result.text).map_err(ReleaseError::from)?;
}
edits.push(ManifestEditRecord {
crate_name: entry.crate_name.clone(),
path: manifest_path.display().to_string(),
from: entry.from.to_string(),
to: target.clone(),
kind: "version".to_string(),
});
}
if entry.unit == "core" && entry.crate_name == "arcature" {
if dry_run {
edits.push(ManifestEditRecord {
crate_name: entry.crate_name.clone(),
path: manifest_path.display().to_string(),
from: format!("={}", entry.from),
to: format!("={}", target),
kind: "dependency".to_string(),
});
} else {
let manifest_text =
std::fs::read_to_string(&manifest_path).map_err(ReleaseError::from)?;
let result =
release::edit_dependency_version(&manifest_text, "arcature-dx", &target)
.map_err(|e| {
ReleaseError::Validation(vec![release::Diagnostic {
crate_name: entry.crate_name.clone(),
message: format!("{e}"),
}])
})?;
if result.changed {
std::fs::write(&manifest_path, &result.text).map_err(ReleaseError::from)?;
}
edits.push(ManifestEditRecord {
crate_name: entry.crate_name.clone(),
path: manifest_path.display().to_string(),
from: format!("={}", entry.from),
to: format!("={}", target),
kind: "dependency".to_string(),
});
}
}
}
if !dry_run {
std::fs::create_dir_all(TRANSACTIONS_DIR).map_err(ReleaseError::from)?;
std::fs::write(&plan_path, &plan_toml).map_err(ReleaseError::from)?;
}
let archive_set = release::compute_archive_set(&fragments, &transaction_id);
if !dry_run && !archive_set.sources.is_empty() {
std::fs::create_dir_all(&archive_set.destination).map_err(ReleaseError::from)?;
for source in &archive_set.sources {
let source_path = Path::new(source);
let file_name = source_path.file_name().ok_or_else(|| {
CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
crate_name: "changes".to_string(),
message: format!("cannot extract file name from {source}"),
}]))
})?;
let dest = Path::new(&archive_set.destination).join(file_name);
if source_path.exists() {
std::fs::rename(source_path, &dest).map_err(ReleaseError::from)?;
}
}
}
match format {
OutputFormat::Human => print_human(&plan, &plan_path, &edits, &archive_set, dry_run),
OutputFormat::Json => print_json(&plan, &plan_path, &edits, &archive_set, dry_run),
}
}
#[derive(Clone)]
struct ManifestEditRecord {
crate_name: String,
path: String,
from: String,
to: String,
kind: String,
}
fn print_human(
plan: &release::ReleasePlan,
plan_path: &str,
edits: &[ManifestEditRecord],
archive_set: &release::ArchiveSet,
dry_run: bool,
) -> Result<(), CommandError> {
let mode = if dry_run { "DRY RUN โ " } else { "" };
println!("{mode}Release Plan prepared: {plan_path}");
println!(" transaction: {}", plan.transaction_id);
println!(" commit: {}", plan.commit);
println!(" changed: {} crate(s)", plan.changed_count);
println!();
if !edits.is_empty() {
println!("Manifest edits:");
for edit in edits {
println!(
" {kind:>11} {name:<20} {from} โ {to} ({path})",
kind = edit.kind,
name = edit.crate_name,
from = edit.from,
to = edit.to,
path = edit.path,
);
}
}
if !archive_set.sources.is_empty() {
println!();
println!("Archived fragments โ {}", archive_set.destination);
for source in &archive_set.sources {
println!(" {source}");
}
}
if !dry_run {
println!();
println!("Review the changes and commit:");
println!(
" git add -A && git commit -m \"release: prepare transaction {}\"",
plan.transaction_id
);
}
Ok(())
}
fn print_json(
plan: &release::ReleasePlan,
plan_path: &str,
edits: &[ManifestEditRecord],
archive_set: &release::ArchiveSet,
dry_run: bool,
) -> Result<(), CommandError> {
let payload = serde_json::json!({
"prepared": true,
"dry_run": dry_run,
"plan_path": plan_path,
"transaction_id": plan.transaction_id,
"commit": plan.commit,
"changed_count": plan.changed_count,
"edits": edits.iter().map(|e| serde_json::json!({
"crate": e.crate_name,
"path": e.path,
"from": e.from,
"to": e.to,
"kind": e.kind,
})).collect::<Vec<_>>(),
"archive": {
"destination": archive_set.destination,
"sources": archive_set.sources,
},
});
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
fn find_manifest_path(
document: &crate::release::CargoMetadata,
crate_name: &str,
) -> Option<PathBuf> {
document
.packages
.iter()
.find(|p| p.name == crate_name)
.map(|p| PathBuf::from(&p.manifest_path))
}
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(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(ReleaseError::Validation(vec![
release::Diagnostic {
crate_name: "git".to_string(),
message: format!("unexpected date format: {date}"),
},
])));
}
Ok(date)
}
fn resolve_transaction_id(date: &str) -> Result<String, CommandError> {
let mut suffix = 1;
loop {
let id = format!("{date}.{suffix:02}");
let path = format!("{TRANSACTIONS_DIR}/{id}.toml");
if !Path::new(&path).exists() {
return Ok(id);
}
suffix += 1;
}
}