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