1use std::collections::BTreeMap;
9
10use bytes::Bytes;
11use ipld_core::ipld::Ipld;
12use serde::{Deserialize, Deserializer, Serialize, Serializer};
13
14use crate::id::{ChangeId, Cid};
15
16#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
22pub struct Signature {
23 pub algo: String,
25 pub public_key: Bytes,
27 pub sig: Bytes,
29}
30
31#[derive(Clone, Debug, PartialEq, Eq)]
33pub struct Commit {
34 pub change_id: ChangeId,
36 pub parents: Vec<Cid>,
38 pub nodes: Cid,
40 pub edges: Cid,
42 pub schema: Cid,
44 pub delta: Option<Cid>,
46 pub indexes: Option<Cid>,
51 pub embeddings: Option<Cid>,
67 pub sparse: Option<Cid>,
80 pub author: String,
82 pub agent_id: Option<String>,
84 pub task_id: Option<String>,
86 pub time: u64,
88 pub message: String,
90 pub signature: Option<Signature>,
92 pub extra: BTreeMap<String, Ipld>,
94}
95
96impl Commit {
97 pub const KIND: &'static str = "commit";
99
100 #[must_use]
102 pub fn new(
103 change_id: ChangeId,
104 nodes: Cid,
105 edges: Cid,
106 schema: Cid,
107 author: impl Into<String>,
108 time: u64,
109 message: impl Into<String>,
110 ) -> Self {
111 Self {
112 change_id,
113 parents: Vec::new(),
114 nodes,
115 edges,
116 schema,
117 delta: None,
118 indexes: None,
119 embeddings: None,
120 sparse: None,
121 author: author.into(),
122 agent_id: None,
123 task_id: None,
124 time,
125 message: message.into(),
126 signature: None,
127 extra: BTreeMap::new(),
128 }
129 }
130
131 #[must_use]
133 pub fn with_parent(mut self, parent: Cid) -> Self {
134 self.parents.push(parent);
135 self
136 }
137
138 #[must_use]
140 pub fn with_agent(mut self, agent_id: impl Into<String>) -> Self {
141 self.agent_id = Some(agent_id.into());
142 self
143 }
144
145 #[must_use]
147 pub fn with_task(mut self, task_id: impl Into<String>) -> Self {
148 self.task_id = Some(task_id.into());
149 self
150 }
151
152 pub fn content_cid(&self) -> Result<Cid, crate::error::CodecError> {
179 let payload = ContentCidPayload {
180 schema_version: 2,
181 nodes: self.nodes.clone(),
182 edges: self.edges.clone(),
183 schema: self.schema.clone(),
184 indexes: self.indexes.clone(),
185 };
186 let (_bytes, cid) = crate::codec::dagcbor::hash_to_cid(&payload)?;
187 Ok(cid)
188 }
189}
190
191#[derive(Serialize)]
199struct ContentCidPayload {
200 schema_version: u8,
201 nodes: Cid,
202 edges: Cid,
203 schema: Cid,
204 #[serde(skip_serializing_if = "Option::is_none")]
205 indexes: Option<Cid>,
206}
207
208#[derive(Serialize, Deserialize)]
211struct CommitWire {
212 #[serde(rename = "_kind")]
213 kind: String,
214 change_id: ChangeId,
215 parents: Vec<Cid>,
216 nodes: Cid,
217 edges: Cid,
218 schema: Cid,
219 #[serde(default, skip_serializing_if = "Option::is_none")]
220 delta: Option<Cid>,
221 #[serde(default, skip_serializing_if = "Option::is_none")]
222 indexes: Option<Cid>,
223 #[serde(default, skip_serializing_if = "Option::is_none")]
226 embeddings: Option<Cid>,
227 #[serde(default, skip_serializing_if = "Option::is_none")]
230 sparse: Option<Cid>,
231 author: String,
232 #[serde(default, skip_serializing_if = "Option::is_none")]
233 agent_id: Option<String>,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 task_id: Option<String>,
236 time: u64,
237 message: String,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 signature: Option<Signature>,
240 #[serde(flatten, default, skip_serializing_if = "BTreeMap::is_empty")]
241 extra: BTreeMap<String, Ipld>,
242}
243
244impl Serialize for Commit {
245 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
246 CommitWire {
247 kind: Self::KIND.into(),
248 change_id: self.change_id,
249 parents: self.parents.clone(),
250 nodes: self.nodes.clone(),
251 edges: self.edges.clone(),
252 schema: self.schema.clone(),
253 delta: self.delta.clone(),
254 indexes: self.indexes.clone(),
255 embeddings: self.embeddings.clone(),
256 sparse: self.sparse.clone(),
257 author: self.author.clone(),
258 agent_id: self.agent_id.clone(),
259 task_id: self.task_id.clone(),
260 time: self.time,
261 message: self.message.clone(),
262 signature: self.signature.clone(),
263 extra: self.extra.clone(),
264 }
265 .serialize(serializer)
266 }
267}
268
269impl<'de> Deserialize<'de> for Commit {
270 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
271 let w = CommitWire::deserialize(deserializer)?;
272 if w.kind != Self::KIND {
273 return Err(serde::de::Error::custom(format!(
274 "expected _kind='{}', got '{}'",
275 Self::KIND,
276 w.kind
277 )));
278 }
279 Ok(Self {
280 change_id: w.change_id,
281 parents: w.parents,
282 nodes: w.nodes,
283 edges: w.edges,
284 schema: w.schema,
285 delta: w.delta,
286 indexes: w.indexes,
287 embeddings: w.embeddings,
288 sparse: w.sparse,
289 author: w.author,
290 agent_id: w.agent_id,
291 task_id: w.task_id,
292 time: w.time,
293 message: w.message,
294 signature: w.signature,
295 extra: w.extra,
296 })
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use crate::codec::{from_canonical_bytes, to_canonical_bytes};
304 use crate::id::{CODEC_RAW, Multihash};
305
306 fn raw(n: u32) -> Cid {
307 Cid::new(CODEC_RAW, Multihash::sha2_256(&n.to_be_bytes()))
308 }
309
310 fn sample() -> Commit {
311 Commit::new(
312 ChangeId::from_bytes_raw([1u8; 16]),
313 raw(1),
314 raw(2),
315 raw(3),
316 "alice@example.org",
317 1_700_000_000_000_000,
318 "init",
319 )
320 .with_agent("agent:claude")
321 .with_task("task:001")
322 }
323
324 #[test]
325 fn commit_round_trip_byte_identity() {
326 let original = sample();
327 let bytes = to_canonical_bytes(&original).unwrap();
328 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
329 assert_eq!(original, decoded);
330 let bytes2 = to_canonical_bytes(&decoded).unwrap();
331 assert_eq!(bytes, bytes2);
332 }
333
334 #[test]
339 fn content_cid_is_stable_across_metadata() {
340 let mut a = Commit::new(
341 ChangeId::from_bytes_raw([1u8; 16]),
342 raw(10),
343 raw(20),
344 raw(30),
345 "alice@example.org",
346 1_700_000_000_000_000,
347 "init",
348 );
349 a.indexes = Some(raw(40));
350
351 let mut b = Commit::new(
352 ChangeId::from_bytes_raw([2u8; 16]),
354 raw(10),
356 raw(20),
357 raw(30),
358 "bob@example.org",
360 1_777_000_000_000_000,
361 "different message entirely",
362 );
363 b.indexes = Some(raw(40));
364
365 assert_eq!(
366 a.content_cid().unwrap(),
367 b.content_cid().unwrap(),
368 "content_cid must ignore metadata (time, change_id, author, message)"
369 );
370
371 let (a_bytes, a_commit_cid) = crate::codec::dagcbor::hash_to_cid(&a).unwrap();
372 let (b_bytes, b_commit_cid) = crate::codec::dagcbor::hash_to_cid(&b).unwrap();
373 let _ = (a_bytes, b_bytes);
374 assert_ne!(
375 a_commit_cid, b_commit_cid,
376 "commit_cid SHOULD differ when metadata differs (audit-trail invariant)"
377 );
378 }
379
380 #[test]
382 fn content_cid_distinguishes_data_roots() {
383 let a = Commit::new(
384 ChangeId::from_bytes_raw([1u8; 16]),
385 raw(10),
386 raw(20),
387 raw(30),
388 "alice",
389 1,
390 "msg",
391 );
392 let b = Commit::new(
393 ChangeId::from_bytes_raw([1u8; 16]),
394 raw(11), raw(20),
396 raw(30),
397 "alice",
398 1,
399 "msg",
400 );
401 assert_ne!(a.content_cid().unwrap(), b.content_cid().unwrap());
402 }
403
404 #[test]
412 fn content_cid_ignores_embeddings_field() {
413 let mut a = sample();
414 a.embeddings = Some(raw(100));
415 let mut b = sample();
416 b.embeddings = Some(raw(200)); assert_eq!(
418 a.content_cid().unwrap(),
419 b.content_cid().unwrap(),
420 "content_cid MUST ignore the embeddings sidecar - that is the G16 contract"
421 );
422
423 let mut c = sample();
427 c.embeddings = None;
428 let mut d = sample();
429 d.embeddings = Some(raw(300));
430 assert_eq!(
431 c.content_cid().unwrap(),
432 d.content_cid().unwrap(),
433 "absence of embeddings must not change content_cid either"
434 );
435 }
436
437 #[test]
443 fn content_cid_ignores_sparse_field() {
444 let mut a = sample();
445 a.sparse = Some(raw(100));
446 let mut b = sample();
447 b.sparse = Some(raw(200)); assert_eq!(
449 a.content_cid().unwrap(),
450 b.content_cid().unwrap(),
451 "content_cid MUST ignore the sparse sidecar - that is the G17 contract"
452 );
453
454 let mut c = sample();
458 c.sparse = None;
459 let mut d = sample();
460 d.sparse = Some(raw(300));
461 assert_eq!(
462 c.content_cid().unwrap(),
463 d.content_cid().unwrap(),
464 "absence of sparse must not change content_cid either"
465 );
466 }
467
468 #[test]
472 fn commit_with_sparse_some_round_trips() {
473 let mut original = sample();
474 original.sparse = Some(raw(42));
475 let bytes = to_canonical_bytes(&original).unwrap();
476 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
477 assert_eq!(decoded.sparse, Some(raw(42)));
478 let bytes2 = to_canonical_bytes(&decoded).unwrap();
479 assert_eq!(
480 bytes, bytes2,
481 "round-trip must be byte-identical - wire form is contract-bound"
482 );
483 }
484
485 #[test]
489 fn commit_legacy_no_sparse_key_round_trips() {
490 let original = sample();
491 assert_eq!(original.sparse, None);
492 let bytes = to_canonical_bytes(&original).unwrap();
493
494 assert!(
496 !bytes.windows(b"sparse".len()).any(|w| w == b"sparse"),
497 "wire form must omit the `sparse` key when None"
498 );
499
500 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
501 assert_eq!(decoded.sparse, None);
502
503 let bytes2 = to_canonical_bytes(&decoded).unwrap();
504 assert_eq!(bytes, bytes2, "legacy CBOR must re-encode byte-identically");
505 }
506
507 #[test]
511 fn commit_with_embeddings_some_round_trips() {
512 let mut original = sample();
513 original.embeddings = Some(raw(42));
514 let bytes = to_canonical_bytes(&original).unwrap();
515 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
516 assert_eq!(original, decoded);
517 assert_eq!(decoded.embeddings, Some(raw(42)));
518 let bytes2 = to_canonical_bytes(&decoded).unwrap();
519 assert_eq!(
520 bytes, bytes2,
521 "round-trip must be byte-identical - wire form is contract-bound"
522 );
523 }
524
525 #[test]
530 fn commit_legacy_no_embeddings_key_round_trips() {
531 let original = sample();
534 assert_eq!(original.embeddings, None);
535 let bytes = to_canonical_bytes(&original).unwrap();
536
537 assert!(
541 !bytes
542 .windows(b"embeddings".len())
543 .any(|w| w == b"embeddings"),
544 "wire form must omit the `embeddings` key when None"
545 );
546
547 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
549 assert_eq!(decoded.embeddings, None);
550 assert_eq!(decoded, original);
551
552 let bytes2 = to_canonical_bytes(&decoded).unwrap();
554 assert_eq!(bytes, bytes2, "legacy CBOR must re-encode byte-identically");
555 }
556
557 #[test]
558 fn commit_kind_rejection() {
559 let wire = CommitWire {
560 kind: "node".into(),
561 change_id: ChangeId::from_bytes_raw([1u8; 16]),
562 parents: vec![],
563 nodes: raw(1),
564 edges: raw(2),
565 schema: raw(3),
566 delta: None,
567 indexes: None,
568 embeddings: None,
569 sparse: None,
570 author: "x".into(),
571 agent_id: None,
572 task_id: None,
573 time: 0,
574 message: String::new(),
575 signature: None,
576 extra: BTreeMap::new(),
577 };
578 let bytes = serde_ipld_dagcbor::to_vec(&wire).unwrap();
579 let err = serde_ipld_dagcbor::from_slice::<Commit>(&bytes).unwrap_err();
580 assert!(err.to_string().contains("_kind"));
581 }
582
583 #[test]
584 fn commit_with_parents_round_trip() {
585 let c = sample().with_parent(raw(100)).with_parent(raw(101));
586 let bytes = to_canonical_bytes(&c).unwrap();
587 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
588 assert_eq!(c, decoded);
589 assert_eq!(decoded.parents.len(), 2);
590 }
591
592 #[test]
593 fn commit_with_signature_round_trip() {
594 let mut c = sample();
595 c.signature = Some(Signature {
596 algo: "ed25519".into(),
597 public_key: Bytes::from(vec![0xAAu8; 32]),
598 sig: Bytes::from(vec![0xBBu8; 64]),
599 });
600 let bytes = to_canonical_bytes(&c).unwrap();
601 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
602 assert_eq!(c, decoded);
603 assert_eq!(decoded.signature.as_ref().unwrap().algo, "ed25519");
604 }
605
606 #[test]
607 fn commit_extra_fields_preserved() {
608 let mut c = sample();
609 c.extra
610 .insert("x-future-field".into(), Ipld::String("v9".into()));
611 let bytes = to_canonical_bytes(&c).unwrap();
612 let decoded: Commit = from_canonical_bytes(&bytes).unwrap();
613 assert_eq!(c, decoded);
614 let bytes2 = to_canonical_bytes(&decoded).unwrap();
615 assert_eq!(bytes, bytes2);
616 }
617}