use std::path::Path;
use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run_capture};
use crate::release::{self, ReleaseError};
const TRANSACTIONS_DIR: &str = "release/transactions";
pub(crate) fn run_publish(format: OutputFormat, dry_run: bool) -> Result<(), CommandError> {
let plan_path = find_latest_plan()?;
let plan = release::load_plan(Path::new(&plan_path)).map_err(|e| {
CommandError::Release(ReleaseError::Validation(vec![release::Diagnostic {
crate_name: "plan".to_string(),
message: format!("{e}"),
}]))
})?;
let head_sha = read_head_sha()?;
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 findings = release::validate_plan(&plan, &head_sha, &crates, &unit_graph);
if !findings.is_empty() {
return Err(CommandError::Release(ReleaseError::Validation(findings)));
}
let steps: Vec<release::PublishStep> = plan
.ordered_entries()
.iter()
.map(|e| release::build_publish_steps(&e.crate_name, &e.version, e.order))
.collect();
match format {
OutputFormat::Human => print_human(&plan, &plan_path, &steps, dry_run),
OutputFormat::Json => print_json(&plan, &plan_path, &steps, dry_run),
}
}
fn print_human(
plan: &release::LoadedPlan,
plan_path: &str,
steps: &[release::PublishStep],
dry_run: bool,
) -> Result<(), CommandError> {
let mode = if dry_run { "DRY RUN — " } else { "" };
println!("{mode}Release Transaction: {}", plan.transaction_id);
println!(" plan: {plan_path}");
println!(" commit: {}", plan.commit);
println!(" crates: {}", plan.entries.len());
println!();
if steps.is_empty() {
println!("No crates to publish (empty plan).");
return Ok(());
}
println!("Publish sequence (topological order):");
for step in steps {
println!(
" {order:>3}. {name:<20} v{version} → tag: {tag}",
order = step.order,
name = step.crate_name,
version = step.version,
tag = step.tag,
);
}
println!();
if dry_run {
println!(
"Dry run: no crates would be published, no tags would be created.\n\
Production publish runs in GitHub Actions (Trusted Publishing)."
);
} else {
println!(
"To execute this transaction, trigger the Release Transaction workflow\n\
at commit {} with transaction id {}.",
plan.commit, plan.transaction_id
);
println!();
println!(
" gh workflow run release-transaction.yml \\\n --ref {}",
plan.commit
);
}
Ok(())
}
fn print_json(
plan: &release::LoadedPlan,
plan_path: &str,
steps: &[release::PublishStep],
dry_run: bool,
) -> Result<(), CommandError> {
let payload = serde_json::json!({
"dry_run": dry_run,
"transaction_id": plan.transaction_id,
"plan_path": plan_path,
"commit": plan.commit,
"crate_count": plan.entries.len(),
"publish_sequence": steps.iter().map(|s| serde_json::json!({
"order": s.order,
"crate": s.crate_name,
"version": s.version.to_string(),
"tag": s.tag,
})).collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&payload)?);
Ok(())
}
fn find_latest_plan() -> Result<String, CommandError> {
let dir = Path::new(TRANSACTIONS_DIR);
if !dir.exists() {
return Err(CommandError::Release(ReleaseError::Validation(vec![
release::Diagnostic {
crate_name: "plan".to_string(),
message: format!(
"no committed Release Plan found in {TRANSACTIONS_DIR}/ — \
run `arc release prepare` first"
),
},
])));
}
let mut plans: Vec<String> = Vec::new();
let entries = std::fs::read_dir(dir).map_err(ReleaseError::from)?;
for entry in entries {
let entry = entry.map_err(ReleaseError::from)?;
let name = entry.file_name();
let name = name.to_string_lossy();
if name.ends_with(".toml") {
plans.push(name.to_string());
}
}
if plans.is_empty() {
return Err(CommandError::Release(ReleaseError::Validation(vec![
release::Diagnostic {
crate_name: "plan".to_string(),
message: format!("no Release Plan TOML found in {TRANSACTIONS_DIR}/"),
},
])));
}
plans.sort();
plans.reverse();
Ok(format!("{TRANSACTIONS_DIR}/{}", plans[0]))
}
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)
}