Skip to main content

libgrite_git/
chunk.rs

1//! CBOR chunk encoding/decoding for portable event storage
2//!
3//! Chunk format:
4//! - Magic: `GRITECNK` (8 bytes)
5//! - Version: u16 (little-endian)
6//! - Codec length: u8
7//! - Codec: "cbor-v1"
8//! - Payload: CBOR array of events
9
10use blake2::digest::consts::U32;
11use blake2::{Blake2b, Digest};
12use ciborium::Value;
13use libgrite_core::types::event::{DependencyType, Event, EventKind, IssueState, SymbolInfo};
14use libgrite_core::types::ids::{ActorId, EventId, IssueId};
15
16use crate::GitError;
17
18/// Magic bytes at start of chunk
19pub const CHUNK_MAGIC: &[u8; 8] = b"GRITECNK";
20
21/// Current chunk format version
22pub const CHUNK_VERSION: u16 = 1;
23
24/// Codec identifier
25pub const CHUNK_CODEC: &str = "cbor-v1";
26
27/// Encode a list of events into a chunk
28pub fn encode_chunk(events: &[Event]) -> Result<Vec<u8>, GitError> {
29    let mut buf = Vec::new();
30
31    // Magic
32    buf.extend_from_slice(CHUNK_MAGIC);
33
34    // Version (little-endian u16)
35    buf.extend_from_slice(&CHUNK_VERSION.to_le_bytes());
36
37    // Codec length and codec string
38    let codec_bytes = CHUNK_CODEC.as_bytes();
39    buf.push(codec_bytes.len() as u8);
40    buf.extend_from_slice(codec_bytes);
41
42    // Encode events as CBOR array
43    let events_value = events_to_cbor(events);
44    ciborium::into_writer(&events_value, &mut buf)
45        .map_err(|e| GitError::CborDecode(format!("Failed to encode events: {}", e)))?;
46
47    Ok(buf)
48}
49
50/// Decode a chunk into a list of events
51pub fn decode_chunk(data: &[u8]) -> Result<Vec<Event>, GitError> {
52    // Check minimum size
53    if data.len() < 8 + 2 + 1 {
54        return Err(GitError::InvalidChunk("Chunk too small".to_string()));
55    }
56
57    // Verify magic
58    if &data[0..8] != CHUNK_MAGIC {
59        return Err(GitError::InvalidChunk("Invalid magic bytes".to_string()));
60    }
61
62    // Read version
63    let version = u16::from_le_bytes([data[8], data[9]]);
64    if version != CHUNK_VERSION {
65        return Err(GitError::InvalidChunk(format!(
66            "Unsupported chunk version: {}",
67            version
68        )));
69    }
70
71    // Read codec
72    let codec_len = data[10] as usize;
73    if data.len() < 11 + codec_len {
74        return Err(GitError::InvalidChunk(
75            "Chunk truncated at codec".to_string(),
76        ));
77    }
78    let codec = std::str::from_utf8(&data[11..11 + codec_len])
79        .map_err(|_| GitError::InvalidChunk("Invalid codec string".to_string()))?;
80    if codec != CHUNK_CODEC {
81        return Err(GitError::InvalidChunk(format!(
82            "Unsupported codec: {}",
83            codec
84        )));
85    }
86
87    // Parse CBOR payload
88    let payload_start = 11 + codec_len;
89    let value: Value = ciborium::from_reader(&data[payload_start..])
90        .map_err(|e| GitError::CborDecode(format!("Failed to decode CBOR: {}", e)))?;
91
92    cbor_to_events(value)
93}
94
95/// Compute BLAKE2b-256 hash of chunk data
96pub fn chunk_hash(data: &[u8]) -> [u8; 32] {
97    let mut hasher = Blake2b::<U32>::new();
98    hasher.update(data);
99    hasher.finalize().into()
100}
101
102/// Convert events to CBOR value
103fn events_to_cbor(events: &[Event]) -> Value {
104    let events_array: Vec<Value> = events.iter().map(event_to_cbor).collect();
105    Value::Array(events_array)
106}
107
108/// Convert a single event to CBOR
109/// Format: [event_id, issue_id, actor, ts, parent, kind_tag, kind_payload, sig]
110fn event_to_cbor(event: &Event) -> Value {
111    let (kind_tag, kind_payload) = libgrite_core::hash::kind_to_tag_and_payload(&event.kind);
112
113    let parent_value = match &event.parent {
114        Some(p) => Value::Bytes(p.to_vec()),
115        None => Value::Null,
116    };
117
118    let sig_value = match &event.sig {
119        Some(s) => Value::Bytes(s.clone()),
120        None => Value::Null,
121    };
122
123    Value::Array(vec![
124        Value::Bytes(event.event_id.to_vec()),
125        Value::Bytes(event.issue_id.to_vec()),
126        Value::Bytes(event.actor.to_vec()),
127        Value::Integer(event.ts_unix_ms.into()),
128        parent_value,
129        Value::Integer(kind_tag.into()),
130        kind_payload,
131        sig_value,
132    ])
133}
134
135/// Convert CBOR value to events
136fn cbor_to_events(value: Value) -> Result<Vec<Event>, GitError> {
137    let array = match value {
138        Value::Array(arr) => arr,
139        _ => {
140            return Err(GitError::InvalidChunk(
141                "Expected array of events".to_string(),
142            ))
143        }
144    };
145
146    array.into_iter().map(cbor_to_event).collect()
147}
148
149/// Convert a single CBOR value to an Event
150fn cbor_to_event(value: Value) -> Result<Event, GitError> {
151    let array = match value {
152        Value::Array(arr) => arr,
153        _ => return Err(GitError::InvalidEvent("Expected event array".to_string())),
154    };
155
156    if array.len() != 8 {
157        return Err(GitError::InvalidEvent(format!(
158            "Expected 8 elements, got {}",
159            array.len()
160        )));
161    }
162
163    let mut iter = array.into_iter();
164
165    // event_id
166    let event_id: EventId = extract_bytes(&next_item(&mut iter, "event_id")?, "event_id", 32)?
167        .try_into()
168        .map_err(|_| GitError::InvalidEvent("Invalid event_id length".to_string()))?;
169
170    // issue_id
171    let issue_id: IssueId = extract_bytes(&next_item(&mut iter, "issue_id")?, "issue_id", 16)?
172        .try_into()
173        .map_err(|_| GitError::InvalidEvent("Invalid issue_id length".to_string()))?;
174
175    // actor
176    let actor: ActorId = extract_bytes(&next_item(&mut iter, "actor")?, "actor", 16)?
177        .try_into()
178        .map_err(|_| GitError::InvalidEvent("Invalid actor length".to_string()))?;
179
180    // ts_unix_ms
181    let ts_unix_ms = extract_u64(&next_item(&mut iter, "ts_unix_ms")?, "ts_unix_ms")?;
182
183    // parent
184    let parent_value = next_item(&mut iter, "parent")?;
185    let parent: Option<EventId> = match parent_value {
186        Value::Null => None,
187        Value::Bytes(b) => {
188            let arr: EventId = b
189                .try_into()
190                .map_err(|_| GitError::InvalidEvent("Invalid parent length".to_string()))?;
191            Some(arr)
192        }
193        _ => return Err(GitError::InvalidEvent("Invalid parent type".to_string())),
194    };
195
196    // kind_tag
197    let kind_tag = extract_u32(&next_item(&mut iter, "kind_tag")?, "kind_tag")?;
198
199    // kind_payload
200    let kind_payload = next_item(&mut iter, "kind_payload")?;
201
202    // sig
203    let sig_value = next_item(&mut iter, "sig")?;
204    let sig: Option<Vec<u8>> = match sig_value {
205        Value::Null => None,
206        Value::Bytes(b) => Some(b),
207        _ => return Err(GitError::InvalidEvent("Invalid sig type".to_string())),
208    };
209
210    // Parse kind from tag and payload
211    let kind = parse_event_kind(kind_tag, kind_payload)?;
212
213    Ok(Event {
214        event_id,
215        issue_id,
216        actor,
217        ts_unix_ms,
218        parent,
219        kind,
220        sig,
221    })
222}
223
224/// Parse EventKind from tag and payload
225fn parse_event_kind(tag: u32, payload: Value) -> Result<EventKind, GitError> {
226    let array = match payload {
227        Value::Array(arr) => arr,
228        _ => {
229            return Err(GitError::InvalidEvent(
230                "Expected kind payload array".to_string(),
231            ))
232        }
233    };
234
235    match tag {
236        1 => {
237            // IssueCreated { title, body, labels }
238            if array.len() != 3 {
239                return Err(GitError::InvalidEvent(
240                    "IssueCreated expects 3 fields".to_string(),
241                ));
242            }
243            let mut iter = array.into_iter();
244            let title = extract_string(&next_item(&mut iter, "title")?, "title")?;
245            let body = extract_string(&next_item(&mut iter, "body")?, "body")?;
246            let labels = extract_string_array(&next_item(&mut iter, "labels")?, "labels")?;
247            Ok(EventKind::IssueCreated {
248                title,
249                body,
250                labels,
251            })
252        }
253        2 => {
254            // IssueUpdated { title, body }
255            if array.len() != 2 {
256                return Err(GitError::InvalidEvent(
257                    "IssueUpdated expects 2 fields".to_string(),
258                ));
259            }
260            let mut iter = array.into_iter();
261            let title = extract_optional_string(&next_item(&mut iter, "title")?, "title")?;
262            let body = extract_optional_string(&next_item(&mut iter, "body")?, "body")?;
263            Ok(EventKind::IssueUpdated { title, body })
264        }
265        3 => {
266            // CommentAdded { body }
267            if array.len() != 1 {
268                return Err(GitError::InvalidEvent(
269                    "CommentAdded expects 1 field".to_string(),
270                ));
271            }
272            let mut iter = array.into_iter();
273            let body = extract_string(&next_item(&mut iter, "body")?, "body")?;
274            Ok(EventKind::CommentAdded { body })
275        }
276        4 => {
277            // LabelAdded { label }
278            if array.len() != 1 {
279                return Err(GitError::InvalidEvent(
280                    "LabelAdded expects 1 field".to_string(),
281                ));
282            }
283            let mut iter = array.into_iter();
284            let label = extract_string(&next_item(&mut iter, "label")?, "label")?;
285            Ok(EventKind::LabelAdded { label })
286        }
287        5 => {
288            // LabelRemoved { label }
289            if array.len() != 1 {
290                return Err(GitError::InvalidEvent(
291                    "LabelRemoved expects 1 field".to_string(),
292                ));
293            }
294            let mut iter = array.into_iter();
295            let label = extract_string(&next_item(&mut iter, "label")?, "label")?;
296            Ok(EventKind::LabelRemoved { label })
297        }
298        6 => {
299            // StateChanged { state }
300            if array.len() != 1 {
301                return Err(GitError::InvalidEvent(
302                    "StateChanged expects 1 field".to_string(),
303                ));
304            }
305            let mut iter = array.into_iter();
306            let state_str = extract_string(&next_item(&mut iter, "state")?, "state")?;
307            let state = match state_str.as_str() {
308                "open" => IssueState::Open,
309                "closed" => IssueState::Closed,
310                _ => {
311                    return Err(GitError::InvalidEvent(format!(
312                        "Invalid state: {}",
313                        state_str
314                    )))
315                }
316            };
317            Ok(EventKind::StateChanged { state })
318        }
319        7 => {
320            // LinkAdded { url, note }
321            if array.len() != 2 {
322                return Err(GitError::InvalidEvent(
323                    "LinkAdded expects 2 fields".to_string(),
324                ));
325            }
326            let mut iter = array.into_iter();
327            let url = extract_string(&next_item(&mut iter, "url")?, "url")?;
328            let note = extract_optional_string(&next_item(&mut iter, "note")?, "note")?;
329            Ok(EventKind::LinkAdded { url, note })
330        }
331        8 => {
332            // AssigneeAdded { user }
333            if array.len() != 1 {
334                return Err(GitError::InvalidEvent(
335                    "AssigneeAdded expects 1 field".to_string(),
336                ));
337            }
338            let mut iter = array.into_iter();
339            let user = extract_string(&next_item(&mut iter, "user")?, "user")?;
340            Ok(EventKind::AssigneeAdded { user })
341        }
342        9 => {
343            // AssigneeRemoved { user }
344            if array.len() != 1 {
345                return Err(GitError::InvalidEvent(
346                    "AssigneeRemoved expects 1 field".to_string(),
347                ));
348            }
349            let mut iter = array.into_iter();
350            let user = extract_string(&next_item(&mut iter, "user")?, "user")?;
351            Ok(EventKind::AssigneeRemoved { user })
352        }
353        10 => {
354            // AttachmentAdded { name, sha256, mime }
355            if array.len() != 3 {
356                return Err(GitError::InvalidEvent(
357                    "AttachmentAdded expects 3 fields".to_string(),
358                ));
359            }
360            let mut iter = array.into_iter();
361            let name = extract_string(&next_item(&mut iter, "name")?, "name")?;
362            let sha256: [u8; 32] = extract_bytes(&next_item(&mut iter, "sha256")?, "sha256", 32)?
363                .try_into()
364                .map_err(|_| GitError::InvalidEvent("Invalid sha256 length".to_string()))?;
365            let mime = extract_string(&next_item(&mut iter, "mime")?, "mime")?;
366            Ok(EventKind::AttachmentAdded { name, sha256, mime })
367        }
368        11 => {
369            // DependencyAdded { target, dep_type }
370            if array.len() != 2 {
371                return Err(GitError::InvalidEvent(
372                    "DependencyAdded expects 2 fields".to_string(),
373                ));
374            }
375            let mut iter = array.into_iter();
376            let target: IssueId = extract_bytes(&next_item(&mut iter, "target")?, "target", 16)?
377                .try_into()
378                .map_err(|_| GitError::InvalidEvent("Invalid target length".to_string()))?;
379            let dep_type_str = extract_string(&next_item(&mut iter, "dep_type")?, "dep_type")?;
380            let dep_type = DependencyType::from_str(&dep_type_str).ok_or_else(|| {
381                GitError::InvalidEvent(format!("Invalid dep_type: {}", dep_type_str))
382            })?;
383            Ok(EventKind::DependencyAdded { target, dep_type })
384        }
385        12 => {
386            // DependencyRemoved { target, dep_type }
387            if array.len() != 2 {
388                return Err(GitError::InvalidEvent(
389                    "DependencyRemoved expects 2 fields".to_string(),
390                ));
391            }
392            let mut iter = array.into_iter();
393            let target: IssueId = extract_bytes(&next_item(&mut iter, "target")?, "target", 16)?
394                .try_into()
395                .map_err(|_| GitError::InvalidEvent("Invalid target length".to_string()))?;
396            let dep_type_str = extract_string(&next_item(&mut iter, "dep_type")?, "dep_type")?;
397            let dep_type = DependencyType::from_str(&dep_type_str).ok_or_else(|| {
398                GitError::InvalidEvent(format!("Invalid dep_type: {}", dep_type_str))
399            })?;
400            Ok(EventKind::DependencyRemoved { target, dep_type })
401        }
402        13 => {
403            // ContextUpdated { path, language, symbols, summary, content_hash }
404            if array.len() != 5 {
405                return Err(GitError::InvalidEvent(
406                    "ContextUpdated expects 5 fields".to_string(),
407                ));
408            }
409            let mut iter = array.into_iter();
410            let path = extract_string(&next_item(&mut iter, "path")?, "path")?;
411            let language = extract_string(&next_item(&mut iter, "language")?, "language")?;
412            let symbols_value = next_item(&mut iter, "symbols")?;
413            let symbols = parse_symbols(symbols_value)?;
414            let summary = extract_string(&next_item(&mut iter, "summary")?, "summary")?;
415            let content_hash: [u8; 32] =
416                extract_bytes(&next_item(&mut iter, "content_hash")?, "content_hash", 32)?
417                    .try_into()
418                    .map_err(|_| {
419                        GitError::InvalidEvent("Invalid content_hash length".to_string())
420                    })?;
421            Ok(EventKind::ContextUpdated {
422                path,
423                language,
424                symbols,
425                summary,
426                content_hash,
427            })
428        }
429        14 => {
430            // ProjectContextUpdated { key, value }
431            if array.len() != 2 {
432                return Err(GitError::InvalidEvent(
433                    "ProjectContextUpdated expects 2 fields".to_string(),
434                ));
435            }
436            let mut iter = array.into_iter();
437            let key = extract_string(&next_item(&mut iter, "key")?, "key")?;
438            let value = extract_string(&next_item(&mut iter, "value")?, "value")?;
439            Ok(EventKind::ProjectContextUpdated { key, value })
440        }
441        _ => Err(GitError::InvalidEvent(format!("Unknown kind tag: {}", tag))),
442    }
443}
444
445/// Parse a CBOR array of symbols into Vec<SymbolInfo>
446fn parse_symbols(value: Value) -> Result<Vec<SymbolInfo>, GitError> {
447    let array = match value {
448        Value::Array(arr) => arr,
449        _ => return Err(GitError::InvalidEvent("symbols must be array".to_string())),
450    };
451    array
452        .into_iter()
453        .map(|sym_value| {
454            let sym_arr = match sym_value {
455                Value::Array(arr) => arr,
456                _ => return Err(GitError::InvalidEvent("symbol must be array".to_string())),
457            };
458            if sym_arr.len() != 4 {
459                return Err(GitError::InvalidEvent(
460                    "symbol expects 4 fields".to_string(),
461                ));
462            }
463            let mut iter = sym_arr.into_iter();
464            let name = extract_string(&next_item(&mut iter, "symbol.name")?, "symbol.name")?;
465            let kind = extract_string(&next_item(&mut iter, "symbol.kind")?, "symbol.kind")?;
466            let line_start = extract_u32(
467                &next_item(&mut iter, "symbol.line_start")?,
468                "symbol.line_start",
469            )?;
470            let line_end =
471                extract_u32(&next_item(&mut iter, "symbol.line_end")?, "symbol.line_end")?;
472            Ok(SymbolInfo {
473                name,
474                kind,
475                line_start,
476                line_end,
477            })
478        })
479        .collect()
480}
481
482// Helper functions for extracting values from CBOR
483
484fn next_item(iter: &mut impl Iterator<Item = Value>, field: &str) -> Result<Value, GitError> {
485    iter.next()
486        .ok_or_else(|| GitError::InvalidEvent(format!("missing field: {}", field)))
487}
488
489fn extract_bytes(value: &Value, field: &str, expected_len: usize) -> Result<Vec<u8>, GitError> {
490    match value {
491        Value::Bytes(b) => {
492            if b.len() != expected_len {
493                return Err(GitError::InvalidEvent(format!(
494                    "{} has wrong length: expected {}, got {}",
495                    field,
496                    expected_len,
497                    b.len()
498                )));
499            }
500            Ok(b.clone())
501        }
502        _ => Err(GitError::InvalidEvent(format!("{} must be bytes", field))),
503    }
504}
505
506fn extract_u64(value: &Value, field: &str) -> Result<u64, GitError> {
507    match value {
508        Value::Integer(i) => {
509            let n: i128 = (*i).into();
510            if n < 0 || n > u64::MAX as i128 {
511                return Err(GitError::InvalidEvent(format!("{} out of range", field)));
512            }
513            Ok(n as u64)
514        }
515        _ => Err(GitError::InvalidEvent(format!("{} must be integer", field))),
516    }
517}
518
519fn extract_u32(value: &Value, field: &str) -> Result<u32, GitError> {
520    match value {
521        Value::Integer(i) => {
522            let n: i128 = (*i).into();
523            if n < 0 || n > u32::MAX as i128 {
524                return Err(GitError::InvalidEvent(format!("{} out of range", field)));
525            }
526            Ok(n as u32)
527        }
528        _ => Err(GitError::InvalidEvent(format!("{} must be integer", field))),
529    }
530}
531
532fn extract_string(value: &Value, field: &str) -> Result<String, GitError> {
533    match value {
534        Value::Text(s) => Ok(s.clone()),
535        _ => Err(GitError::InvalidEvent(format!("{} must be string", field))),
536    }
537}
538
539fn extract_optional_string(value: &Value, field: &str) -> Result<Option<String>, GitError> {
540    match value {
541        Value::Null => Ok(None),
542        Value::Text(s) => Ok(Some(s.clone())),
543        _ => Err(GitError::InvalidEvent(format!(
544            "{} must be string or null",
545            field
546        ))),
547    }
548}
549
550fn extract_string_array(value: &Value, field: &str) -> Result<Vec<String>, GitError> {
551    match value {
552        Value::Array(arr) => arr.iter().map(|v| extract_string(v, field)).collect(),
553        _ => Err(GitError::InvalidEvent(format!("{} must be array", field))),
554    }
555}
556
557#[cfg(test)]
558mod tests {
559    use super::*;
560    use libgrite_core::hash::compute_event_id;
561    use libgrite_core::types::ids::generate_issue_id;
562
563    fn make_test_event(kind: EventKind) -> Event {
564        let issue_id = generate_issue_id();
565        let actor = [1u8; 16];
566        let ts_unix_ms = 1700000000000u64;
567        let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
568        Event::new(event_id, issue_id, actor, ts_unix_ms, None, kind)
569    }
570
571    #[test]
572    fn test_chunk_roundtrip_issue_created() {
573        let event = make_test_event(EventKind::IssueCreated {
574            title: "Test Issue".to_string(),
575            body: "Test body".to_string(),
576            labels: vec!["bug".to_string(), "p0".to_string()],
577        });
578
579        let chunk = encode_chunk(std::slice::from_ref(&event)).unwrap();
580
581        // Verify magic
582        assert_eq!(&chunk[0..8], CHUNK_MAGIC);
583
584        // Decode and verify
585        let decoded = decode_chunk(&chunk).unwrap();
586        assert_eq!(decoded.len(), 1);
587        assert_eq!(decoded[0].event_id, event.event_id);
588        assert_eq!(decoded[0].issue_id, event.issue_id);
589        assert_eq!(decoded[0].actor, event.actor);
590        assert_eq!(decoded[0].ts_unix_ms, event.ts_unix_ms);
591
592        if let EventKind::IssueCreated {
593            title,
594            body,
595            labels,
596        } = &decoded[0].kind
597        {
598            assert_eq!(title, "Test Issue");
599            assert_eq!(body, "Test body");
600            assert!(labels.contains(&"bug".to_string()));
601            assert!(labels.contains(&"p0".to_string()));
602        } else {
603            panic!("Wrong event kind");
604        }
605    }
606
607    #[test]
608    fn test_chunk_roundtrip_all_kinds() {
609        let events = vec![
610            make_test_event(EventKind::IssueCreated {
611                title: "Test".to_string(),
612                body: "Body".to_string(),
613                labels: vec![],
614            }),
615            make_test_event(EventKind::IssueUpdated {
616                title: Some("New Title".to_string()),
617                body: None,
618            }),
619            make_test_event(EventKind::CommentAdded {
620                body: "A comment".to_string(),
621            }),
622            make_test_event(EventKind::LabelAdded {
623                label: "bug".to_string(),
624            }),
625            make_test_event(EventKind::LabelRemoved {
626                label: "wip".to_string(),
627            }),
628            make_test_event(EventKind::StateChanged {
629                state: IssueState::Closed,
630            }),
631            make_test_event(EventKind::LinkAdded {
632                url: "https://example.com".to_string(),
633                note: Some("ref".to_string()),
634            }),
635            make_test_event(EventKind::AssigneeAdded {
636                user: "alice".to_string(),
637            }),
638            make_test_event(EventKind::AssigneeRemoved {
639                user: "bob".to_string(),
640            }),
641            make_test_event(EventKind::AttachmentAdded {
642                name: "file.txt".to_string(),
643                sha256: [0u8; 32],
644                mime: "text/plain".to_string(),
645            }),
646            make_test_event(EventKind::DependencyAdded {
647                target: [0xAA; 16],
648                dep_type: DependencyType::Blocks,
649            }),
650            make_test_event(EventKind::DependencyRemoved {
651                target: [0xBB; 16],
652                dep_type: DependencyType::DependsOn,
653            }),
654            make_test_event(EventKind::ContextUpdated {
655                path: "src/main.rs".to_string(),
656                language: "rust".to_string(),
657                symbols: vec![SymbolInfo {
658                    name: "main".to_string(),
659                    kind: "function".to_string(),
660                    line_start: 1,
661                    line_end: 10,
662                }],
663                summary: "Entry point".to_string(),
664                content_hash: [0xCC; 32],
665            }),
666            make_test_event(EventKind::ProjectContextUpdated {
667                key: "framework".to_string(),
668                value: "actix-web".to_string(),
669            }),
670        ];
671
672        let chunk = encode_chunk(&events).unwrap();
673        let decoded = decode_chunk(&chunk).unwrap();
674
675        assert_eq!(decoded.len(), events.len());
676        for (orig, dec) in events.iter().zip(decoded.iter()) {
677            assert_eq!(orig.event_id, dec.event_id);
678            assert_eq!(orig.kind, dec.kind);
679        }
680    }
681
682    #[test]
683    fn test_chunk_hash_deterministic() {
684        let event = make_test_event(EventKind::IssueCreated {
685            title: "Test".to_string(),
686            body: "Body".to_string(),
687            labels: vec![],
688        });
689
690        let chunk1 = encode_chunk(std::slice::from_ref(&event)).unwrap();
691        let chunk2 = encode_chunk(&[event]).unwrap();
692
693        let hash1 = chunk_hash(&chunk1);
694        let hash2 = chunk_hash(&chunk2);
695
696        assert_eq!(hash1, hash2);
697    }
698
699    #[test]
700    fn test_invalid_chunk_magic() {
701        let data = b"BADMAGIC\x01\x00\x07cbor-v1";
702        let result = decode_chunk(data);
703        assert!(matches!(result, Err(GitError::InvalidChunk(_))));
704    }
705
706    #[test]
707    fn test_invalid_chunk_version() {
708        let mut data = Vec::new();
709        data.extend_from_slice(CHUNK_MAGIC);
710        data.extend_from_slice(&99u16.to_le_bytes()); // Bad version
711        data.push(7);
712        data.extend_from_slice(b"cbor-v1");
713
714        let result = decode_chunk(&data);
715        assert!(matches!(result, Err(GitError::InvalidChunk(_))));
716    }
717}