use std::collections::BTreeMap;
use std::path::Path;
use serde::Deserialize;
use crate::release::version::Ybf;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoadedEntry {
pub(crate) crate_name: String,
pub(crate) version: Ybf,
pub(crate) order: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoadedPlan {
pub(crate) transaction_id: String,
pub(crate) commit: String,
pub(crate) entries: BTreeMap<String, LoadedEntry>,
}
impl LoadedPlan {
#[allow(dead_code)]
pub(crate) fn is_empty(&self) -> bool {
self.entries.is_empty()
}
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
}
}
#[derive(Debug)]
pub(crate) enum PlanLoadError {
Io(std::io::Error),
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)
}
}
#[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,
}
pub(crate) fn load_plan(path: &Path) -> Result<LoadedPlan, PlanLoadError> {
let text = std::fs::read_to_string(path)?;
parse_plan(&text)
}
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(_)));
}
}