use std::collections::BTreeMap;
use crate::release::version::Ybf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformVersion {
pub(crate) year: u32,
pub(crate) break_: u32,
}
impl PlatformVersion {
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"),
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PlatformManifest {
pub(crate) platform: PlatformVersion,
pub(crate) crates: BTreeMap<String, Ybf>,
}
impl PlatformManifest {
#[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);
}
}