1use super::Event;
17use super::canonical::canonicalize_json;
18use super::hash_text::encode_blake3_hash;
19
20pub const SHARD_HEADER: &str = "# bones event log v1";
22
23pub const FIELD_COMMENT: &str =
25 "# fields: wall_ts_us\tagent\titc\tparents\ttype\titem_id\tdata\tevent_hash";
26
27#[derive(Debug, thiserror::Error)]
33pub enum WriteError {
34 #[error("JSON payload contains literal newline — one-line invariant violated")]
36 NewlineInPayload,
37
38 #[error("failed to serialize event data: {0}")]
40 SerializeData(#[from] serde_json::Error),
41}
42
43#[must_use]
51pub fn shard_header() -> String {
52 format!("{SHARD_HEADER}\n{FIELD_COMMENT}\n")
53}
54
55pub fn to_tsjson_line(event: &Event) -> Result<String, WriteError> {
67 let data_json = canonical_data_json(event)?;
68
69 if data_json.contains('\n') {
71 return Err(WriteError::NewlineInPayload);
72 }
73
74 let parents = event.parents_str();
75
76 Ok(format!(
77 "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
78 event.wall_ts_us,
79 event.agent,
80 event.itc,
81 parents,
82 event.event_type,
83 event.item_id,
84 data_json,
85 event.event_hash,
86 ))
87}
88
89pub fn write_line(event: &Event) -> Result<String, WriteError> {
97 let mut line = to_tsjson_line(event)?;
98 line.push('\n');
99 Ok(line)
100}
101
102pub fn compute_event_hash(event: &Event) -> Result<String, WriteError> {
113 let data_json = canonical_data_json(event)?;
114 let parents = event.parents_str();
115
116 let hash_input = format!(
117 "{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
118 event.wall_ts_us,
119 event.agent,
120 event.itc,
121 parents,
122 event.event_type,
123 event.item_id,
124 data_json,
125 );
126
127 let hash = blake3::hash(hash_input.as_bytes());
128 Ok(encode_blake3_hash(&hash))
129}
130
131pub fn write_event(event: &mut Event) -> Result<String, WriteError> {
140 event.event_hash = compute_event_hash(event)?;
141 write_line(event)
142}
143
144fn canonical_data_json(event: &Event) -> Result<String, WriteError> {
150 let value = event.data.to_json_value()?;
151 Ok(canonicalize_json(&value))
152}
153
154#[cfg(test)]
159mod tests {
160 use super::*;
161 use crate::event::data::*;
162 use crate::event::types::EventType;
163 use crate::model::item_id::ItemId;
164 use std::collections::BTreeMap;
165
166 fn sample_create_event() -> Event {
167 Event {
168 wall_ts_us: 1_708_012_200_123_456,
169 agent: "claude-abc".into(),
170 itc: "itc:AQ".into(),
171 parents: vec![],
172 event_type: EventType::Create,
173 item_id: ItemId::new_unchecked("bn-a3f8"),
174 data: EventData::Create(CreateData {
175 title: "Fix auth retry".into(),
176 kind: crate::model::item::Kind::Task,
177 size: Some(crate::model::item::Size::M),
178 urgency: crate::model::item::Urgency::Default,
179 labels: vec!["backend".into()],
180 parent: None,
181 causation: None,
182 description: None,
183 extra: BTreeMap::new(),
184 }),
185 event_hash: "blake3:placeholder".into(),
186 }
187 }
188
189 fn sample_move_event() -> Event {
190 Event {
191 wall_ts_us: 1_708_012_201_000_000,
192 agent: "claude-abc".into(),
193 itc: "itc:AQ.1".into(),
194 parents: vec!["blake3:a1b2c3d4e5f6".into()],
195 event_type: EventType::Move,
196 item_id: ItemId::new_unchecked("bn-a3f8"),
197 data: EventData::Move(MoveData {
198 state: crate::model::item::State::Doing,
199 reason: None,
200 extra: BTreeMap::new(),
201 }),
202 event_hash: "blake3:d4e5f6789abc".into(),
203 }
204 }
205
206 #[test]
207 fn shard_header_format() {
208 let header = shard_header();
209 assert!(header.starts_with("# bones event log v1\n"));
210 assert!(header.contains("# fields:"));
211 assert!(header.ends_with('\n'));
212 assert_eq!(header.lines().count(), 2);
214 }
215
216 #[test]
217 fn to_tsjson_line_create_event() {
218 let event = sample_create_event();
219 let line = to_tsjson_line(&event).expect("should serialize");
220
221 let fields: Vec<&str> = line.split('\t').collect();
223 assert_eq!(fields.len(), 8, "expected 8 tab-separated fields");
224
225 assert_eq!(fields[0], "1708012200123456");
227 assert_eq!(fields[1], "claude-abc");
229 assert_eq!(fields[2], "itc:AQ");
231 assert_eq!(fields[3], "");
233 assert_eq!(fields[4], "item.create");
235 assert_eq!(fields[5], "bn-a3f8");
237 assert!(fields[6].starts_with('{'));
239 assert!(fields[6].ends_with('}'));
240 assert_eq!(fields[7], "blake3:placeholder");
242
243 assert!(!line.contains('\n'));
245 }
246
247 #[test]
248 fn to_tsjson_line_with_parents() {
249 let event = sample_move_event();
250 let line = to_tsjson_line(&event).expect("should serialize");
251 let fields: Vec<&str> = line.split('\t').collect();
252
253 assert_eq!(fields[3], "blake3:a1b2c3d4e5f6");
255 }
256
257 #[test]
258 fn to_tsjson_line_multiple_parents() {
259 let mut event = sample_move_event();
260 event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
261 let line = to_tsjson_line(&event).expect("should serialize");
262 let fields: Vec<&str> = line.split('\t').collect();
263
264 assert_eq!(fields[3], "blake3:aaa,blake3:bbb");
265 }
266
267 #[test]
268 fn write_line_has_trailing_newline() {
269 let event = sample_create_event();
270 let line = write_line(&event).expect("should serialize");
271 assert!(line.ends_with('\n'));
272 assert_eq!(line.matches('\n').count(), 1);
274 }
275
276 #[test]
277 fn canonical_json_keys_sorted() {
278 let event = sample_create_event();
279 let line = to_tsjson_line(&event).expect("should serialize");
280 let fields: Vec<&str> = line.split('\t').collect();
281 let json_str = fields[6];
282
283 let val: serde_json::Value = serde_json::from_str(json_str).expect("valid JSON");
286 let obj = val.as_object().expect("should be object");
287 let keys: Vec<&String> = obj.keys().collect();
288
289 let mut sorted_keys = keys.clone();
291 sorted_keys.sort();
292 assert_eq!(keys, sorted_keys, "JSON keys should be sorted");
293 }
294
295 #[test]
296 fn json_payload_no_whitespace() {
297 let event = sample_create_event();
298 let line = to_tsjson_line(&event).expect("should serialize");
299 let fields: Vec<&str> = line.split('\t').collect();
300 let json_str = fields[6];
301
302 assert!(!json_str.contains(" :"));
305 assert!(!json_str.contains(": "));
306 }
308
309 #[test]
310 fn compute_event_hash_deterministic() {
311 let event = sample_create_event();
312 let hash1 = compute_event_hash(&event).expect("hash");
313 let hash2 = compute_event_hash(&event).expect("hash");
314 assert_eq!(hash1, hash2, "same event should produce same hash");
315 assert!(
316 hash1.starts_with("blake3:"),
317 "hash should have blake3: prefix"
318 );
319 }
320
321 #[test]
322 fn compute_event_hash_changes_with_data() {
323 let event1 = sample_create_event();
324 let mut event2 = sample_create_event();
325 event2.wall_ts_us += 1;
326
327 let hash1 = compute_event_hash(&event1).expect("hash");
328 let hash2 = compute_event_hash(&event2).expect("hash");
329 assert_ne!(
330 hash1, hash2,
331 "different events should have different hashes"
332 );
333 }
334
335 #[test]
336 fn write_event_sets_hash() {
337 let mut event = sample_create_event();
338 assert_eq!(event.event_hash, "blake3:placeholder");
339
340 let line = write_event(&mut event).expect("write");
341 assert_ne!(event.event_hash, "blake3:placeholder");
342 assert!(event.event_hash.starts_with("blake3:"));
343
344 assert!(line.contains(&event.event_hash));
346 }
347
348 #[test]
349 fn deterministic_output() {
350 let event = sample_create_event();
351 let line1 = to_tsjson_line(&event).expect("serialize");
352 let line2 = to_tsjson_line(&event).expect("serialize");
353 assert_eq!(line1, line2, "same event should produce same line");
354 }
355
356 #[test]
357 fn all_event_types_serialize() {
358 use crate::model::item::{Kind, State, Urgency};
359 use serde_json::json;
360
361 let base_event = |event_type: EventType, data: EventData| Event {
362 wall_ts_us: 1_000_000,
363 agent: "agent".into(),
364 itc: "itc:X".into(),
365 parents: vec![],
366 event_type,
367 item_id: ItemId::new_unchecked("bn-a7x"),
368 data,
369 event_hash: "blake3:000".into(),
370 };
371
372 let events = vec![
373 base_event(
374 EventType::Create,
375 EventData::Create(CreateData {
376 title: "T".into(),
377 kind: Kind::Task,
378 size: None,
379 urgency: Urgency::Default,
380 labels: vec![],
381 parent: None,
382 causation: None,
383 description: None,
384 extra: BTreeMap::new(),
385 }),
386 ),
387 base_event(
388 EventType::Update,
389 EventData::Update(UpdateData {
390 field: "title".into(),
391 value: json!("New"),
392 extra: BTreeMap::new(),
393 }),
394 ),
395 base_event(
396 EventType::Move,
397 EventData::Move(MoveData {
398 state: State::Done,
399 reason: Some("done".into()),
400 extra: BTreeMap::new(),
401 }),
402 ),
403 base_event(
404 EventType::Assign,
405 EventData::Assign(AssignData {
406 agent: "alice".into(),
407 action: AssignAction::Assign,
408 extra: BTreeMap::new(),
409 }),
410 ),
411 base_event(
412 EventType::Comment,
413 EventData::Comment(CommentData {
414 body: "Note".into(),
415 extra: BTreeMap::new(),
416 }),
417 ),
418 base_event(
419 EventType::Link,
420 EventData::Link(LinkData {
421 target: "bn-b8y".into(),
422 link_type: "blocks".into(),
423 extra: BTreeMap::new(),
424 }),
425 ),
426 base_event(
427 EventType::Unlink,
428 EventData::Unlink(UnlinkData {
429 target: "bn-b8y".into(),
430 link_type: None,
431 extra: BTreeMap::new(),
432 }),
433 ),
434 base_event(
435 EventType::Delete,
436 EventData::Delete(DeleteData {
437 reason: None,
438 extra: BTreeMap::new(),
439 }),
440 ),
441 base_event(
442 EventType::Compact,
443 EventData::Compact(CompactData {
444 summary: "TL;DR".into(),
445 extra: BTreeMap::new(),
446 }),
447 ),
448 base_event(
449 EventType::Snapshot,
450 EventData::Snapshot(SnapshotData {
451 state: json!({"id": "bn-a7x"}),
452 extra: BTreeMap::new(),
453 }),
454 ),
455 base_event(
456 EventType::Redact,
457 EventData::Redact(RedactData {
458 target_hash: "blake3:xyz".into(),
459 reason: "oops".into(),
460 extra: BTreeMap::new(),
461 }),
462 ),
463 ];
464
465 assert_eq!(events.len(), 11, "should cover all 11 event types");
466
467 for event in &events {
468 let result = to_tsjson_line(event);
469 assert!(
470 result.is_ok(),
471 "failed to serialize {}: {:?}",
472 event.event_type,
473 result.err()
474 );
475 let line = result.expect("checked above");
476 let fields: Vec<&str> = line.split('\t').collect();
477 assert_eq!(
478 fields.len(),
479 8,
480 "wrong field count for {}",
481 event.event_type
482 );
483 assert!(
485 !line.contains('\n'),
486 "newline in output for {}",
487 event.event_type
488 );
489 }
490 }
491
492 #[test]
493 fn write_event_roundtrip_hash() {
494 let mut event = sample_move_event();
497 let line = write_event(&mut event).expect("write");
498
499 let fields: Vec<&str> = line.trim_end().split('\t').collect();
501 let line_hash = fields[7];
502 assert_eq!(line_hash, event.event_hash);
503
504 let recomputed = compute_event_hash(&event).expect("hash");
506 assert_eq!(recomputed, event.event_hash);
507 }
508
509 #[test]
510 fn empty_extra_fields_not_in_json() {
511 let event = sample_create_event();
513 let line = to_tsjson_line(&event).expect("serialize");
514 let fields: Vec<&str> = line.split('\t').collect();
515 let json_str = fields[6];
516
517 let val: serde_json::Value = serde_json::from_str(json_str).expect("parse");
520 let obj = val.as_object().expect("object");
522 for key in obj.keys() {
523 assert!(
524 [
525 "title",
526 "kind",
527 "size",
528 "urgency",
529 "labels",
530 "parent",
531 "causation",
532 "description"
533 ]
534 .contains(&key.as_str()),
535 "unexpected key in JSON: {key}"
536 );
537 }
538 }
539}