1use 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
18pub const CHUNK_MAGIC: &[u8; 8] = b"GRITECNK";
20
21pub const CHUNK_VERSION: u16 = 1;
23
24pub const CHUNK_CODEC: &str = "cbor-v1";
26
27pub fn encode_chunk(events: &[Event]) -> Result<Vec<u8>, GitError> {
29 let mut buf = Vec::new();
30
31 buf.extend_from_slice(CHUNK_MAGIC);
33
34 buf.extend_from_slice(&CHUNK_VERSION.to_le_bytes());
36
37 let codec_bytes = CHUNK_CODEC.as_bytes();
39 buf.push(codec_bytes.len() as u8);
40 buf.extend_from_slice(codec_bytes);
41
42 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
50pub fn decode_chunk(data: &[u8]) -> Result<Vec<Event>, GitError> {
52 if data.len() < 8 + 2 + 1 {
54 return Err(GitError::InvalidChunk("Chunk too small".to_string()));
55 }
56
57 if &data[0..8] != CHUNK_MAGIC {
59 return Err(GitError::InvalidChunk("Invalid magic bytes".to_string()));
60 }
61
62 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 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 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
95pub fn chunk_hash(data: &[u8]) -> [u8; 32] {
97 let mut hasher = Blake2b::<U32>::new();
98 hasher.update(data);
99 hasher.finalize().into()
100}
101
102fn 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
108fn 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
135fn 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
149fn 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 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 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 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 let ts_unix_ms = extract_u64(&next_item(&mut iter, "ts_unix_ms")?, "ts_unix_ms")?;
182
183 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 let kind_tag = extract_u32(&next_item(&mut iter, "kind_tag")?, "kind_tag")?;
198
199 let kind_payload = next_item(&mut iter, "kind_payload")?;
201
202 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 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
224fn 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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
445fn 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
482fn 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 assert_eq!(&chunk[0..8], CHUNK_MAGIC);
583
584 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()); 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}