1pub mod canonical;
19pub mod data;
20pub mod hash_text;
21pub mod migrate;
22pub mod parser;
23pub mod types;
24pub mod validate;
25pub mod writer;
26
27pub use canonical::{canonicalize_json, canonicalize_json_str};
28pub use data::{
29 AssignAction, AssignData, CommentData, CompactData, CreateData, DataParseError, DeleteData,
30 EventData, LinkData, MoveData, RedactData, SnapshotData, UnlinkData, UpdateData,
31};
32pub use migrate::{RawEvent, migrate_event};
33pub use parser::{
34 CURRENT_VERSION, FIELD_COMMENT, ParseError, ParsedLine, PartialEvent, PartialParsedLine,
35 SHARD_HEADER, detect_version, parse_line, parse_line_partial, parse_lines,
36};
37pub use types::{EventType, UnknownEventType};
38
39use crate::model::item_id::ItemId;
40use serde::{Deserialize, Serialize};
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
65pub struct Event {
66 pub wall_ts_us: i64,
70
71 pub agent: String,
73
74 pub itc: String,
78
79 pub parents: Vec<String>,
84
85 pub event_type: EventType,
87
88 pub item_id: ItemId,
90
91 pub data: EventData,
93
94 pub event_hash: String,
101}
102
103impl<'de> Deserialize<'de> for Event {
104 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
105 where
106 D: serde::Deserializer<'de>,
107 {
108 #[derive(Deserialize)]
111 struct EventRaw {
112 wall_ts_us: i64,
113 agent: String,
114 itc: String,
115 parents: Vec<String>,
116 event_type: EventType,
117 item_id: ItemId,
118 data: serde_json::Value,
119 event_hash: String,
120 }
121
122 let raw = EventRaw::deserialize(deserializer)?;
123 let data_json = raw.data.to_string();
124 let data = EventData::deserialize_for(raw.event_type, &data_json)
125 .map_err(serde::de::Error::custom)?;
126
127 Ok(Self {
128 wall_ts_us: raw.wall_ts_us,
129 agent: raw.agent,
130 itc: raw.itc,
131 parents: raw.parents,
132 event_type: raw.event_type,
133 item_id: raw.item_id,
134 data,
135 event_hash: raw.event_hash,
136 })
137 }
138}
139
140impl Event {
141 #[must_use]
145 pub fn parents_str(&self) -> String {
146 self.parents.join(",")
147 }
148}
149
150fn truncate_for_display(value: &str, max_chars: usize) -> String {
151 if value.chars().count() <= max_chars {
152 return value.to_string();
153 }
154
155 let mut preview: String = value.chars().take(max_chars).collect();
156 preview.push_str("...");
157 preview
158}
159
160impl std::fmt::Display for Event {
161 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
162 write!(
163 f,
164 "{}\t{}\t{}\t{}\t{}\t{}",
165 self.wall_ts_us,
166 self.agent,
167 self.event_type,
168 self.item_id,
169 self.event_hash,
170 match &self.data {
172 EventData::Create(d) => format!("create: {}", d.title),
173 EventData::Update(d) => format!("update: {}={}", d.field, d.value),
174 EventData::Move(d) => format!("move: {}", d.state),
175 EventData::Assign(d) => format!("{}: {}", d.action, d.agent),
176 EventData::Comment(d) => {
177 let preview = truncate_for_display(&d.body, 40);
178 format!("comment: {preview}")
179 }
180 EventData::Link(d) => format!("link: {} {}", d.link_type, d.target),
181 EventData::Unlink(d) => format!("unlink: {}", d.target),
182 EventData::Delete(_) => "delete".to_string(),
183 EventData::Compact(d) => {
184 let preview = truncate_for_display(&d.summary, 40);
185 format!("compact: {preview}")
186 }
187 EventData::Snapshot(_) => "snapshot".to_string(),
188 EventData::Redact(d) => format!("redact: {}", d.target_hash),
189 }
190 )
191 }
192}
193
194#[cfg(test)]
199mod tests {
200 use super::*;
201 use serde_json::json;
202 use std::collections::BTreeMap;
203
204 fn sample_create_event() -> Event {
205 Event {
206 wall_ts_us: 1_708_012_200_123_456,
207 agent: "claude-abc".into(),
208 itc: "itc:AQ".into(),
209 parents: vec![],
210 event_type: EventType::Create,
211 item_id: ItemId::new_unchecked("bn-a3f8"),
212 data: EventData::Create(CreateData {
213 title: "Fix auth retry".into(),
214 kind: crate::model::item::Kind::Task,
215 size: Some(crate::model::item::Size::M),
216 urgency: crate::model::item::Urgency::Default,
217 labels: vec!["backend".into()],
218 parent: None,
219 causation: None,
220 description: None,
221 extra: BTreeMap::new(),
222 }),
223 event_hash: "blake3:a1b2c3d4e5f6".into(),
224 }
225 }
226
227 fn sample_move_event() -> Event {
228 Event {
229 wall_ts_us: 1_708_012_201_000_000,
230 agent: "claude-abc".into(),
231 itc: "itc:AQ.1".into(),
232 parents: vec!["blake3:a1b2c3d4e5f6".into()],
233 event_type: EventType::Move,
234 item_id: ItemId::new_unchecked("bn-a3f8"),
235 data: EventData::Move(MoveData {
236 state: crate::model::item::State::Doing,
237 reason: None,
238 extra: BTreeMap::new(),
239 }),
240 event_hash: "blake3:d4e5f6789abc".into(),
241 }
242 }
243
244 #[test]
245 fn event_struct_fields() {
246 let event = sample_create_event();
247 assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
248 assert_eq!(event.agent, "claude-abc");
249 assert_eq!(event.itc, "itc:AQ");
250 assert!(event.parents.is_empty());
251 assert_eq!(event.event_type, EventType::Create);
252 assert_eq!(event.item_id.as_str(), "bn-a3f8");
253 assert!(matches!(event.data, EventData::Create(_)));
254 assert_eq!(event.event_hash, "blake3:a1b2c3d4e5f6");
255 }
256
257 #[test]
258 fn event_parents_str_empty() {
259 let event = sample_create_event();
260 assert_eq!(event.parents_str(), "");
261 }
262
263 #[test]
264 fn event_parents_str_single() {
265 let event = sample_move_event();
266 assert_eq!(event.parents_str(), "blake3:a1b2c3d4e5f6");
267 }
268
269 #[test]
270 fn event_parents_str_multiple() {
271 let mut event = sample_move_event();
272 event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
273 assert_eq!(event.parents_str(), "blake3:aaa,blake3:bbb");
274 }
275
276 #[test]
277 fn event_display() {
278 let event = sample_create_event();
279 let display = event.to_string();
280 assert!(display.contains("1708012200123456"));
281 assert!(display.contains("claude-abc"));
282 assert!(display.contains("item.create"));
283 assert!(display.contains("bn-a3f8"));
284 assert!(display.contains("Fix auth retry"));
285 }
286
287 #[test]
288 fn event_display_truncates_unicode_comment_without_panicking() {
289 let mut event = sample_create_event();
290 event.event_type = EventType::Comment;
291 event.data = EventData::Comment(CommentData {
292 body: "é".repeat(60),
293 extra: BTreeMap::new(),
294 });
295
296 let display = event.to_string();
297 assert!(display.contains("comment:"));
298 assert!(display.contains("..."));
299 }
300
301 #[test]
302 fn event_serde_json_roundtrip() {
303 let event = sample_create_event();
304 let json = serde_json::to_string(&event).expect("serialize");
305 let deser: Event = serde_json::from_str(&json).expect("deserialize");
306 assert_eq!(event, deser);
307 }
308
309 #[test]
310 fn event_serde_json_roundtrip_with_parents() {
311 let event = sample_move_event();
312 let json = serde_json::to_string(&event).expect("serialize");
313 let deser: Event = serde_json::from_str(&json).expect("deserialize");
314 assert_eq!(event, deser);
315 }
316
317 #[test]
318 fn event_serde_all_types_roundtrip() {
319 let base = || -> (i64, String, String, Vec<String>, ItemId, String) {
320 (
321 1_000_000,
322 "agent".into(),
323 "itc:X".into(),
324 vec![],
325 ItemId::new_unchecked("bn-a7x"),
326 "blake3:000".into(),
327 )
328 };
329
330 let events: Vec<Event> = vec![
331 {
332 let (ts, agent, itc, parents, item_id, hash) = base();
333 Event {
334 wall_ts_us: ts,
335 agent,
336 itc,
337 parents,
338 event_type: EventType::Create,
339 item_id,
340 data: EventData::Create(CreateData {
341 title: "T".into(),
342 kind: crate::model::item::Kind::Task,
343 size: None,
344 urgency: crate::model::item::Urgency::Default,
345 labels: vec![],
346 parent: None,
347 causation: None,
348 description: None,
349 extra: BTreeMap::new(),
350 }),
351 event_hash: hash,
352 }
353 },
354 {
355 let (ts, agent, itc, parents, item_id, hash) = base();
356 Event {
357 wall_ts_us: ts,
358 agent,
359 itc,
360 parents,
361 event_type: EventType::Update,
362 item_id,
363 data: EventData::Update(UpdateData {
364 field: "title".into(),
365 value: json!("New"),
366 extra: BTreeMap::new(),
367 }),
368 event_hash: hash,
369 }
370 },
371 {
372 let (ts, agent, itc, parents, item_id, hash) = base();
373 Event {
374 wall_ts_us: ts,
375 agent,
376 itc,
377 parents,
378 event_type: EventType::Move,
379 item_id,
380 data: EventData::Move(MoveData {
381 state: crate::model::item::State::Done,
382 reason: Some("done".into()),
383 extra: BTreeMap::new(),
384 }),
385 event_hash: hash,
386 }
387 },
388 {
389 let (ts, agent, itc, parents, item_id, hash) = base();
390 Event {
391 wall_ts_us: ts,
392 agent,
393 itc,
394 parents,
395 event_type: EventType::Assign,
396 item_id,
397 data: EventData::Assign(AssignData {
398 agent: "alice".into(),
399 action: AssignAction::Assign,
400 extra: BTreeMap::new(),
401 }),
402 event_hash: hash,
403 }
404 },
405 {
406 let (ts, agent, itc, parents, item_id, hash) = base();
407 Event {
408 wall_ts_us: ts,
409 agent,
410 itc,
411 parents,
412 event_type: EventType::Comment,
413 item_id,
414 data: EventData::Comment(CommentData {
415 body: "Note".into(),
416 extra: BTreeMap::new(),
417 }),
418 event_hash: hash,
419 }
420 },
421 {
422 let (ts, agent, itc, parents, item_id, hash) = base();
423 Event {
424 wall_ts_us: ts,
425 agent,
426 itc,
427 parents,
428 event_type: EventType::Link,
429 item_id,
430 data: EventData::Link(LinkData {
431 target: "bn-b8y".into(),
432 link_type: "blocks".into(),
433 extra: BTreeMap::new(),
434 }),
435 event_hash: hash,
436 }
437 },
438 {
439 let (ts, agent, itc, parents, item_id, hash) = base();
440 Event {
441 wall_ts_us: ts,
442 agent,
443 itc,
444 parents,
445 event_type: EventType::Unlink,
446 item_id,
447 data: EventData::Unlink(UnlinkData {
448 target: "bn-b8y".into(),
449 link_type: None,
450 extra: BTreeMap::new(),
451 }),
452 event_hash: hash,
453 }
454 },
455 {
456 let (ts, agent, itc, parents, item_id, hash) = base();
457 Event {
458 wall_ts_us: ts,
459 agent,
460 itc,
461 parents,
462 event_type: EventType::Delete,
463 item_id,
464 data: EventData::Delete(DeleteData {
465 reason: None,
466 extra: BTreeMap::new(),
467 }),
468 event_hash: hash,
469 }
470 },
471 {
472 let (ts, agent, itc, parents, item_id, hash) = base();
473 Event {
474 wall_ts_us: ts,
475 agent,
476 itc,
477 parents,
478 event_type: EventType::Compact,
479 item_id,
480 data: EventData::Compact(CompactData {
481 summary: "TL;DR".into(),
482 extra: BTreeMap::new(),
483 }),
484 event_hash: hash,
485 }
486 },
487 {
488 let (ts, agent, itc, parents, item_id, hash) = base();
489 Event {
490 wall_ts_us: ts,
491 agent,
492 itc,
493 parents,
494 event_type: EventType::Snapshot,
495 item_id,
496 data: EventData::Snapshot(SnapshotData {
497 state: json!({"id": "bn-a7x"}),
498 extra: BTreeMap::new(),
499 }),
500 event_hash: hash,
501 }
502 },
503 {
504 let (ts, agent, itc, parents, item_id, hash) = base();
505 Event {
506 wall_ts_us: ts,
507 agent,
508 itc,
509 parents,
510 event_type: EventType::Redact,
511 item_id,
512 data: EventData::Redact(RedactData {
513 target_hash: "blake3:xyz".into(),
514 reason: "oops".into(),
515 extra: BTreeMap::new(),
516 }),
517 event_hash: hash,
518 }
519 },
520 ];
521
522 assert_eq!(events.len(), 11, "should cover all 11 event types");
523
524 for event in &events {
525 let json = serde_json::to_string(event)
526 .unwrap_or_else(|e| panic!("serialize {} failed: {e}", event.event_type));
527 let deser: Event = serde_json::from_str(&json)
528 .unwrap_or_else(|e| panic!("deserialize {} failed: {e}", event.event_type));
529 assert_eq!(*event, deser, "roundtrip failed for {}", event.event_type);
530 }
531 }
532
533 #[test]
534 fn event_display_all_data_types() {
535 let events = vec![sample_create_event(), sample_move_event()];
537 for event in events {
538 let _ = event.to_string(); }
540 }
541}