use chrono::{DateTime, Utc};
use clayers_xml::ContentHash;
use xot::Xot;
pub const REPO_NS: &str = "urn:clayers:repository";
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Attribute {
pub local_name: String,
pub namespace_uri: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub namespace_prefix: Option<String>,
pub value: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Author {
pub name: String,
pub email: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ElementObject {
pub local_name: String,
pub namespace_uri: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub namespace_prefix: Option<String>,
#[cfg_attr(feature = "serde", serde(default))]
pub extra_namespaces: Vec<(String, String)>,
pub attributes: Vec<Attribute>,
pub children: Vec<ContentHash>,
pub inclusive_hash: ContentHash,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TextObject {
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommentObject {
pub content: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PIObject {
pub target: String,
pub data: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DocumentObject {
pub root: ContentHash,
#[cfg_attr(feature = "serde", serde(default))]
pub prologue: Vec<ContentHash>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TreeEntry {
pub path: String,
pub document: ContentHash,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TreeObject {
pub entries: Vec<TreeEntry>,
}
impl TreeObject {
#[must_use]
pub fn new(mut entries: Vec<TreeEntry>) -> Self {
entries.sort_by(|a, b| a.path.cmp(&b.path));
Self { entries }
}
#[must_use]
pub fn get(&self, path: &str) -> Option<&TreeEntry> {
self.entries.iter().find(|e| e.path == path)
}
#[must_use]
pub fn paths(&self) -> Vec<&str> {
self.entries.iter().map(|e| e.path.as_str()).collect()
}
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn to_xml(&self) -> String {
let mut xot = Xot::new();
let ns = xot.add_namespace(REPO_NS);
let prefix = xot.add_prefix("repo");
let tree_name = xot.add_name_ns("tree", ns);
let entry_name = xot.add_name_ns("entry", ns);
let path_attr = xot.add_name("path");
let tree_el = xot.new_element(tree_name);
xot.namespaces_mut(tree_el).insert(prefix, ns);
for entry in &self.entries {
let entry_el = xot.new_element(entry_name);
xot.attributes_mut(entry_el)
.insert(path_attr, entry.path.clone());
let text = xot.new_text(&entry.document.to_string());
xot.append(entry_el, text).expect("append text");
xot.append(tree_el, entry_el).expect("append entry");
}
xot.to_string(tree_el).expect("serialize tree")
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CommitObject {
pub tree: ContentHash,
pub parents: Vec<ContentHash>,
pub author: Author,
pub timestamp: DateTime<Utc>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct TagObject {
pub target: ContentHash,
pub name: String,
pub tagger: Author,
pub timestamp: DateTime<Utc>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum Object {
Element(ElementObject),
Text(TextObject),
Comment(CommentObject),
PI(PIObject),
Document(DocumentObject),
Tree(TreeObject),
Commit(CommitObject),
Tag(TagObject),
}
impl DocumentObject {
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn to_xml(&self) -> String {
let mut xot = Xot::new();
let ns = xot.add_namespace(REPO_NS);
let prefix = xot.add_prefix("repo");
let doc_name = xot.add_name_ns("document", ns);
let root_name = xot.add_name_ns("root", ns);
let prologue_name = xot.add_name_ns("prologue", ns);
let version_attr = xot.add_name("version");
let encoding_attr = xot.add_name("encoding");
let doc_el = xot.new_element(doc_name);
xot.namespaces_mut(doc_el).insert(prefix, ns);
xot.attributes_mut(doc_el)
.insert(encoding_attr, "UTF-8".into());
xot.attributes_mut(doc_el)
.insert(version_attr, "1.0".into());
let root_el = xot.new_element(root_name);
let root_text = xot.new_text(&self.root.to_string());
xot.append(root_el, root_text).expect("append text");
xot.append(doc_el, root_el).expect("append root");
for h in &self.prologue {
let prologue_el = xot.new_element(prologue_name);
let text = xot.new_text(&h.to_string());
xot.append(prologue_el, text).expect("append text");
xot.append(doc_el, prologue_el).expect("append prologue");
}
xot.to_string(doc_el).expect("serialize document")
}
}
impl CommitObject {
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn to_xml(&self) -> String {
let mut xot = Xot::new();
let ns = xot.add_namespace(REPO_NS);
let prefix = xot.add_prefix("repo");
let commit_name = xot.add_name_ns("commit", ns);
let tree_name = xot.add_name_ns("tree", ns);
let parent_name = xot.add_name_ns("parent", ns);
let author_name = xot.add_name_ns("author", ns);
let timestamp_name = xot.add_name_ns("timestamp", ns);
let message_name = xot.add_name_ns("message", ns);
let name_attr = xot.add_name("name");
let email_attr = xot.add_name("email");
let commit_el = xot.new_element(commit_name);
xot.namespaces_mut(commit_el).insert(prefix, ns);
let tree_el = xot.new_element(tree_name);
let text = xot.new_text(&self.tree.to_string());
xot.append(tree_el, text).expect("append text");
xot.append(commit_el, tree_el).expect("append tree");
for p in &self.parents {
let parent_el = xot.new_element(parent_name);
let text = xot.new_text(&p.to_string());
xot.append(parent_el, text).expect("append text");
xot.append(commit_el, parent_el).expect("append parent");
}
let author_el = xot.new_element(author_name);
xot.attributes_mut(author_el)
.insert(email_attr, self.author.email.clone());
xot.attributes_mut(author_el)
.insert(name_attr, self.author.name.clone());
xot.append(commit_el, author_el).expect("append author");
let ts_el = xot.new_element(timestamp_name);
let ts_text = xot.new_text(&self.timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string());
xot.append(ts_el, ts_text).expect("append text");
xot.append(commit_el, ts_el).expect("append timestamp");
let msg_el = xot.new_element(message_name);
let msg_text = xot.new_text(&self.message);
xot.append(msg_el, msg_text).expect("append text");
xot.append(commit_el, msg_el).expect("append message");
xot.to_string(commit_el).expect("serialize commit")
}
}
impl TagObject {
#[must_use]
#[allow(clippy::missing_panics_doc)]
pub fn to_xml(&self) -> String {
let mut xot = Xot::new();
let ns = xot.add_namespace(REPO_NS);
let prefix = xot.add_prefix("repo");
let tag_name = xot.add_name_ns("tag", ns);
let target_name = xot.add_name_ns("target", ns);
let name_name = xot.add_name_ns("name", ns);
let tagger_name = xot.add_name_ns("tagger", ns);
let timestamp_name = xot.add_name_ns("timestamp", ns);
let message_name = xot.add_name_ns("message", ns);
let name_attr = xot.add_name("name");
let email_attr = xot.add_name("email");
let tag_el = xot.new_element(tag_name);
xot.namespaces_mut(tag_el).insert(prefix, ns);
let target_el = xot.new_element(target_name);
let text = xot.new_text(&self.target.to_string());
xot.append(target_el, text).expect("append text");
xot.append(tag_el, target_el).expect("append target");
let name_el = xot.new_element(name_name);
let name_text = xot.new_text(&self.name);
xot.append(name_el, name_text).expect("append text");
xot.append(tag_el, name_el).expect("append name");
let tagger_el = xot.new_element(tagger_name);
xot.attributes_mut(tagger_el)
.insert(email_attr, self.tagger.email.clone());
xot.attributes_mut(tagger_el)
.insert(name_attr, self.tagger.name.clone());
xot.append(tag_el, tagger_el).expect("append tagger");
let ts_el = xot.new_element(timestamp_name);
let ts_text = xot.new_text(&self.timestamp.format("%Y-%m-%dT%H:%M:%SZ").to_string());
xot.append(ts_el, ts_text).expect("append text");
xot.append(tag_el, ts_el).expect("append timestamp");
let msg_el = xot.new_element(message_name);
let msg_text = xot.new_text(&self.message);
xot.append(msg_el, msg_text).expect("append text");
xot.append(tag_el, msg_el).expect("append message");
xot.to_string(tag_el).expect("serialize tag")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn document_to_xml_contains_root_hash() {
let hash = ContentHash::from_canonical(b"test");
let doc = DocumentObject { root: hash, prologue: vec![] };
let xml = doc.to_xml();
assert!(xml.contains(&hash.to_string()));
assert!(xml.contains(REPO_NS));
}
#[test]
fn commit_to_xml_contains_all_fields() {
let hash = ContentHash::from_canonical(b"test");
let commit = CommitObject {
tree: hash,
parents: vec![hash],
author: Author {
name: "Alice".into(),
email: "alice@example.com".into(),
},
timestamp: DateTime::parse_from_rfc3339("2026-03-17T10:30:00Z")
.expect("valid timestamp")
.to_utc(),
message: "Test commit".into(),
};
let xml = commit.to_xml();
assert!(xml.contains("repo:commit"));
assert!(xml.contains("repo:tree"));
assert!(xml.contains("repo:parent"));
assert!(xml.contains("Alice"));
assert!(xml.contains("Test commit"));
}
#[test]
fn tree_sorts_entries() {
let h1 = ContentHash::from_canonical(b"doc1");
let h2 = ContentHash::from_canonical(b"doc2");
let tree = TreeObject::new(vec![
TreeEntry { path: "z.xml".into(), document: h1 },
TreeEntry { path: "a.xml".into(), document: h2 },
]);
assert_eq!(tree.entries[0].path, "a.xml");
assert_eq!(tree.entries[1].path, "z.xml");
}
#[test]
fn tree_get_by_path() {
let h1 = ContentHash::from_canonical(b"doc1");
let tree = TreeObject::new(vec![
TreeEntry { path: "file.xml".into(), document: h1 },
]);
assert!(tree.get("file.xml").is_some());
assert_eq!(tree.get("file.xml").unwrap().document, h1);
}
#[test]
fn tree_get_missing() {
let tree = TreeObject::new(vec![]);
assert!(tree.get("nonexistent.xml").is_none());
}
#[test]
fn tree_to_xml_deterministic() {
let h1 = ContentHash::from_canonical(b"doc1");
let h2 = ContentHash::from_canonical(b"doc2");
let tree1 = TreeObject::new(vec![
TreeEntry { path: "z.xml".into(), document: h1 },
TreeEntry { path: "a.xml".into(), document: h2 },
]);
let tree2 = TreeObject::new(vec![
TreeEntry { path: "a.xml".into(), document: h2 },
TreeEntry { path: "z.xml".into(), document: h1 },
]);
assert_eq!(tree1.to_xml(), tree2.to_xml());
}
#[test]
fn tree_to_xml_empty() {
let tree = TreeObject::new(vec![]);
let xml = tree.to_xml();
assert!(xml.contains("repo:tree"));
assert!(!xml.contains("repo:entry"));
}
#[test]
fn tree_to_xml_contains_entries() {
let h = ContentHash::from_canonical(b"doc1");
let tree = TreeObject::new(vec![
TreeEntry { path: "file.xml".into(), document: h },
]);
let xml = tree.to_xml();
assert!(xml.contains("repo:entry"));
assert!(xml.contains("path=\"file.xml\""));
assert!(xml.contains(&h.to_string()));
}
#[test]
fn tree_paths() {
let h = ContentHash::from_canonical(b"doc1");
let tree = TreeObject::new(vec![
TreeEntry { path: "c.xml".into(), document: h },
TreeEntry { path: "a.xml".into(), document: h },
TreeEntry { path: "b.xml".into(), document: h },
]);
assert_eq!(tree.paths(), vec!["a.xml", "b.xml", "c.xml"]);
}
#[test]
fn tag_to_xml_contains_all_fields() {
let hash = ContentHash::from_canonical(b"test");
let tag = TagObject {
target: hash,
name: "v1.0".into(),
tagger: Author {
name: "Bob".into(),
email: "bob@example.com".into(),
},
timestamp: DateTime::parse_from_rfc3339("2026-03-17T10:30:00Z")
.expect("valid timestamp")
.to_utc(),
message: "Release v1.0".into(),
};
let xml = tag.to_xml();
assert!(xml.contains("repo:tag"));
assert!(xml.contains("v1.0"));
assert!(xml.contains("Bob"));
}
use proptest::prelude::*;
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
#[test]
fn prop_tree_sort_determinism(
entries in prop::collection::hash_map(
"[a-z]{1,8}\\.xml",
crate::store::prop_strategies::arb_content_hash(),
2..=10,
)
) {
let tree_entries: Vec<TreeEntry> = entries.iter()
.map(|(path, hash)| TreeEntry { path: path.clone(), document: *hash })
.collect();
let tree1 = TreeObject::new(tree_entries.clone());
let mut reversed = tree_entries;
reversed.reverse();
let tree2 = TreeObject::new(reversed);
prop_assert_eq!(tree1.to_xml(), tree2.to_xml());
}
#[test]
fn prop_build_tree_hash_determinism(
entries in prop::collection::hash_map(
"[a-z]{1,8}\\.xml",
crate::store::prop_strategies::arb_content_hash(),
2..=10,
)
) {
let rt = crate::store::prop_strategies::runtime();
rt.block_on(async {
let store = crate::store::memory::MemoryStore::new();
let repo = crate::repo::Repo::init(store);
let forward: Vec<(String, ContentHash)> = entries.iter()
.map(|(p, h)| (p.clone(), *h))
.collect();
let mut backward = forward.clone();
backward.reverse();
let h1 = repo.build_tree(forward).await.unwrap();
let h2 = repo.build_tree(backward).await.unwrap();
prop_assert_eq!(h1, h2, "shuffled entries should produce same tree hash");
Ok(())
})?;
}
}
}