use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
pub const ARCHIVE_PROVENANCE_FORMAT: u32 = 1;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum History {
Summarised,
Full,
#[serde(other)]
Unknown,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct EntityProvenance {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub rationale: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub kind: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub timestamp: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub actor: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ArchiveProvenance {
pub format: u32,
pub history: History,
#[serde(default)]
pub entities: BTreeMap<String, EntityProvenance>,
}
impl ArchiveProvenance {
pub fn summarised(entities: BTreeMap<String, EntityProvenance>) -> Self {
Self {
format: ARCHIVE_PROVENANCE_FORMAT,
history: History::Summarised,
entities,
}
}
pub fn entity(&self, id: &str) -> Option<&EntityProvenance> {
self.entities.get(id)
}
pub fn to_archive_bytes(&self) -> Result<Vec<u8>, serde_json::Error> {
let mut s = serde_json::to_string_pretty(self)?;
s.push('\n');
Ok(s.into_bytes())
}
pub fn from_archive_bytes(bytes: &[u8]) -> Result<Self, serde_json::Error> {
serde_json::from_slice(bytes)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_through_archive_bytes() {
let mut entities = BTreeMap::new();
entities.insert(
"specs:alpha".to_string(),
EntityProvenance {
rationale: Some("first draft".to_string()),
kind: Some("create".to_string()),
timestamp: Some("2026-06-24T11:32:02Z".to_string()),
actor: Some("agent".to_string()),
},
);
let payload = ArchiveProvenance::summarised(entities);
let bytes = payload.to_archive_bytes().unwrap();
let back = ArchiveProvenance::from_archive_bytes(&bytes).unwrap();
assert_eq!(payload, back);
assert_eq!(back.history, History::Summarised);
assert_eq!(
back.entity("specs:alpha")
.and_then(|e| e.rationale.as_deref()),
Some("first draft")
);
}
#[test]
fn absent_entity_reports_none_not_a_default() {
let payload = ArchiveProvenance::summarised(BTreeMap::new());
assert!(payload.entity("specs:missing").is_none());
}
#[test]
fn unknown_history_disposition_is_forward_compatible() {
let json = r#"{"format":1,"history":"per_section","entities":{}}"#;
let back = ArchiveProvenance::from_archive_bytes(json.as_bytes()).unwrap();
assert_eq!(back.history, History::Unknown);
}
#[test]
fn touched_but_unnoted_entry_is_distinct_from_absent() {
let mut entities = BTreeMap::new();
entities.insert("specs:beta".to_string(), EntityProvenance::default());
let payload = ArchiveProvenance::summarised(entities);
let rec = payload.entity("specs:beta").expect("present");
assert!(rec.rationale.is_none());
assert!(payload.entity("specs:gamma").is_none());
}
}