pub mod canonical;
pub mod data;
pub mod hash_text;
pub mod migrate;
pub mod parser;
pub mod types;
pub mod validate;
pub mod writer;
pub use canonical::{canonicalize_json, canonicalize_json_str};
pub use data::{
AssignAction, AssignData, CommentData, CompactData, CreateData, DataParseError, DeleteData,
EventData, LinkData, MoveData, RedactData, SnapshotData, UnlinkData, UpdateData,
};
pub use migrate::{RawEvent, migrate_event};
pub use parser::{
CURRENT_VERSION, FIELD_COMMENT, ParseError, ParsedLine, PartialEvent, PartialParsedLine,
SHARD_HEADER, detect_version, parse_line, parse_line_partial, parse_lines,
};
pub use types::{EventType, UnknownEventType};
use crate::model::item_id::ItemId;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Event {
pub wall_ts_us: i64,
pub agent: String,
pub itc: String,
pub parents: Vec<String>,
pub event_type: EventType,
pub item_id: ItemId,
pub data: EventData,
pub event_hash: String,
}
impl<'de> Deserialize<'de> for Event {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
struct EventRaw {
wall_ts_us: i64,
agent: String,
itc: String,
parents: Vec<String>,
event_type: EventType,
item_id: ItemId,
data: serde_json::Value,
event_hash: String,
}
let raw = EventRaw::deserialize(deserializer)?;
let data_json = raw.data.to_string();
let data = EventData::deserialize_for(raw.event_type, &data_json)
.map_err(serde::de::Error::custom)?;
Ok(Self {
wall_ts_us: raw.wall_ts_us,
agent: raw.agent,
itc: raw.itc,
parents: raw.parents,
event_type: raw.event_type,
item_id: raw.item_id,
data,
event_hash: raw.event_hash,
})
}
}
impl Event {
#[must_use]
pub fn parents_str(&self) -> String {
self.parents.join(",")
}
}
fn truncate_for_display(value: &str, max_chars: usize) -> String {
if value.chars().count() <= max_chars {
return value.to_string();
}
let mut preview: String = value.chars().take(max_chars).collect();
preview.push_str("...");
preview
}
impl std::fmt::Display for Event {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}\t{}\t{}\t{}\t{}\t{}",
self.wall_ts_us,
self.agent,
self.event_type,
self.item_id,
self.event_hash,
match &self.data {
EventData::Create(d) => format!("create: {}", d.title),
EventData::Update(d) => format!("update: {}={}", d.field, d.value),
EventData::Move(d) => format!("move: {}", d.state),
EventData::Assign(d) => format!("{}: {}", d.action, d.agent),
EventData::Comment(d) => {
let preview = truncate_for_display(&d.body, 40);
format!("comment: {preview}")
}
EventData::Link(d) => format!("link: {} {}", d.link_type, d.target),
EventData::Unlink(d) => format!("unlink: {}", d.target),
EventData::Delete(_) => "delete".to_string(),
EventData::Compact(d) => {
let preview = truncate_for_display(&d.summary, 40);
format!("compact: {preview}")
}
EventData::Snapshot(_) => "snapshot".to_string(),
EventData::Redact(d) => format!("redact: {}", d.target_hash),
}
)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::collections::BTreeMap;
fn sample_create_event() -> Event {
Event {
wall_ts_us: 1_708_012_200_123_456,
agent: "claude-abc".into(),
itc: "itc:AQ".into(),
parents: vec![],
event_type: EventType::Create,
item_id: ItemId::new_unchecked("bn-a3f8"),
data: EventData::Create(CreateData {
title: "Fix auth retry".into(),
kind: crate::model::item::Kind::Task,
size: Some(crate::model::item::Size::M),
urgency: crate::model::item::Urgency::Default,
labels: vec!["backend".into()],
parent: None,
causation: None,
description: None,
extra: BTreeMap::new(),
}),
event_hash: "blake3:a1b2c3d4e5f6".into(),
}
}
fn sample_move_event() -> Event {
Event {
wall_ts_us: 1_708_012_201_000_000,
agent: "claude-abc".into(),
itc: "itc:AQ.1".into(),
parents: vec!["blake3:a1b2c3d4e5f6".into()],
event_type: EventType::Move,
item_id: ItemId::new_unchecked("bn-a3f8"),
data: EventData::Move(MoveData {
state: crate::model::item::State::Doing,
reason: None,
extra: BTreeMap::new(),
}),
event_hash: "blake3:d4e5f6789abc".into(),
}
}
#[test]
fn event_struct_fields() {
let event = sample_create_event();
assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
assert_eq!(event.agent, "claude-abc");
assert_eq!(event.itc, "itc:AQ");
assert!(event.parents.is_empty());
assert_eq!(event.event_type, EventType::Create);
assert_eq!(event.item_id.as_str(), "bn-a3f8");
assert!(matches!(event.data, EventData::Create(_)));
assert_eq!(event.event_hash, "blake3:a1b2c3d4e5f6");
}
#[test]
fn event_parents_str_empty() {
let event = sample_create_event();
assert_eq!(event.parents_str(), "");
}
#[test]
fn event_parents_str_single() {
let event = sample_move_event();
assert_eq!(event.parents_str(), "blake3:a1b2c3d4e5f6");
}
#[test]
fn event_parents_str_multiple() {
let mut event = sample_move_event();
event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
assert_eq!(event.parents_str(), "blake3:aaa,blake3:bbb");
}
#[test]
fn event_display() {
let event = sample_create_event();
let display = event.to_string();
assert!(display.contains("1708012200123456"));
assert!(display.contains("claude-abc"));
assert!(display.contains("item.create"));
assert!(display.contains("bn-a3f8"));
assert!(display.contains("Fix auth retry"));
}
#[test]
fn event_display_truncates_unicode_comment_without_panicking() {
let mut event = sample_create_event();
event.event_type = EventType::Comment;
event.data = EventData::Comment(CommentData {
body: "é".repeat(60),
extra: BTreeMap::new(),
});
let display = event.to_string();
assert!(display.contains("comment:"));
assert!(display.contains("..."));
}
#[test]
fn event_serde_json_roundtrip() {
let event = sample_create_event();
let json = serde_json::to_string(&event).expect("serialize");
let deser: Event = serde_json::from_str(&json).expect("deserialize");
assert_eq!(event, deser);
}
#[test]
fn event_serde_json_roundtrip_with_parents() {
let event = sample_move_event();
let json = serde_json::to_string(&event).expect("serialize");
let deser: Event = serde_json::from_str(&json).expect("deserialize");
assert_eq!(event, deser);
}
#[test]
fn event_serde_all_types_roundtrip() {
let base = || -> (i64, String, String, Vec<String>, ItemId, String) {
(
1_000_000,
"agent".into(),
"itc:X".into(),
vec![],
ItemId::new_unchecked("bn-a7x"),
"blake3:000".into(),
)
};
let events: Vec<Event> = vec![
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Create,
item_id,
data: EventData::Create(CreateData {
title: "T".into(),
kind: crate::model::item::Kind::Task,
size: None,
urgency: crate::model::item::Urgency::Default,
labels: vec![],
parent: None,
causation: None,
description: None,
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Update,
item_id,
data: EventData::Update(UpdateData {
field: "title".into(),
value: json!("New"),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Move,
item_id,
data: EventData::Move(MoveData {
state: crate::model::item::State::Done,
reason: Some("done".into()),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Assign,
item_id,
data: EventData::Assign(AssignData {
agent: "alice".into(),
action: AssignAction::Assign,
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Comment,
item_id,
data: EventData::Comment(CommentData {
body: "Note".into(),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Link,
item_id,
data: EventData::Link(LinkData {
target: "bn-b8y".into(),
link_type: "blocks".into(),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Unlink,
item_id,
data: EventData::Unlink(UnlinkData {
target: "bn-b8y".into(),
link_type: None,
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Delete,
item_id,
data: EventData::Delete(DeleteData {
reason: None,
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Compact,
item_id,
data: EventData::Compact(CompactData {
summary: "TL;DR".into(),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Snapshot,
item_id,
data: EventData::Snapshot(SnapshotData {
state: json!({"id": "bn-a7x"}),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
{
let (ts, agent, itc, parents, item_id, hash) = base();
Event {
wall_ts_us: ts,
agent,
itc,
parents,
event_type: EventType::Redact,
item_id,
data: EventData::Redact(RedactData {
target_hash: "blake3:xyz".into(),
reason: "oops".into(),
extra: BTreeMap::new(),
}),
event_hash: hash,
}
},
];
assert_eq!(events.len(), 11, "should cover all 11 event types");
for event in &events {
let json = serde_json::to_string(event)
.unwrap_or_else(|e| panic!("serialize {} failed: {e}", event.event_type));
let deser: Event = serde_json::from_str(&json)
.unwrap_or_else(|e| panic!("deserialize {} failed: {e}", event.event_type));
assert_eq!(*event, deser, "roundtrip failed for {}", event.event_type);
}
}
#[test]
fn event_display_all_data_types() {
let events = vec![sample_create_event(), sample_move_event()];
for event in events {
let _ = event.to_string(); }
}
}