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
150impl std::fmt::Display for Event {
151 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
152 write!(
153 f,
154 "{}\t{}\t{}\t{}\t{}\t{}",
155 self.wall_ts_us,
156 self.agent,
157 self.event_type,
158 self.item_id,
159 self.event_hash,
160 match &self.data {
162 EventData::Create(d) => format!("create: {}", d.title),
163 EventData::Update(d) => format!("update: {}={}", d.field, d.value),
164 EventData::Move(d) => format!("move: {}", d.state),
165 EventData::Assign(d) => format!("{}: {}", d.action, d.agent),
166 EventData::Comment(d) => {
167 let preview = if d.body.len() > 40 {
168 format!("{}...", &d.body[..40])
169 } else {
170 d.body.clone()
171 };
172 format!("comment: {preview}")
173 }
174 EventData::Link(d) => format!("link: {} {}", d.link_type, d.target),
175 EventData::Unlink(d) => format!("unlink: {}", d.target),
176 EventData::Delete(_) => "delete".to_string(),
177 EventData::Compact(d) => {
178 let preview = if d.summary.len() > 40 {
179 format!("{}...", &d.summary[..40])
180 } else {
181 d.summary.clone()
182 };
183 format!("compact: {preview}")
184 }
185 EventData::Snapshot(_) => "snapshot".to_string(),
186 EventData::Redact(d) => format!("redact: {}", d.target_hash),
187 }
188 )
189 }
190}
191
192#[cfg(test)]
197mod tests {
198 use super::*;
199 use serde_json::json;
200 use std::collections::BTreeMap;
201
202 fn sample_create_event() -> Event {
203 Event {
204 wall_ts_us: 1_708_012_200_123_456,
205 agent: "claude-abc".into(),
206 itc: "itc:AQ".into(),
207 parents: vec![],
208 event_type: EventType::Create,
209 item_id: ItemId::new_unchecked("bn-a3f8"),
210 data: EventData::Create(CreateData {
211 title: "Fix auth retry".into(),
212 kind: crate::model::item::Kind::Task,
213 size: Some(crate::model::item::Size::M),
214 urgency: crate::model::item::Urgency::Default,
215 labels: vec!["backend".into()],
216 parent: None,
217 causation: None,
218 description: None,
219 extra: BTreeMap::new(),
220 }),
221 event_hash: "blake3:a1b2c3d4e5f6".into(),
222 }
223 }
224
225 fn sample_move_event() -> Event {
226 Event {
227 wall_ts_us: 1_708_012_201_000_000,
228 agent: "claude-abc".into(),
229 itc: "itc:AQ.1".into(),
230 parents: vec!["blake3:a1b2c3d4e5f6".into()],
231 event_type: EventType::Move,
232 item_id: ItemId::new_unchecked("bn-a3f8"),
233 data: EventData::Move(MoveData {
234 state: crate::model::item::State::Doing,
235 reason: None,
236 extra: BTreeMap::new(),
237 }),
238 event_hash: "blake3:d4e5f6789abc".into(),
239 }
240 }
241
242 #[test]
243 fn event_struct_fields() {
244 let event = sample_create_event();
245 assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
246 assert_eq!(event.agent, "claude-abc");
247 assert_eq!(event.itc, "itc:AQ");
248 assert!(event.parents.is_empty());
249 assert_eq!(event.event_type, EventType::Create);
250 assert_eq!(event.item_id.as_str(), "bn-a3f8");
251 assert!(matches!(event.data, EventData::Create(_)));
252 assert_eq!(event.event_hash, "blake3:a1b2c3d4e5f6");
253 }
254
255 #[test]
256 fn event_parents_str_empty() {
257 let event = sample_create_event();
258 assert_eq!(event.parents_str(), "");
259 }
260
261 #[test]
262 fn event_parents_str_single() {
263 let event = sample_move_event();
264 assert_eq!(event.parents_str(), "blake3:a1b2c3d4e5f6");
265 }
266
267 #[test]
268 fn event_parents_str_multiple() {
269 let mut event = sample_move_event();
270 event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
271 assert_eq!(event.parents_str(), "blake3:aaa,blake3:bbb");
272 }
273
274 #[test]
275 fn event_display() {
276 let event = sample_create_event();
277 let display = event.to_string();
278 assert!(display.contains("1708012200123456"));
279 assert!(display.contains("claude-abc"));
280 assert!(display.contains("item.create"));
281 assert!(display.contains("bn-a3f8"));
282 assert!(display.contains("Fix auth retry"));
283 }
284
285 #[test]
286 fn event_serde_json_roundtrip() {
287 let event = sample_create_event();
288 let json = serde_json::to_string(&event).expect("serialize");
289 let deser: Event = serde_json::from_str(&json).expect("deserialize");
290 assert_eq!(event, deser);
291 }
292
293 #[test]
294 fn event_serde_json_roundtrip_with_parents() {
295 let event = sample_move_event();
296 let json = serde_json::to_string(&event).expect("serialize");
297 let deser: Event = serde_json::from_str(&json).expect("deserialize");
298 assert_eq!(event, deser);
299 }
300
301 #[test]
302 fn event_serde_all_types_roundtrip() {
303 let base = || -> (i64, String, String, Vec<String>, ItemId, String) {
304 (
305 1_000_000,
306 "agent".into(),
307 "itc:X".into(),
308 vec![],
309 ItemId::new_unchecked("bn-a7x"),
310 "blake3:000".into(),
311 )
312 };
313
314 let events: Vec<Event> = vec![
315 {
316 let (ts, agent, itc, parents, item_id, hash) = base();
317 Event {
318 wall_ts_us: ts,
319 agent,
320 itc,
321 parents,
322 event_type: EventType::Create,
323 item_id,
324 data: EventData::Create(CreateData {
325 title: "T".into(),
326 kind: crate::model::item::Kind::Task,
327 size: None,
328 urgency: crate::model::item::Urgency::Default,
329 labels: vec![],
330 parent: None,
331 causation: None,
332 description: None,
333 extra: BTreeMap::new(),
334 }),
335 event_hash: hash,
336 }
337 },
338 {
339 let (ts, agent, itc, parents, item_id, hash) = base();
340 Event {
341 wall_ts_us: ts,
342 agent,
343 itc,
344 parents,
345 event_type: EventType::Update,
346 item_id,
347 data: EventData::Update(UpdateData {
348 field: "title".into(),
349 value: json!("New"),
350 extra: BTreeMap::new(),
351 }),
352 event_hash: hash,
353 }
354 },
355 {
356 let (ts, agent, itc, parents, item_id, hash) = base();
357 Event {
358 wall_ts_us: ts,
359 agent,
360 itc,
361 parents,
362 event_type: EventType::Move,
363 item_id,
364 data: EventData::Move(MoveData {
365 state: crate::model::item::State::Done,
366 reason: Some("done".into()),
367 extra: BTreeMap::new(),
368 }),
369 event_hash: hash,
370 }
371 },
372 {
373 let (ts, agent, itc, parents, item_id, hash) = base();
374 Event {
375 wall_ts_us: ts,
376 agent,
377 itc,
378 parents,
379 event_type: EventType::Assign,
380 item_id,
381 data: EventData::Assign(AssignData {
382 agent: "alice".into(),
383 action: AssignAction::Assign,
384 extra: BTreeMap::new(),
385 }),
386 event_hash: hash,
387 }
388 },
389 {
390 let (ts, agent, itc, parents, item_id, hash) = base();
391 Event {
392 wall_ts_us: ts,
393 agent,
394 itc,
395 parents,
396 event_type: EventType::Comment,
397 item_id,
398 data: EventData::Comment(CommentData {
399 body: "Note".into(),
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::Link,
413 item_id,
414 data: EventData::Link(LinkData {
415 target: "bn-b8y".into(),
416 link_type: "blocks".into(),
417 extra: BTreeMap::new(),
418 }),
419 event_hash: hash,
420 }
421 },
422 {
423 let (ts, agent, itc, parents, item_id, hash) = base();
424 Event {
425 wall_ts_us: ts,
426 agent,
427 itc,
428 parents,
429 event_type: EventType::Unlink,
430 item_id,
431 data: EventData::Unlink(UnlinkData {
432 target: "bn-b8y".into(),
433 link_type: None,
434 extra: BTreeMap::new(),
435 }),
436 event_hash: hash,
437 }
438 },
439 {
440 let (ts, agent, itc, parents, item_id, hash) = base();
441 Event {
442 wall_ts_us: ts,
443 agent,
444 itc,
445 parents,
446 event_type: EventType::Delete,
447 item_id,
448 data: EventData::Delete(DeleteData {
449 reason: 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::Compact,
463 item_id,
464 data: EventData::Compact(CompactData {
465 summary: "TL;DR".into(),
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::Snapshot,
479 item_id,
480 data: EventData::Snapshot(SnapshotData {
481 state: json!({"id": "bn-a7x"}),
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::Redact,
495 item_id,
496 data: EventData::Redact(RedactData {
497 target_hash: "blake3:xyz".into(),
498 reason: "oops".into(),
499 extra: BTreeMap::new(),
500 }),
501 event_hash: hash,
502 }
503 },
504 ];
505
506 assert_eq!(events.len(), 11, "should cover all 11 event types");
507
508 for event in &events {
509 let json = serde_json::to_string(event)
510 .unwrap_or_else(|e| panic!("serialize {} failed: {e}", event.event_type));
511 let deser: Event = serde_json::from_str(&json)
512 .unwrap_or_else(|e| panic!("deserialize {} failed: {e}", event.event_type));
513 assert_eq!(*event, deser, "roundtrip failed for {}", event.event_type);
514 }
515 }
516
517 #[test]
518 fn event_display_all_data_types() {
519 let events = vec![sample_create_event(), sample_move_event()];
521 for event in events {
522 let _ = event.to_string(); }
524 }
525}