arcature-cli 2026.2.0

Developer lifecycle CLI for Arcature applications.
Documentation
//! Platform manifest data model (RV2.8).
//!
//! The Platform manifest (the "Certified Stack Contract") is a
//! version-controlled, machine-readable, human-reviewable TOML file that
//! records a certified set of Arcature crate versions tested together
//! (ADR-0005 Decision §5). It lives at `platform/<platform>.toml`.
//!
//! ```toml
//! # platform/2026.1.toml — Certified Stack Contract
//! platform = "2026.1"
//!
//! [crates]
//! arcature = "2026.1.0"
//! arcature-dx = "2026.1.0"
//! arcature-auth = "2026.1.0"
//! ```
//!
//! The platform version is `YEAR.BREAK` (two YBF components, no FIX — a
//! Platform release certifies a set, not a specific fix). Each crate entry
//! maps a publishable crate to its exact published version. Core members
//! (`arcature` and `arcature-dx`) must share the same version
//! (ADR-0005 invariant 3).

use std::collections::BTreeMap;

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

/// The `YEAR.BREAK` platform version label (e.g. `"2026.1"`).
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformVersion {
    pub(crate) year: u32,
    pub(crate) break_: u32,
}

impl PlatformVersion {
    /// Parse a `YEAR.BREAK` string. Strict: two base-10 integers separated
    /// by a single dot, no leading zeros (except `0` itself).
    pub(crate) fn parse(input: &str) -> Result<Self, PlatformVersionError> {
        let mut parts = input.split('.');
        let year = parse_component(parts.next(), "year", input)?;
        let break_ = parse_component(parts.next(), "break", input)?;
        if parts.next().is_some() {
            return Err(PlatformVersionError {
                input: input.to_string(),
                reason: "expected exactly 2 components".to_string(),
            });
        }
        Ok(Self { year, break_ })
    }
}

impl std::fmt::Display for PlatformVersion {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(formatter, "{}.{}", self.year, self.break_)
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformVersionError {
    pub(crate) input: String,
    pub(crate) reason: String,
}

impl std::fmt::Display for PlatformVersionError {
    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        write!(
            formatter,
            "invalid platform version {:?}: {}",
            self.input, self.reason
        )
    }
}

impl std::error::Error for PlatformVersionError {}

fn parse_component(
    raw: Option<&str>,
    name: &str,
    input: &str,
) -> Result<u32, PlatformVersionError> {
    let raw = raw.ok_or_else(|| PlatformVersionError {
        input: input.to_string(),
        reason: format!("missing {name} component"),
    })?;
    if raw.is_empty() {
        return Err(PlatformVersionError {
            input: input.to_string(),
            reason: format!("empty {name} component"),
        });
    }
    if raw.len() > 1 && raw.starts_with('0') {
        return Err(PlatformVersionError {
            input: input.to_string(),
            reason: format!("{name} has a leading zero"),
        });
    }
    if !raw.bytes().all(|b| b.is_ascii_digit()) {
        return Err(PlatformVersionError {
            input: input.to_string(),
            reason: format!("{name} is not a base-10 integer"),
        });
    }
    raw.parse::<u32>().map_err(|_| PlatformVersionError {
        input: input.to_string(),
        reason: format!("{name} overflows u32"),
    })
}

/// A parsed Platform manifest — a certified set of crate versions.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformManifest {
    pub(crate) platform: PlatformVersion,
    /// Crate name → exact published version. BTreeMap for deterministic
    /// iteration. Must include every publishable crate and no others.
    pub(crate) crates: BTreeMap<String, Ybf>,
}

impl PlatformManifest {
    /// The canonical text form of this manifest, for serialization to
    /// `platform/<platform>.toml`. Deterministic and byte-stable.
    #[allow(dead_code)]
    pub(crate) fn to_toml(&self) -> String {
        let mut out = String::new();
        out.push_str("# platform/");
        out.push_str(&self.platform.to_string());
        out.push_str(".toml — Certified Stack Contract (ADR-0005 Decision §5)\n");
        out.push_str("# A certified set of Arcature crate versions tested together.\n");
        out.push('\n');
        out.push_str("platform = \"");
        out.push_str(&self.platform.to_string());
        out.push_str("\"\n");
        out.push_str("\n[crates]\n");
        for (name, version) in &self.crates {
            out.push_str(name);
            out.push_str(" = \"");
            out.push_str(&version.to_string());
            out.push_str("\"\n");
        }
        out
    }
}

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

    #[test]
    fn parses_platform_version() {
        let v = PlatformVersion::parse("2026.1").unwrap();
        assert_eq!(
            v,
            PlatformVersion {
                year: 2026,
                break_: 1
            }
        );
        assert_eq!(v.to_string(), "2026.1");
    }

    #[test]
    fn rejects_three_components() {
        assert!(PlatformVersion::parse("2026.1.0").is_err());
    }

    #[test]
    fn rejects_one_component() {
        assert!(PlatformVersion::parse("2026").is_err());
    }

    #[test]
    fn rejects_leading_zeros() {
        assert!(PlatformVersion::parse("02026.1").is_err());
        assert!(PlatformVersion::parse("2026.01").is_err());
    }

    #[test]
    fn rejects_non_numeric() {
        assert!(PlatformVersion::parse("2026.a").is_err());
    }

    #[test]
    fn manifest_to_toml_is_deterministic() {
        let manifest = PlatformManifest {
            platform: PlatformVersion {
                year: 2026,
                break_: 1,
            },
            crates: BTreeMap::from([
                ("arcature".to_string(), Ybf::parse("2026.1.0").unwrap()),
                ("arcature-auth".to_string(), Ybf::parse("2026.1.0").unwrap()),
            ]),
        };
        let first = manifest.to_toml();
        let second = manifest.to_toml();
        assert_eq!(first, second);
        assert!(first.contains("platform = \"2026.1\""));
        assert!(first.contains("[crates]"));
        assert!(first.contains("arcature = \"2026.1.0\""));
        assert!(first.contains("arcature-auth = \"2026.1.0\""));
    }

    #[test]
    fn manifest_to_toml_sorts_crates() {
        let manifest = PlatformManifest {
            platform: PlatformVersion {
                year: 2026,
                break_: 1,
            },
            crates: BTreeMap::from([
                ("zeta".to_string(), Ybf::parse("2026.1.0").unwrap()),
                ("alpha".to_string(), Ybf::parse("2026.1.0").unwrap()),
            ]),
        };
        let toml = manifest.to_toml();
        let alpha_pos = toml.find("alpha").unwrap();
        let zeta_pos = toml.find("zeta").unwrap();
        assert!(alpha_pos < zeta_pos);
    }
}