1use blake2::digest::consts::U32;
2use blake2::{Blake2b, Digest};
3use ciborium::Value;
4
5use crate::types::event::EventKind;
6use crate::types::ids::{ActorId, EventId, IssueId};
7
8pub const SCHEMA_VERSION: u8 = 1;
10
11pub fn compute_event_id(
13 issue_id: &IssueId,
14 actor: &ActorId,
15 ts_unix_ms: u64,
16 parent: Option<&EventId>,
17 kind: &EventKind,
18) -> EventId {
19 let preimage = build_canonical_cbor(issue_id, actor, ts_unix_ms, parent, kind);
20 let mut hasher = Blake2b::<U32>::new();
21 hasher.update(&preimage);
22 hasher.finalize().into()
23}
24
25pub fn build_canonical_cbor(
28 issue_id: &IssueId,
29 actor: &ActorId,
30 ts_unix_ms: u64,
31 parent: Option<&EventId>,
32 kind: &EventKind,
33) -> Vec<u8> {
34 let (kind_tag, kind_payload) = kind_to_tag_and_payload(kind);
35
36 let parent_value = match parent {
37 Some(p) => Value::Bytes(p.to_vec()),
38 None => Value::Null,
39 };
40
41 let array = Value::Array(vec![
42 Value::Integer(SCHEMA_VERSION.into()),
43 Value::Bytes(issue_id.to_vec()),
44 Value::Bytes(actor.to_vec()),
45 Value::Integer(ts_unix_ms.into()),
46 parent_value,
47 Value::Integer(kind_tag.into()),
48 kind_payload,
49 ]);
50
51 let mut buf = Vec::new();
52 ciborium::into_writer(&array, &mut buf)
55 .expect("CBOR serialization of known-safe types should not fail");
56 buf
57}
58
59pub fn kind_to_tag_and_payload(kind: &EventKind) -> (u32, ciborium::Value) {
62 match kind {
63 EventKind::IssueCreated {
64 title,
65 body,
66 labels,
67 } => {
68 let mut sorted_labels = labels.clone();
70 sorted_labels.sort();
71 let labels_value = Value::Array(sorted_labels.into_iter().map(Value::Text).collect());
72 (
73 1,
74 Value::Array(vec![
75 Value::Text(title.clone()),
76 Value::Text(body.clone()),
77 labels_value,
78 ]),
79 )
80 }
81 EventKind::IssueUpdated { title, body } => {
82 let title_value = match title {
83 Some(t) => Value::Text(t.clone()),
84 None => Value::Null,
85 };
86 let body_value = match body {
87 Some(b) => Value::Text(b.clone()),
88 None => Value::Null,
89 };
90 (2, Value::Array(vec![title_value, body_value]))
91 }
92 EventKind::CommentAdded { body } => (3, Value::Array(vec![Value::Text(body.clone())])),
93 EventKind::LabelAdded { label } => (4, Value::Array(vec![Value::Text(label.clone())])),
94 EventKind::LabelRemoved { label } => (5, Value::Array(vec![Value::Text(label.clone())])),
95 EventKind::StateChanged { state } => (
96 6,
97 Value::Array(vec![Value::Text(state.as_str().to_string())]),
98 ),
99 EventKind::LinkAdded { url, note } => {
100 let note_value = match note {
101 Some(n) => Value::Text(n.clone()),
102 None => Value::Null,
103 };
104 (7, Value::Array(vec![Value::Text(url.clone()), note_value]))
105 }
106 EventKind::AssigneeAdded { user } => (8, Value::Array(vec![Value::Text(user.clone())])),
107 EventKind::AssigneeRemoved { user } => (9, Value::Array(vec![Value::Text(user.clone())])),
108 EventKind::AttachmentAdded { name, sha256, mime } => (
109 10,
110 Value::Array(vec![
111 Value::Text(name.clone()),
112 Value::Bytes(sha256.to_vec()),
113 Value::Text(mime.clone()),
114 ]),
115 ),
116 EventKind::DependencyAdded { target, dep_type } => (
117 11,
118 Value::Array(vec![
119 Value::Bytes(target.to_vec()),
120 Value::Text(dep_type.as_str().to_string()),
121 ]),
122 ),
123 EventKind::DependencyRemoved { target, dep_type } => (
124 12,
125 Value::Array(vec![
126 Value::Bytes(target.to_vec()),
127 Value::Text(dep_type.as_str().to_string()),
128 ]),
129 ),
130 EventKind::ContextUpdated {
131 path,
132 language,
133 symbols,
134 summary,
135 content_hash,
136 } => {
137 let mut sorted_symbols = symbols.clone();
139 sorted_symbols.sort_by(|a, b| (&a.name, &a.kind).cmp(&(&b.name, &b.kind)));
140 let symbols_value = Value::Array(
141 sorted_symbols
142 .iter()
143 .map(|s| {
144 Value::Array(vec![
145 Value::Text(s.name.clone()),
146 Value::Text(s.kind.clone()),
147 Value::Integer(s.line_start.into()),
148 Value::Integer(s.line_end.into()),
149 ])
150 })
151 .collect(),
152 );
153 (
154 13,
155 Value::Array(vec![
156 Value::Text(path.clone()),
157 Value::Text(language.clone()),
158 symbols_value,
159 Value::Text(summary.clone()),
160 Value::Bytes(content_hash.to_vec()),
161 ]),
162 )
163 }
164 EventKind::ProjectContextUpdated { key, value } => (
165 14,
166 Value::Array(vec![Value::Text(key.clone()), Value::Text(value.clone())]),
167 ),
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::types::event::IssueState;
175 use crate::types::ids::hex_to_id;
176
177 #[test]
180 fn test_vector_1_issue_created() {
181 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
182 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
183 let ts_unix_ms: u64 = 1700000000000;
184 let parent: Option<&EventId> = None;
185 let kind = EventKind::IssueCreated {
186 title: "Test".to_string(),
187 body: "Body".to_string(),
188 labels: vec!["bug".to_string(), "p0".to_string()],
189 };
190
191 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
192 let expected_cbor = hex::decode(
193 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56800f60183645465737464426f64798263627567627030"
194 ).unwrap();
195 assert_eq!(
196 hex::encode(&cbor),
197 hex::encode(&expected_cbor),
198 "CBOR mismatch"
199 );
200
201 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
202 let expected_event_id: EventId =
203 hex_to_id("9c2aee7924bf7482dd3842c6ec32fd5103883b9d2354f63df2075ac61fe3d827").unwrap();
204 assert_eq!(event_id, expected_event_id);
205 }
206
207 #[test]
208 fn test_vector_2_issue_updated() {
209 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
210 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
211 let ts_unix_ms: u64 = 1700000000000;
212 let parent: Option<&EventId> = None;
213 let kind = EventKind::IssueUpdated {
214 title: Some("Title 2".to_string()),
215 body: None,
216 };
217
218 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
219 let expected_cbor = hex::decode(
220 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56800f60282675469746c652032f6"
221 ).unwrap();
222 assert_eq!(
223 hex::encode(&cbor),
224 hex::encode(&expected_cbor),
225 "CBOR mismatch"
226 );
227
228 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
229 let expected_event_id: EventId =
230 hex_to_id("5227efec6ae3d41725827edb3e62d00a595784d7adec58fb4e1b787c44c4b333").unwrap();
231 assert_eq!(event_id, expected_event_id);
232 }
233
234 #[test]
235 fn test_vector_3_comment_added() {
236 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
237 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
238 let ts_unix_ms: u64 = 1700000001000;
239 let parent_bytes: EventId =
240 hex_to_id("202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f").unwrap();
241 let parent = Some(&parent_bytes);
242 let kind = EventKind::CommentAdded {
243 body: "Looks good".to_string(),
244 };
245
246 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
247 let expected_cbor = hex::decode(
248 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56be85820202122232425262728292a2b2c2d2e2f303132333435363738393a3b3c3d3e3f03816a4c6f6f6b7320676f6f64"
249 ).unwrap();
250 assert_eq!(
251 hex::encode(&cbor),
252 hex::encode(&expected_cbor),
253 "CBOR mismatch"
254 );
255
256 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
257 let expected_event_id: EventId =
258 hex_to_id("fca597420160df9f7230b28384a27dc86656b206520e5c8085e78cbb02a46e27").unwrap();
259 assert_eq!(event_id, expected_event_id);
260 }
261
262 #[test]
263 fn test_vector_4_label_added() {
264 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
265 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
266 let ts_unix_ms: u64 = 1700000002000;
267 let parent: Option<&EventId> = None;
268 let kind = EventKind::LabelAdded {
269 label: "bug".to_string(),
270 };
271
272 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
273 let expected_cbor = hex::decode(
274 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe56fd0f6048163627567"
275 ).unwrap();
276 assert_eq!(
277 hex::encode(&cbor),
278 hex::encode(&expected_cbor),
279 "CBOR mismatch"
280 );
281
282 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
283 let expected_event_id: EventId =
284 hex_to_id("d742a0d9c83f17176e30511d62045686b491ddf55f8d1dfe7a74921787bdd436").unwrap();
285 assert_eq!(event_id, expected_event_id);
286 }
287
288 #[test]
289 fn test_vector_5_label_removed() {
290 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
291 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
292 let ts_unix_ms: u64 = 1700000003000;
293 let parent: Option<&EventId> = None;
294 let kind = EventKind::LabelRemoved {
295 label: "wip".to_string(),
296 };
297
298 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
299 let expected_cbor = hex::decode(
300 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe573b8f6058163776970"
301 ).unwrap();
302 assert_eq!(
303 hex::encode(&cbor),
304 hex::encode(&expected_cbor),
305 "CBOR mismatch"
306 );
307
308 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
309 let expected_event_id: EventId =
310 hex_to_id("f23e9c69c3fa4cd2889e57fe1c547630afa132052197a5fe449e6d5acf22c40c").unwrap();
311 assert_eq!(event_id, expected_event_id);
312 }
313
314 #[test]
315 fn test_vector_6_state_changed() {
316 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
317 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
318 let ts_unix_ms: u64 = 1700000004000;
319 let parent: Option<&EventId> = None;
320 let kind = EventKind::StateChanged {
321 state: IssueState::Closed,
322 };
323
324 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
325 let expected_cbor = hex::decode(
326 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe577a0f6068166636c6f736564"
327 ).unwrap();
328 assert_eq!(
329 hex::encode(&cbor),
330 hex::encode(&expected_cbor),
331 "CBOR mismatch"
332 );
333
334 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
335 let expected_event_id: EventId =
336 hex_to_id("839ae6d0898f48efcc7a41fdbb9631e64ba1f05a6c1725fc196971bfd1645b2b").unwrap();
337 assert_eq!(event_id, expected_event_id);
338 }
339
340 #[test]
341 fn test_vector_7_link_added() {
342 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
343 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
344 let ts_unix_ms: u64 = 1700000005000;
345 let parent: Option<&EventId> = None;
346 let kind = EventKind::LinkAdded {
347 url: "https://example.com".to_string(),
348 note: Some("ref".to_string()),
349 };
350
351 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
352 let expected_cbor = hex::decode(
353 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe57b88f607827368747470733a2f2f6578616d706c652e636f6d63726566"
354 ).unwrap();
355 assert_eq!(
356 hex::encode(&cbor),
357 hex::encode(&expected_cbor),
358 "CBOR mismatch"
359 );
360
361 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
362 let expected_event_id: EventId =
363 hex_to_id("b8af76be8b7a40244bb8e731130ed52969a77b87532dadf9a00a352eeb00e3b5").unwrap();
364 assert_eq!(event_id, expected_event_id);
365 }
366
367 #[test]
368 fn test_vector_8_assignee_added() {
369 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
370 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
371 let ts_unix_ms: u64 = 1700000006000;
372 let parent: Option<&EventId> = None;
373 let kind = EventKind::AssigneeAdded {
374 user: "alice".to_string(),
375 };
376
377 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
378 let expected_cbor = hex::decode(
379 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe57f70f6088165616c696365"
380 ).unwrap();
381 assert_eq!(
382 hex::encode(&cbor),
383 hex::encode(&expected_cbor),
384 "CBOR mismatch"
385 );
386
387 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
388 let expected_event_id: EventId =
389 hex_to_id("42f329d826d34d425dd67080d91f6c909bc56411c9add54389fbec5d457b14e4").unwrap();
390 assert_eq!(event_id, expected_event_id);
391 }
392
393 #[test]
394 fn test_vector_9_assignee_removed() {
395 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
396 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
397 let ts_unix_ms: u64 = 1700000007000;
398 let parent: Option<&EventId> = None;
399 let kind = EventKind::AssigneeRemoved {
400 user: "alice".to_string(),
401 };
402
403 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
404 let expected_cbor = hex::decode(
405 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe58358f6098165616c696365"
406 ).unwrap();
407 assert_eq!(
408 hex::encode(&cbor),
409 hex::encode(&expected_cbor),
410 "CBOR mismatch"
411 );
412
413 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
414 let expected_event_id: EventId =
415 hex_to_id("bfb0fdfed0f0ee36f31107963317dd904143f37d9ef8792f64272cf2f07f6a1e").unwrap();
416 assert_eq!(event_id, expected_event_id);
417 }
418
419 #[test]
420 fn test_vector_10_attachment_added() {
421 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
422 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
423 let ts_unix_ms: u64 = 1700000008000;
424 let parent: Option<&EventId> = None;
425 let sha256: [u8; 32] =
426 hex_to_id("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f").unwrap();
427 let kind = EventKind::AttachmentAdded {
428 name: "log.txt".to_string(),
429 sha256,
430 mime: "text/plain".to_string(),
431 };
432
433 let cbor = build_canonical_cbor(&issue_id, &actor, ts_unix_ms, parent, &kind);
434 let expected_cbor = hex::decode(
435 "870150000102030405060708090a0b0c0d0e0f50101112131415161718191a1b1c1d1e1f1b0000018bcfe58740f60a83676c6f672e7478745820000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f6a746578742f706c61696e"
436 ).unwrap();
437 assert_eq!(
438 hex::encode(&cbor),
439 hex::encode(&expected_cbor),
440 "CBOR mismatch"
441 );
442
443 let event_id = compute_event_id(&issue_id, &actor, ts_unix_ms, parent, &kind);
444 let expected_event_id: EventId =
445 hex_to_id("dc83946d33437f0b73d8b04c63f7b0b85b9e9a24e790fee3ca129d3d8b870749").unwrap();
446 assert_eq!(event_id, expected_event_id);
447 }
448
449 #[test]
450 fn test_vector_11_dependency_added() {
451 use crate::types::event::DependencyType;
452 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
453 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
454 let ts_unix_ms: u64 = 1700000009000;
455 let target: IssueId = hex_to_id("aabbccddeeff00112233445566778899").unwrap();
456 let kind = EventKind::DependencyAdded {
457 target,
458 dep_type: DependencyType::Blocks,
459 };
460
461 let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
463 let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
464 assert_eq!(id1, id2);
465
466 let kind2 = EventKind::DependencyAdded {
468 target,
469 dep_type: DependencyType::DependsOn,
470 };
471 let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind2);
472 assert_ne!(id1, id3);
473 }
474
475 #[test]
476 fn test_vector_12_dependency_removed() {
477 use crate::types::event::DependencyType;
478 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
479 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
480 let ts_unix_ms: u64 = 1700000010000;
481 let target: IssueId = hex_to_id("aabbccddeeff00112233445566778899").unwrap();
482 let kind = EventKind::DependencyRemoved {
483 target,
484 dep_type: DependencyType::Blocks,
485 };
486
487 let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
488 let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
489 assert_eq!(id1, id2);
490
491 let kind_add = EventKind::DependencyAdded {
493 target,
494 dep_type: DependencyType::Blocks,
495 };
496 let id_add = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind_add);
497 assert_ne!(id1, id_add);
498 }
499
500 #[test]
501 fn test_vector_13_context_updated() {
502 use crate::types::event::SymbolInfo;
503 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
504 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
505 let ts_unix_ms: u64 = 1700000011000;
506 let kind = EventKind::ContextUpdated {
507 path: "src/main.rs".to_string(),
508 language: "rust".to_string(),
509 symbols: vec![
510 SymbolInfo {
511 name: "main".to_string(),
512 kind: "function".to_string(),
513 line_start: 1,
514 line_end: 10,
515 },
516 SymbolInfo {
517 name: "Config".to_string(),
518 kind: "struct".to_string(),
519 line_start: 12,
520 line_end: 20,
521 },
522 ],
523 summary: "Entry point".to_string(),
524 content_hash: [0xAA; 32],
525 };
526
527 let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
528 let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
529 assert_eq!(id1, id2);
530
531 let kind_reordered = EventKind::ContextUpdated {
533 path: "src/main.rs".to_string(),
534 language: "rust".to_string(),
535 symbols: vec![
536 SymbolInfo {
537 name: "Config".to_string(),
538 kind: "struct".to_string(),
539 line_start: 12,
540 line_end: 20,
541 },
542 SymbolInfo {
543 name: "main".to_string(),
544 kind: "function".to_string(),
545 line_start: 1,
546 line_end: 10,
547 },
548 ],
549 summary: "Entry point".to_string(),
550 content_hash: [0xAA; 32],
551 };
552 let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind_reordered);
553 assert_eq!(id1, id3, "Symbol order should not affect hash");
554 }
555
556 #[test]
557 fn test_vector_14_project_context_updated() {
558 let issue_id: IssueId = hex_to_id("000102030405060708090a0b0c0d0e0f").unwrap();
559 let actor: ActorId = hex_to_id("101112131415161718191a1b1c1d1e1f").unwrap();
560 let ts_unix_ms: u64 = 1700000012000;
561 let kind = EventKind::ProjectContextUpdated {
562 key: "framework".to_string(),
563 value: "actix-web".to_string(),
564 };
565
566 let id1 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
567 let id2 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind);
568 assert_eq!(id1, id2);
569
570 let kind2 = EventKind::ProjectContextUpdated {
572 key: "build_system".to_string(),
573 value: "actix-web".to_string(),
574 };
575 let id3 = compute_event_id(&issue_id, &actor, ts_unix_ms, None, &kind2);
576 assert_ne!(id1, id3);
577 }
578}