arcature-cli 2026.1.1

Developer lifecycle CLI for Arcature applications.
Documentation
//! Release Plan TOML serialization (RV2.7).
//!
//! Serializes a [`ReleasePlan`] into the committed TOML artifact at
//! `release/transactions/<id>.toml` (ADR-0005 Decision §6). The format
//! matches the ADR's conceptual shape:
//!
//! ```toml
//! # release/transactions/<id>.toml — one committed Release Plan
//! [transaction]
//! id = "2026-08-15.01"
//! commit = "<exact 40-char git sha>"
//!
//! [[publish]]
//! crate = "arcature-auth"
//! version = "2026.1.7"
//! order = 1
//! ```
//!
//! Only crates that actually change (`from != to`) are written to the
//! committed plan — unchanged crates are not published (ADR-0005
//! invariant 4). The output is deterministic: sorted by topological
//! order, then by crate name, byte-stable across reruns.

use crate::release::plan::ReleasePlan;

/// Serialize a [`ReleasePlan`] into its committed TOML text.
///
/// Only changed crates (`from != to`) appear in the `[[publish]]` array.
/// The output is sorted by `(order, crate_name)` for deterministic,
/// byte-stable serialization. The text ends with a trailing newline.
pub(crate) fn serialize_plan(plan: &ReleasePlan) -> String {
    let mut out = String::new();

    out.push_str("# release/transactions/");
    out.push_str(&plan.transaction_id);
    out.push_str(".toml — one committed Release Plan\n");
    out.push_str("# ADR-0005 Decision §6. Contains no secret.\n");

    out.push_str("\n[transaction]\n");
    out.push_str("id = \"");
    out.push_str(&plan.transaction_id);
    out.push_str("\"\n");
    out.push_str("commit = \"");
    out.push_str(&plan.commit);
    out.push_str("\"\n");

    let mut changed: Vec<&crate::release::plan::PublishEntry> =
        plan.entries.values().filter(|e| e.from != e.to).collect();
    changed.sort_by_key(|e| (e.order, e.crate_name.clone()));

    for entry in &changed {
        out.push_str("\n[[publish]]\n");
        out.push_str("crate = \"");
        out.push_str(&entry.crate_name);
        out.push_str("\"\n");
        out.push_str("version = \"");
        out.push_str(&entry.to.to_string());
        out.push_str("\"\n");
        out.push_str("order = ");
        out.push_str(&entry.order.to_string());
        out.push('\n');
    }

    out
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::release::change::ChangeKind;
    use crate::release::plan::PublishEntry;
    use crate::release::version::Ybf;
    use std::collections::BTreeMap;

    fn entry(
        name: &str,
        unit: &str,
        from: &str,
        to: &str,
        kind: ChangeKind,
        order: usize,
    ) -> PublishEntry {
        PublishEntry {
            crate_name: name.to_string(),
            unit: unit.to_string(),
            from: Ybf::parse(from).unwrap(),
            to: Ybf::parse(to).unwrap(),
            change_kind: kind,
            order,
        }
    }

    #[test]
    fn serializes_empty_plan() {
        let plan = ReleasePlan {
            transaction_id: "2026-08-15.01".to_string(),
            commit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
            entries: BTreeMap::new(),
            changed_count: 0,
        };
        let toml = serialize_plan(&plan);
        assert!(toml.contains("[transaction]"));
        assert!(toml.contains("id = \"2026-08-15.01\""));
        assert!(toml.contains("commit = \"a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\""));
        assert!(!toml.contains("[[publish]]"));
    }

    #[test]
    fn serializes_changed_crates_only() {
        let mut entries = BTreeMap::new();
        entries.insert(
            "arcature-auth".to_string(),
            entry(
                "arcature-auth",
                "arcature-auth",
                "2026.1.0",
                "2026.1.1",
                ChangeKind::Compatible,
                2,
            ),
        );
        entries.insert(
            "arcature-db".to_string(),
            entry(
                "arcature-db",
                "arcature-db",
                "2026.1.0",
                "2026.1.0",
                ChangeKind::None,
                1,
            ),
        );
        let plan = ReleasePlan {
            transaction_id: "2026-08-15.01".to_string(),
            commit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
            entries,
            changed_count: 1,
        };
        let toml = serialize_plan(&plan);
        assert!(toml.contains("[[publish]]"));
        assert!(toml.contains("crate = \"arcature-auth\""));
        assert!(toml.contains("version = \"2026.1.1\""));
        assert!(toml.contains("order = 2"));
        // Unchanged crate not in publish list.
        assert!(!toml.contains("arcature-db"));
    }

    #[test]
    fn output_is_deterministic() {
        let mut entries = BTreeMap::new();
        entries.insert(
            "arcature-db".to_string(),
            entry(
                "arcature-db",
                "arcature-db",
                "2026.1.0",
                "2026.2.0",
                ChangeKind::Breaking,
                1,
            ),
        );
        entries.insert(
            "arcature-auth".to_string(),
            entry(
                "arcature-auth",
                "arcature-auth",
                "2026.1.0",
                "2026.1.1",
                ChangeKind::Compatible,
                2,
            ),
        );
        let plan = ReleasePlan {
            transaction_id: "2026-08-15.01".to_string(),
            commit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
            entries,
            changed_count: 2,
        };
        let first = serialize_plan(&plan);
        let second = serialize_plan(&plan);
        assert_eq!(first, second);
    }

    #[test]
    fn sorted_by_order_then_name() {
        let mut entries = BTreeMap::new();
        // Same order, different names — should sort by name.
        entries.insert(
            "zeta".to_string(),
            entry(
                "zeta",
                "zeta",
                "2026.1.0",
                "2026.1.1",
                ChangeKind::Compatible,
                1,
            ),
        );
        entries.insert(
            "alpha".to_string(),
            entry(
                "alpha",
                "alpha",
                "2026.1.0",
                "2026.1.1",
                ChangeKind::Compatible,
                1,
            ),
        );
        let plan = ReleasePlan {
            transaction_id: "2026-08-15.01".to_string(),
            commit: "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2".to_string(),
            entries,
            changed_count: 2,
        };
        let toml = serialize_plan(&plan);
        let alpha_pos = toml.find("alpha").unwrap();
        let zeta_pos = toml.find("zeta").unwrap();
        assert!(alpha_pos < zeta_pos);
    }
}