arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Load a committed Release Plan from `release/transactions/<id>.toml`
//! (RV2.9).
//!
//! The Release Plan is the authoritative, machine-readable description of
//! what will be published in one Release Transaction (ADR-0005 Decision §6).
//! It is committed to git and tied to an exact commit sha. The publish
//! engine reads versions **from the plan** — never from a tag or workflow
//! inputs (ADR-0005 invariant 17).
//!
//! This module owns only the loading and parsing of the committed plan
//! file. Validation against the current commit and cargo metadata lives in
//! [`super::validate`].

use std::collections::BTreeMap;
use std::path::Path;

use serde::Deserialize;

use crate::release::version::Ybf;

/// One publish entry loaded from the committed plan TOML.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoadedEntry {
    /// The crate name (e.g. `"arcature-auth"`).
    pub(crate) crate_name: String,
    /// The target version to publish.
    pub(crate) version: Ybf,
    /// The topological publish order (1-based). Dependencies come before
    /// dependents.
    pub(crate) order: usize,
}

/// A committed Release Plan loaded from disk.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoadedPlan {
    /// The transaction id (e.g. `"2026-08-15.01"`).
    pub(crate) transaction_id: String,
    /// The exact 40-character git sha the plan is pinned to.
    pub(crate) commit: String,
    /// Per-crate publish entries, keyed by crate name and sorted
    /// deterministically (BTreeMap).
    pub(crate) entries: BTreeMap<String, LoadedEntry>,
}

impl LoadedPlan {
    /// Whether the plan has no publish entries.
    #[allow(dead_code)]
    pub(crate) fn is_empty(&self) -> bool {
        self.entries.is_empty()
    }

    /// Entries sorted by topological order (1-based), then by crate name
    /// for determinism. This is the publish sequence.
    pub(crate) fn ordered_entries(&self) -> Vec<&LoadedEntry> {
        let mut entries: Vec<&LoadedEntry> = self.entries.values().collect();
        entries.sort_by_key(|e| (e.order, &e.crate_name));
        entries
    }
}

/// Errors that occur while loading or parsing a committed Release Plan.
#[derive(Debug)]
pub(crate) enum PlanLoadError {
    /// Filesystem error reading the plan file.
    Io(std::io::Error),
    /// The plan TOML could not be parsed.
    Parse(String),
}

impl std::fmt::Display for PlanLoadError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Io(error) => write!(formatter, "cannot read release plan: {error}"),
            Self::Parse(error) => write!(formatter, "cannot parse release plan: {error}"),
        }
    }
}

impl std::error::Error for PlanLoadError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            Self::Io(error) => Some(error),
            Self::Parse(_) => None,
        }
    }
}

impl From<std::io::Error> for PlanLoadError {
    fn from(value: std::io::Error) -> Self {
        Self::Io(value)
    }
}

// --- serde document model (private) ---------------------------------------

#[derive(Deserialize)]
struct PlanDoc {
    transaction: TransactionDoc,
    #[serde(default)]
    publish: Vec<PublishDoc>,
}

#[derive(Deserialize)]
struct TransactionDoc {
    id: String,
    commit: String,
}

#[derive(Deserialize)]
struct PublishDoc {
    #[serde(rename = "crate")]
    crate_name: String,
    version: String,
    order: usize,
}

/// Load and parse a committed Release Plan from a TOML file on disk.
pub(crate) fn load_plan(path: &Path) -> Result<LoadedPlan, PlanLoadError> {
    let text = std::fs::read_to_string(path)?;
    parse_plan(&text)
}

/// Parse a Release Plan from TOML text (pure; no I/O).
///
/// Validates the commit sha format (40 hex chars) and every version string
/// (must be valid YBF). Duplicate crate entries are rejected.
pub(crate) fn parse_plan(text: &str) -> Result<LoadedPlan, PlanLoadError> {
    let doc: PlanDoc = toml::from_str(text).map_err(|e| PlanLoadError::Parse(e.to_string()))?;

    if doc.transaction.commit.len() != 40
        || !doc
            .transaction
            .commit
            .bytes()
            .all(|b| b.is_ascii_hexdigit())
    {
        return Err(PlanLoadError::Parse(format!(
            "invalid commit sha in plan: {}",
            doc.transaction.commit
        )));
    }

    let mut entries = BTreeMap::new();
    for publish in doc.publish {
        let version = Ybf::parse(&publish.version).map_err(|e| {
            PlanLoadError::Parse(format!("invalid version for {}: {e}", publish.crate_name))
        })?;

        let entry = LoadedEntry {
            crate_name: publish.crate_name.clone(),
            version,
            order: publish.order,
        };

        if entries.insert(publish.crate_name, entry).is_some() {
            return Err(PlanLoadError::Parse(
                "duplicate crate entry in plan".to_string(),
            ));
        }
    }

    Ok(LoadedPlan {
        transaction_id: doc.transaction.id,
        commit: doc.transaction.commit,
        entries,
    })
}

#[cfg(test)]
mod tests {
    use super::*;

    const VALID_PLAN: &str = "\
# release/transactions/2026-08-15.01.toml — one committed Release Plan
# ADR-0005 Decision §6. Contains no secret.

[transaction]
id = \"2026-08-15.01\"
commit = \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"

[[publish]]
crate = \"arcature-auth\"
version = \"2026.1.7\"
order = 1

[[publish]]
crate = \"arcature-cli\"
version = \"2026.1.9\"
order = 2
";

    #[test]
    fn parse_valid_plan() {
        let plan = parse_plan(VALID_PLAN).expect("valid plan parses");
        assert_eq!(plan.transaction_id, "2026-08-15.01");
        assert_eq!(plan.commit, "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0");
        assert_eq!(plan.entries.len(), 2);

        let auth = plan.entries.get("arcature-auth").expect("auth entry");
        assert_eq!(auth.version, Ybf::parse("2026.1.7").unwrap());
        assert_eq!(auth.order, 1);

        let cli = plan.entries.get("arcature-cli").expect("cli entry");
        assert_eq!(cli.version, Ybf::parse("2026.1.9").unwrap());
        assert_eq!(cli.order, 2);
    }

    #[test]
    fn ordered_entries_sort_by_order_then_name() {
        let plan = parse_plan(VALID_PLAN).unwrap();
        let ordered = plan.ordered_entries();
        assert_eq!(ordered.len(), 2);
        assert_eq!(ordered[0].crate_name, "arcature-auth");
        assert_eq!(ordered[1].crate_name, "arcature-cli");
    }

    #[test]
    fn parse_empty_plan() {
        let text = "\
[transaction]
id = \"2026-08-15.01\"
commit = \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"
";
        let plan = parse_plan(text).expect("empty plan parses");
        assert!(plan.is_empty());
        assert_eq!(plan.entries.len(), 0);
    }

    #[test]
    fn reject_invalid_commit_sha() {
        let text = "\
[transaction]
id = \"2026-08-15.01\"
commit = \"not-a-sha\"
";
        let error = parse_plan(text).expect_err("invalid sha rejected");
        assert!(matches!(error, PlanLoadError::Parse(_)));
    }

    #[test]
    fn reject_invalid_version() {
        let text = "\
[transaction]
id = \"2026-08-15.01\"
commit = \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"

[[publish]]
crate = \"arcature-auth\"
version = \"not-a-version\"
order = 1
";
        let error = parse_plan(text).expect_err("invalid version rejected");
        assert!(matches!(error, PlanLoadError::Parse(_)));
    }

    #[test]
    fn reject_duplicate_crate() {
        let text = "\
[transaction]
id = \"2026-08-15.01\"
commit = \"a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0\"

[[publish]]
crate = \"arcature-auth\"
version = \"2026.1.7\"
order = 1

[[publish]]
crate = \"arcature-auth\"
version = \"2026.1.8\"
order = 2
";
        let error = parse_plan(text).expect_err("duplicate rejected");
        assert!(matches!(error, PlanLoadError::Parse(_)));
    }

    #[test]
    fn reject_malformed_toml() {
        let error = parse_plan("not toml at all {{{").expect_err("malformed rejected");
        assert!(matches!(error, PlanLoadError::Parse(_)));
    }

    #[test]
    fn reject_missing_transaction() {
        let text = "\
[[publish]]
crate = \"arcature-auth\"
version = \"2026.1.7\"
order = 1
";
        let error = parse_plan(text).expect_err("missing transaction rejected");
        assert!(matches!(error, PlanLoadError::Parse(_)));
    }
}