use super::Event;
use super::canonical::canonicalize_json;
use super::hash_text::encode_blake3_hash;
pub const SHARD_HEADER: &str = "# bones event log v1";
pub const FIELD_COMMENT: &str =
"# fields: wall_ts_us\tagent\titc\tparents\ttype\titem_id\tdata\tevent_hash";
#[derive(Debug, thiserror::Error)]
pub enum WriteError {
#[error("JSON payload contains literal newline — one-line invariant violated")]
NewlineInPayload,
#[error("failed to serialize event data: {0}")]
SerializeData(#[from] serde_json::Error),
}
#[must_use]
pub fn shard_header() -> String {
format!("{SHARD_HEADER}\n{FIELD_COMMENT}\n")
}
pub fn to_tsjson_line(event: &Event) -> Result<String, WriteError> {
let data_json = canonical_data_json(event)?;
if data_json.contains('\n') {
return Err(WriteError::NewlineInPayload);
}
let parents = event.parents_str();
Ok(format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
event.wall_ts_us,
event.agent,
event.itc,
parents,
event.event_type,
event.item_id,
data_json,
event.event_hash,
))
}
pub fn write_line(event: &Event) -> Result<String, WriteError> {
let mut line = to_tsjson_line(event)?;
line.push('\n');
Ok(line)
}
pub fn compute_event_hash(event: &Event) -> Result<String, WriteError> {
let data_json = canonical_data_json(event)?;
let parents = event.parents_str();
let hash_input = format!(
"{}\t{}\t{}\t{}\t{}\t{}\t{}\n",
event.wall_ts_us,
event.agent,
event.itc,
parents,
event.event_type,
event.item_id,
data_json,
);
let hash = blake3::hash(hash_input.as_bytes());
Ok(encode_blake3_hash(&hash))
}
pub fn write_event(event: &mut Event) -> Result<String, WriteError> {
event.event_hash = compute_event_hash(event)?;
write_line(event)
}
fn canonical_data_json(event: &Event) -> Result<String, WriteError> {
let value = event.data.to_json_value()?;
Ok(canonicalize_json(&value))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::data::*;
use crate::event::types::EventType;
use crate::model::item_id::ItemId;
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:placeholder".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 shard_header_format() {
let header = shard_header();
assert!(header.starts_with("# bones event log v1\n"));
assert!(header.contains("# fields:"));
assert!(header.ends_with('\n'));
assert_eq!(header.lines().count(), 2);
}
#[test]
fn to_tsjson_line_create_event() {
let event = sample_create_event();
let line = to_tsjson_line(&event).expect("should serialize");
let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields.len(), 8, "expected 8 tab-separated fields");
assert_eq!(fields[0], "1708012200123456");
assert_eq!(fields[1], "claude-abc");
assert_eq!(fields[2], "itc:AQ");
assert_eq!(fields[3], "");
assert_eq!(fields[4], "item.create");
assert_eq!(fields[5], "bn-a3f8");
assert!(fields[6].starts_with('{'));
assert!(fields[6].ends_with('}'));
assert_eq!(fields[7], "blake3:placeholder");
assert!(!line.contains('\n'));
}
#[test]
fn to_tsjson_line_with_parents() {
let event = sample_move_event();
let line = to_tsjson_line(&event).expect("should serialize");
let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields[3], "blake3:a1b2c3d4e5f6");
}
#[test]
fn to_tsjson_line_multiple_parents() {
let mut event = sample_move_event();
event.parents = vec!["blake3:aaa".into(), "blake3:bbb".into()];
let line = to_tsjson_line(&event).expect("should serialize");
let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(fields[3], "blake3:aaa,blake3:bbb");
}
#[test]
fn write_line_has_trailing_newline() {
let event = sample_create_event();
let line = write_line(&event).expect("should serialize");
assert!(line.ends_with('\n'));
assert_eq!(line.matches('\n').count(), 1);
}
#[test]
fn canonical_json_keys_sorted() {
let event = sample_create_event();
let line = to_tsjson_line(&event).expect("should serialize");
let fields: Vec<&str> = line.split('\t').collect();
let json_str = fields[6];
let val: serde_json::Value = serde_json::from_str(json_str).expect("valid JSON");
let obj = val.as_object().expect("should be object");
let keys: Vec<&String> = obj.keys().collect();
let mut sorted_keys = keys.clone();
sorted_keys.sort();
assert_eq!(keys, sorted_keys, "JSON keys should be sorted");
}
#[test]
fn json_payload_no_whitespace() {
let event = sample_create_event();
let line = to_tsjson_line(&event).expect("should serialize");
let fields: Vec<&str> = line.split('\t').collect();
let json_str = fields[6];
assert!(!json_str.contains(" :"));
assert!(!json_str.contains(": "));
}
#[test]
fn compute_event_hash_deterministic() {
let event = sample_create_event();
let hash1 = compute_event_hash(&event).expect("hash");
let hash2 = compute_event_hash(&event).expect("hash");
assert_eq!(hash1, hash2, "same event should produce same hash");
assert!(
hash1.starts_with("blake3:"),
"hash should have blake3: prefix"
);
}
#[test]
fn compute_event_hash_changes_with_data() {
let event1 = sample_create_event();
let mut event2 = sample_create_event();
event2.wall_ts_us += 1;
let hash1 = compute_event_hash(&event1).expect("hash");
let hash2 = compute_event_hash(&event2).expect("hash");
assert_ne!(
hash1, hash2,
"different events should have different hashes"
);
}
#[test]
fn write_event_sets_hash() {
let mut event = sample_create_event();
assert_eq!(event.event_hash, "blake3:placeholder");
let line = write_event(&mut event).expect("write");
assert_ne!(event.event_hash, "blake3:placeholder");
assert!(event.event_hash.starts_with("blake3:"));
assert!(line.contains(&event.event_hash));
}
#[test]
fn deterministic_output() {
let event = sample_create_event();
let line1 = to_tsjson_line(&event).expect("serialize");
let line2 = to_tsjson_line(&event).expect("serialize");
assert_eq!(line1, line2, "same event should produce same line");
}
#[test]
fn all_event_types_serialize() {
use crate::model::item::{Kind, State, Urgency};
use serde_json::json;
let base_event = |event_type: EventType, data: EventData| Event {
wall_ts_us: 1_000_000,
agent: "agent".into(),
itc: "itc:X".into(),
parents: vec![],
event_type,
item_id: ItemId::new_unchecked("bn-a7x"),
data,
event_hash: "blake3:000".into(),
};
let events = vec![
base_event(
EventType::Create,
EventData::Create(CreateData {
title: "T".into(),
kind: Kind::Task,
size: None,
urgency: Urgency::Default,
labels: vec![],
parent: None,
causation: None,
description: None,
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Update,
EventData::Update(UpdateData {
field: "title".into(),
value: json!("New"),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Move,
EventData::Move(MoveData {
state: State::Done,
reason: Some("done".into()),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Assign,
EventData::Assign(AssignData {
agent: "alice".into(),
action: AssignAction::Assign,
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Comment,
EventData::Comment(CommentData {
body: "Note".into(),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Link,
EventData::Link(LinkData {
target: "bn-b8y".into(),
link_type: "blocks".into(),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Unlink,
EventData::Unlink(UnlinkData {
target: "bn-b8y".into(),
link_type: None,
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Delete,
EventData::Delete(DeleteData {
reason: None,
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Compact,
EventData::Compact(CompactData {
summary: "TL;DR".into(),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Snapshot,
EventData::Snapshot(SnapshotData {
state: json!({"id": "bn-a7x"}),
extra: BTreeMap::new(),
}),
),
base_event(
EventType::Redact,
EventData::Redact(RedactData {
target_hash: "blake3:xyz".into(),
reason: "oops".into(),
extra: BTreeMap::new(),
}),
),
];
assert_eq!(events.len(), 11, "should cover all 11 event types");
for event in &events {
let result = to_tsjson_line(event);
assert!(
result.is_ok(),
"failed to serialize {}: {:?}",
event.event_type,
result.err()
);
let line = result.expect("checked above");
let fields: Vec<&str> = line.split('\t').collect();
assert_eq!(
fields.len(),
8,
"wrong field count for {}",
event.event_type
);
assert!(
!line.contains('\n'),
"newline in output for {}",
event.event_type
);
}
}
#[test]
fn write_event_roundtrip_hash() {
let mut event = sample_move_event();
let line = write_event(&mut event).expect("write");
let fields: Vec<&str> = line.trim_end().split('\t').collect();
let line_hash = fields[7];
assert_eq!(line_hash, event.event_hash);
let recomputed = compute_event_hash(&event).expect("hash");
assert_eq!(recomputed, event.event_hash);
}
#[test]
fn empty_extra_fields_not_in_json() {
let event = sample_create_event();
let line = to_tsjson_line(&event).expect("serialize");
let fields: Vec<&str> = line.split('\t').collect();
let json_str = fields[6];
let val: serde_json::Value = serde_json::from_str(json_str).expect("parse");
let obj = val.as_object().expect("object");
for key in obj.keys() {
assert!(
[
"title",
"kind",
"size",
"urgency",
"labels",
"parent",
"causation",
"description"
]
.contains(&key.as_str()),
"unexpected key in JSON: {key}"
);
}
}
}