Skip to main content

bones_core/event/
writer.rs

1//! TSJSON event writer/serializer.
2//!
3//! Serializes [`Event`] structs to TSJSON (tab-separated) lines. Guarantees:
4//!
5//! - Canonical JSON payload (keys sorted, compact, no whitespace).
6//! - One-line invariant: no literal `\n` in the serialized JSON.
7//! - Deterministic: same event always produces the same output bytes.
8//! - Event hash is BLAKE3 of fields 1–7 joined by tabs, newline-terminated.
9//!
10//! # TSJSON Format
11//!
12//! ```text
13//! {wall_ts_us}\t{agent}\t{itc}\t{parents}\t{type}\t{item_id}\t{data_json}\t{event_hash}\n
14//! ```
15
16use super::Event;
17use super::canonical::canonicalize_json;
18use super::hash_text::encode_blake3_hash;
19
20/// The shard header line written at the start of new event log files.
21pub const SHARD_HEADER: &str = "# bones event log v1";
22
23/// The field-description comment line written after the shard header.
24pub const FIELD_COMMENT: &str =
25    "# fields: wall_ts_us\tagent\titc\tparents\ttype\titem_id\tdata\tevent_hash";
26
27// ---------------------------------------------------------------------------
28// Errors
29// ---------------------------------------------------------------------------
30
31/// Errors that can occur during event writing.
32#[derive(Debug, thiserror::Error)]
33pub enum WriteError {
34    /// The serialized JSON payload contained a literal newline.
35    #[error("JSON payload contains literal newline — one-line invariant violated")]
36    NewlineInPayload,
37
38    /// Failed to serialize the event data payload to JSON.
39    #[error("failed to serialize event data: {0}")]
40    SerializeData(#[from] serde_json::Error),
41}
42
43// ---------------------------------------------------------------------------
44// Public API
45// ---------------------------------------------------------------------------
46
47/// Return the shard header block (header + field comment) for a new event file.
48///
49/// Includes the trailing newline on each line.
50#[must_use]
51pub fn shard_header() -> String {
52    format!("{SHARD_HEADER}\n{FIELD_COMMENT}\n")
53}
54
55/// Serialize an [`Event`] to a single TSJSON line (without trailing newline).
56///
57/// The data payload is serialized as canonical JSON (sorted keys, compact).
58/// The `event_hash` field on the Event is included as-is.
59///
60/// # Errors
61///
62/// Returns [`WriteError::NewlineInPayload`] if the canonical JSON contains
63/// a literal newline (should never happen with valid data, but enforced).
64///
65/// Returns [`WriteError::SerializeData`] if the payload fails to serialize.
66pub fn to_tsjson_line(event: &Event) -> Result<String, WriteError> {
67    let data_json = canonical_data_json(event)?;
68
69    // Enforce one-line invariant
70    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
89/// Serialize an [`Event`] to a TSJSON line with trailing newline.
90///
91/// Convenience wrapper around [`to_tsjson_line`] that appends `\n`.
92///
93/// # Errors
94///
95/// Same as [`to_tsjson_line`].
96pub 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
102/// Compute the BLAKE3 event hash from fields 1–7 of an Event.
103///
104/// The hash input is the UTF-8 bytes of:
105/// `{wall_ts_us}\t{agent}\t{itc}\t{parents}\t{type}\t{item_id}\t{data_json}\n`
106///
107/// Returns the hash in `blake3:<base64url-no-pad>` format.
108///
109/// # Errors
110///
111/// Returns [`WriteError::SerializeData`] if the payload fails to serialize.
112pub 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
131/// Compute the event hash and set it on a mutable Event, then serialize.
132///
133/// This is the primary write path: it computes the content hash, stores it
134/// in `event.event_hash`, and returns the full TSJSON line (with newline).
135///
136/// # Errors
137///
138/// Same as [`to_tsjson_line`].
139pub fn write_event(event: &mut Event) -> Result<String, WriteError> {
140    event.event_hash = compute_event_hash(event)?;
141    write_line(event)
142}
143
144// ---------------------------------------------------------------------------
145// Internals
146// ---------------------------------------------------------------------------
147
148/// Serialize the event data payload to canonical JSON.
149fn canonical_data_json(event: &Event) -> Result<String, WriteError> {
150    let value = event.data.to_json_value()?;
151    Ok(canonicalize_json(&value))
152}
153
154// ---------------------------------------------------------------------------
155// Tests
156// ---------------------------------------------------------------------------
157
158#[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        // Should have exactly 2 lines
213        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        // Should be tab-separated with 8 fields
222        let fields: Vec<&str> = line.split('\t').collect();
223        assert_eq!(fields.len(), 8, "expected 8 tab-separated fields");
224
225        // Field 1: timestamp
226        assert_eq!(fields[0], "1708012200123456");
227        // Field 2: agent
228        assert_eq!(fields[1], "claude-abc");
229        // Field 3: itc
230        assert_eq!(fields[2], "itc:AQ");
231        // Field 4: parents (empty for root)
232        assert_eq!(fields[3], "");
233        // Field 5: event type
234        assert_eq!(fields[4], "item.create");
235        // Field 6: item_id
236        assert_eq!(fields[5], "bn-a3f8");
237        // Field 7: canonical JSON data
238        assert!(fields[6].starts_with('{'));
239        assert!(fields[6].ends_with('}'));
240        // Field 8: event hash
241        assert_eq!(fields[7], "blake3:placeholder");
242
243        // No newline in the output
244        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        // Parents field should have the hash
254        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        // Only one newline, at the end
273        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        // Parse back and check key order — canonical means sorted
284        // For CreateData, keys should be alphabetically ordered
285        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        // Verify sorted
290        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        // Canonical JSON should have no spaces outside of string values
303        // Quick check: no " : " or ", " patterns
304        assert!(!json_str.contains(" :"));
305        assert!(!json_str.contains(": "));
306        // It's OK for string VALUES to have spaces (e.g., "Fix auth retry")
307    }
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        // The line should contain the computed hash
345        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            // Verify no newlines
484            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        // write_event should compute hash, then the line should contain
495        // that exact hash
496        let mut event = sample_move_event();
497        let line = write_event(&mut event).expect("write");
498
499        // Extract hash from the line
500        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        // Recompute hash independently
505        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        // BTreeMap extras should not appear when empty
512        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        // "extra" should not appear in the JSON (flatten with empty map)
518        // Note: serde flatten with empty BTreeMap produces no extra keys
519        let val: serde_json::Value = serde_json::from_str(json_str).expect("parse");
520        // The only keys should be the ones defined in CreateData
521        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}