use std::collections::BTreeMap;
use bytes::Bytes;
use ipld_core::ipld::Ipld;
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use crate::id::{ChangeId, Cid};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Signature {
pub algo: String,
pub public_key: Bytes,
pub sig: Bytes,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Commit {
pub change_id: ChangeId,
pub parents: Vec<Cid>,
pub nodes: Cid,
pub edges: Cid,
pub schema: Cid,
pub delta: Option<Cid>,
pub indexes: Option<Cid>,
pub embeddings: Option<Cid>,
pub author: String,
pub agent_id: Option<String>,
pub task_id: Option<String>,
pub time: u64,
pub message: String,
pub signature: Option<Signature>,
pub extra: BTreeMap<String, Ipld>,
}
impl Commit {
pub const KIND: &'static str = "commit";
#[must_use]
pub fn new(
change_id: ChangeId,
nodes: Cid,
edges: Cid,
schema: Cid,
author: impl Into<String>,
time: u64,
message: impl Into<String>,
) -> Self {
Self {
change_id,
parents: Vec::new(),
nodes,
edges,
schema,
delta: None,
indexes: None,
embeddings: None,
author: author.into(),
agent_id: None,
task_id: None,
time,
message: message.into(),
signature: None,
extra: BTreeMap::new(),
}
}
#[must_use]
pub fn with_parent(mut self, parent: Cid) -> Self {
self.parents.push(parent);
self
}
#[must_use]
pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
self.agent_id = Some(agent_id.into());
self
}
#[must_use]
pub fn with_task(mut self, task_id: impl Into<String>) -> Self {
self.task_id = Some(task_id.into());
self
}
pub fn content_cid(&self) -> Result<Cid, crate::error::CodecError> {
let mut parents = self.parents.clone();
parents.sort_by(|a, b| a.to_string().cmp(&b.to_string()));
let payload = ContentCidPayload {
schema_version: 1,
nodes: self.nodes.clone(),
edges: self.edges.clone(),
schema: self.schema.clone(),
indexes: self.indexes.clone(),
parents,
};
let (_bytes, cid) = crate::codec::dagcbor::hash_to_cid(&payload)?;
Ok(cid)
}
}
#[derive(Serialize)]
struct ContentCidPayload {
schema_version: u8,
nodes: Cid,
edges: Cid,
schema: Cid,
#[serde(skip_serializing_if = "Option::is_none")]
indexes: Option<Cid>,
parents: Vec<Cid>,
}
#[derive(Serialize, Deserialize)]
struct CommitWire {
#[serde(rename = "_kind")]
kind: String,
change_id: ChangeId,
parents: Vec<Cid>,
nodes: Cid,
edges: Cid,
schema: Cid,
#[serde(default, skip_serializing_if = "Option::is_none")]
delta: Option<Cid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
indexes: Option<Cid>,
#[serde(default, skip_serializing_if = "Option::is_none")]
embeddings: Option<Cid>,
author: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
agent_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
task_id: Option<String>,
time: u64,
message: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
signature: Option<Signature>,
#[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
extra: BTreeMap<String, Ipld>,
}
impl Serialize for Commit {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
CommitWire {
kind: Self::KIND.into(),
change_id: self.change_id,
parents: self.parents.clone(),
nodes: self.nodes.clone(),
edges: self.edges.clone(),
schema: self.schema.clone(),
delta: self.delta.clone(),
indexes: self.indexes.clone(),
embeddings: self.embeddings.clone(),
author: self.author.clone(),
agent_id: self.agent_id.clone(),
task_id: self.task_id.clone(),
time: self.time,
message: self.message.clone(),
signature: self.signature.clone(),
extra: self.extra.clone(),
}
.serialize(serializer)
}
}
impl<'de> Deserialize<'de> for Commit {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let w = CommitWire::deserialize(deserializer)?;
if w.kind != Self::KIND {
return Err(serde::de::Error::custom(format!(
"expected _kind='{}', got '{}'",
Self::KIND,
w.kind
)));
}
Ok(Self {
change_id: w.change_id,
parents: w.parents,
nodes: w.nodes,
edges: w.edges,
schema: w.schema,
delta: w.delta,
indexes: w.indexes,
embeddings: w.embeddings,
author: w.author,
agent_id: w.agent_id,
task_id: w.task_id,
time: w.time,
message: w.message,
signature: w.signature,
extra: w.extra,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::codec::{from_canonical_bytes, to_canonical_bytes};
use crate::id::{CODEC_RAW, Multihash};
fn raw(n: u32) -> Cid {
Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
}
fn sample() -> Commit {
Commit::new(
ChangeId::from_bytes_raw([1u8; 16]),
raw(1),
raw(2),
raw(3),
"alice@example.org",
1_700_000_000_000_000,
"init",
)
.with_agent("agent:claude")
.with_task("task:001")
}
#[test]
fn commit_round_trip_byte_identity() {
let original = sample();
let bytes = to_canonical_bytes(&original).unwrap();
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(original, decoded);
let bytes2 = to_canonical_bytes(&decoded).unwrap();
assert_eq!(bytes, bytes2);
}
#[test]
fn content_cid_is_stable_across_metadata() {
let mut a = Commit::new(
ChangeId::from_bytes_raw([1u8; 16]),
raw(10),
raw(20),
raw(30),
"alice@example.org",
1_700_000_000_000_000,
"init",
);
a.indexes = Some(raw(40));
let mut b = Commit::new(
ChangeId::from_bytes_raw([2u8; 16]),
raw(10),
raw(20),
raw(30),
"bob@example.org",
1_777_000_000_000_000,
"different message entirely",
);
b.indexes = Some(raw(40));
assert_eq!(
a.content_cid().unwrap(),
b.content_cid().unwrap(),
"content_cid must ignore metadata (time, change_id, author, message)"
);
let (a_bytes, a_commit_cid) = crate::codec::dagcbor::hash_to_cid(&a).unwrap();
let (b_bytes, b_commit_cid) = crate::codec::dagcbor::hash_to_cid(&b).unwrap();
let _ = (a_bytes, b_bytes);
assert_ne!(
a_commit_cid, b_commit_cid,
"commit_cid SHOULD differ when metadata differs (audit-trail invariant)"
);
}
#[test]
fn content_cid_distinguishes_data_roots() {
let a = Commit::new(
ChangeId::from_bytes_raw([1u8; 16]),
raw(10),
raw(20),
raw(30),
"alice",
1,
"msg",
);
let b = Commit::new(
ChangeId::from_bytes_raw([1u8; 16]),
raw(11), raw(20),
raw(30),
"alice",
1,
"msg",
);
assert_ne!(a.content_cid().unwrap(), b.content_cid().unwrap());
}
#[test]
fn content_cid_ignores_embeddings_field() {
let mut a = sample();
a.embeddings = Some(raw(100));
let mut b = sample();
b.embeddings = Some(raw(200)); assert_eq!(
a.content_cid().unwrap(),
b.content_cid().unwrap(),
"content_cid MUST ignore the embeddings sidecar - that is the G16 contract"
);
let mut c = sample();
c.embeddings = None;
let mut d = sample();
d.embeddings = Some(raw(300));
assert_eq!(
c.content_cid().unwrap(),
d.content_cid().unwrap(),
"absence of embeddings must not change content_cid either"
);
}
#[test]
fn commit_with_embeddings_some_round_trips() {
let mut original = sample();
original.embeddings = Some(raw(42));
let bytes = to_canonical_bytes(&original).unwrap();
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(original, decoded);
assert_eq!(decoded.embeddings, Some(raw(42)));
let bytes2 = to_canonical_bytes(&decoded).unwrap();
assert_eq!(
bytes, bytes2,
"round-trip must be byte-identical - wire form is contract-bound"
);
}
#[test]
fn commit_legacy_no_embeddings_key_round_trips() {
let original = sample();
assert_eq!(original.embeddings, None);
let bytes = to_canonical_bytes(&original).unwrap();
assert!(
!bytes
.windows(b"embeddings".len())
.any(|w| w == b"embeddings"),
"wire form must omit the `embeddings` key when None"
);
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(decoded.embeddings, None);
assert_eq!(decoded, original);
let bytes2 = to_canonical_bytes(&decoded).unwrap();
assert_eq!(bytes, bytes2, "legacy CBOR must re-encode byte-identically");
}
#[test]
fn commit_kind_rejection() {
let wire = CommitWire {
kind: "node".into(),
change_id: ChangeId::from_bytes_raw([1u8; 16]),
parents: vec![],
nodes: raw(1),
edges: raw(2),
schema: raw(3),
delta: None,
indexes: None,
embeddings: None,
author: "x".into(),
agent_id: None,
task_id: None,
time: 0,
message: String::new(),
signature: None,
extra: BTreeMap::new(),
};
let bytes = serde_ipld_dagcbor::to_vec(&wire).unwrap();
let err = serde_ipld_dagcbor::from_slice::<Commit>(&bytes).unwrap_err();
assert!(err.to_string().contains("_kind"));
}
#[test]
fn commit_with_parents_round_trip() {
let c = sample().with_parent(raw(100)).with_parent(raw(101));
let bytes = to_canonical_bytes(&c).unwrap();
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(c, decoded);
assert_eq!(decoded.parents.len(), 2);
}
#[test]
fn commit_with_signature_round_trip() {
let mut c = sample();
c.signature = Some(Signature {
algo: "ed25519".into(),
public_key: Bytes::from(vec![0xAAu8; 32]),
sig: Bytes::from(vec![0xBBu8; 64]),
});
let bytes = to_canonical_bytes(&c).unwrap();
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(c, decoded);
assert_eq!(decoded.signature.as_ref().unwrap().algo, "ed25519");
}
#[test]
fn commit_extra_fields_preserved() {
let mut c = sample();
c.extra
.insert("x-future-field".into(), Ipld::String("v9".into()));
let bytes = to_canonical_bytes(&c).unwrap();
let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
assert_eq!(c, decoded);
let bytes2 = to_canonical_bytes(&decoded).unwrap();
assert_eq!(bytes, bytes2);
}
}