arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! `arc release plan` — the release planner (RV2.6).
//!
//! Computes and displays a proposed [`ReleasePlan`] — the authoritative,
//! machine-readable, deterministic description of what will be published
//! for one release transaction (ADR-0005 Decision §6). The planner is
//! side-effect-free: it mutates nothing (ADR-0005 invariant 9). The
//! committed plan artifact is written by `arc release prepare` (RV2.7).
//!
//! Inputs (all already-validated by earlier phases):
//! - `cargo metadata` (RV2.1) — current crate versions, dependency DAG.
//! - `[package.metadata.arcature]` (RV2.1) — release-unit membership.
//! - Change fragments under `changes/` (RV2.3) — per-unit change kind.
//! - YBF bump computation (RV2.5) — target version per crate.
//! - Unit topological order (RV2.4) — publish order.
//!
//! The commit sha is read from `git rev-parse HEAD`; the proposed
//! transaction id is date-based (the commit is not pinned until `prepare`).

use crate::cli::OutputFormat;
use crate::error::CommandError;
use crate::process::{ProcessSpec, run_capture};
use crate::release::{self, ReleaseError};

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

    // 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: 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))
        })?;

    // Commit sha (read-only, side-effect-free).
    let commit_sha = read_head_sha()?;

    // Proposed transaction id (date-based; finalized by prepare).
    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(())
}

/// 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(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`.
/// Using git (not a date crate) avoids a new dependency. The date comes
/// from the last commit's date, which is stable for a given checkout.
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)
}