arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc release prepare` โ€” the release prepare step (RV2.7).
//!
//! Materializes a committed Release Plan: applies deterministic TOML
//! edits to per-crate manifests, writes the plan TOML to
//! `release/transactions/<id>.toml`, and archives the consumed change
//! fragments. Prepare is the only write step before publishing; it never
//! publishes (ADR-0005 invariant 9).
//!
//! The operator flow is `arc release plan` โ†’ `arc release prepare`
//! (review + commit) โ†’ `arc release publish` (ADR-0005 Decision ยง7).
//! Prepare writes files to the working tree; the human reviews the diff
//! and commits. The plan is tied to the exact HEAD commit sha.
//!
//! `--dry-run` shows what would happen without writing any files.

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};

/// The directory holding pending change fragments.
const CHANGES_DIR: &str = "changes";

/// The directory for committed Release Plans.
const TRANSACTIONS_DIR: &str = "release/transactions";

/// Run the `arc release prepare` command.
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);

    // YBF range validation (RV2.5).
    let range_findings = release::validate_ranges(&document, &crates, &crate_graph);
    if !range_findings.is_empty() {
        return Err(CommandError::Release(ReleaseError::Validation(
            range_findings,
        )));
    }

    // Change fragments (RV2.3).
    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))
        })?;

    // Commit sha and proposed transaction id.
    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(());
    }

    // Serialize the plan TOML.
    let plan_toml = release::serialize_plan(&plan);
    let plan_path = format!("{TRANSACTIONS_DIR}/{transaction_id}.toml");

    // Apply manifest edits to changed crates.
    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(),
            });
        }

        // For core, also edit the arcature โ†’ arcature-dx exact requirement.
        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(),
                });
            }
        }
    }

    // Write the plan TOML.
    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)?;
    }

    // Archive consumed fragments.
    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(())
}

/// Find a crate's manifest path from the cargo metadata document.
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))
}

/// Read the current HEAD commit sha via `git rev-parse HEAD`.
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)
}

/// Get today's date as `YYYY-MM-DD` via `git log --format=%cd --date=short`.
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)
}

/// Resolve a transaction id for the given date. If transactions already
/// exist for this date, increment the suffix.
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;
    }
}