use crate::release::plan::ReleasePlan;
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"));
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();
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);
}
}