Skip to main content

bones_core/event/
parser.rs

1//! Zero-copy TSJSON line parser.
2//!
3//! Parses TSJSON (tab-separated fields with JSON payload) event lines into
4//! [`Event`] structs or partially-parsed [`PartialEvent`] records. Designed
5//! for high-throughput scanning of event shard files.
6//!
7//! # TSJSON Format (v1, 8-field)
8//!
9//! ```text
10//! wall_ts_us \t agent \t itc \t parents \t type \t item_id \t data \t event_hash
11//! ```
12//!
13//! - Comment lines start with `#` and are returned as [`ParsedLine::Comment`].
14//! - Blank/whitespace-only lines are returned as [`ParsedLine::Blank`].
15//! - Data lines are split on exactly 7 tab characters (yielding 8 fields).
16//!
17//! # Zero-copy
18//!
19//! [`PartialEvent`] borrows `&str` slices from the input line wherever
20//! possible. Full parse ([`parse_line`]) copies into owned [`Event`] only
21//! after validation succeeds.
22
23use std::fmt;
24
25use tracing::warn;
26
27use crate::event::Event;
28use crate::event::canonical::canonicalize_json;
29use crate::event::data::EventData;
30use crate::event::hash_text::{decode_blake3_hash, encode_blake3_hash, is_valid_blake3_hash};
31use crate::event::migrate_event;
32use crate::event::types::EventType;
33use crate::model::item_id::ItemId;
34
35// ---------------------------------------------------------------------------
36// Shard header constants
37// ---------------------------------------------------------------------------
38
39/// The shard header line written at the top of every `.events` file.
40pub const SHARD_HEADER: &str = "# bones event log v1";
41
42/// The field comment line that follows the shard header.
43pub const FIELD_COMMENT: &str = "# fields: wall_ts_us \\t agent \\t itc \\t parents \\t type \\t item_id \\t data \\t event_hash";
44
45/// The current event log format version understood by this build of bones.
46pub const CURRENT_VERSION: u32 = 1;
47
48/// The header prefix for detecting format version.
49const HEADER_PREFIX: &str = "# bones event log v";
50
51// ---------------------------------------------------------------------------
52// Error types
53// ---------------------------------------------------------------------------
54
55/// Errors that can occur while parsing a TSJSON line.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ParseError {
58    /// Line has the wrong number of tab-separated fields.
59    FieldCount {
60        /// Number of fields found.
61        found: usize,
62        /// Expected number of fields.
63        expected: usize,
64    },
65    /// The `wall_ts_us` field is not a valid i64.
66    InvalidTimestamp(String),
67    /// The `agent` field is empty or contains whitespace.
68    InvalidAgent(String),
69    /// The `itc` field is empty.
70    EmptyItc,
71    /// A parent hash has an invalid format.
72    InvalidParentHash(String),
73    /// The event type string is not a known `item.<verb>`.
74    InvalidEventType(String),
75    /// The item ID is not a valid bones ID.
76    InvalidItemId(String),
77    /// The data field is not valid JSON.
78    InvalidDataJson(String),
79    /// The data JSON does not match the expected schema for the event type.
80    DataSchemaMismatch {
81        /// The event type.
82        event_type: String,
83        /// Details of the mismatch.
84        details: String,
85    },
86    /// The `event_hash` field has an invalid format.
87    InvalidEventHash(String),
88    /// The computed hash does not match `event_hash`.
89    HashMismatch {
90        /// Expected (from the line).
91        expected: String,
92        /// Computed from fields 1–7.
93        computed: String,
94    },
95    /// The shard was written by a newer version of bones.
96    ///
97    /// The inner string is a human-readable upgrade message.
98    VersionMismatch(String),
99}
100
101impl fmt::Display for ParseError {
102    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
103        match self {
104            Self::FieldCount { found, expected } => {
105                write!(f, "expected {expected} tab-separated fields, found {found}")
106            }
107            Self::InvalidTimestamp(raw) => {
108                write!(f, "invalid wall_ts_us (not i64): '{raw}'")
109            }
110            Self::InvalidAgent(raw) => {
111                write!(f, "invalid agent field: '{raw}'")
112            }
113            Self::EmptyItc => write!(f, "itc field is empty"),
114            Self::InvalidParentHash(raw) => {
115                write!(f, "invalid parent hash: '{raw}'")
116            }
117            Self::InvalidEventType(raw) => {
118                write!(f, "unknown event type: '{raw}'")
119            }
120            Self::InvalidItemId(raw) => {
121                write!(f, "invalid item ID: '{raw}'")
122            }
123            Self::InvalidDataJson(details) => {
124                write!(f, "invalid data JSON: {details}")
125            }
126            Self::DataSchemaMismatch {
127                event_type,
128                details,
129            } => {
130                write!(f, "data schema mismatch for {event_type}: {details}")
131            }
132            Self::InvalidEventHash(raw) => {
133                write!(f, "invalid event_hash format: '{raw}'")
134            }
135            Self::HashMismatch { expected, computed } => {
136                write!(
137                    f,
138                    "event_hash mismatch: line has '{expected}', computed '{computed}'"
139                )
140            }
141            Self::VersionMismatch(msg) => write!(f, "event log version mismatch: {msg}"),
142        }
143    }
144}
145
146impl std::error::Error for ParseError {}
147
148// ---------------------------------------------------------------------------
149// Version detection
150// ---------------------------------------------------------------------------
151
152/// Detect the event log format version from the first line of a shard file.
153///
154/// The expected header format is `# bones event log v<N>` where `N` is a
155/// positive integer.
156///
157/// # Returns
158///
159/// - `Ok(version)` if the header is present and the version is ≤
160///   [`CURRENT_VERSION`].
161/// - `Err(message)` with an actionable upgrade instruction if the version
162///   is newer than this build of bones, the header is malformed, or the
163///   version number cannot be parsed.
164///
165/// # Forward compatibility
166///
167/// A version number greater than [`CURRENT_VERSION`] means this file was
168/// written by a newer version of bones and may contain format changes that
169/// this version cannot handle. The error message instructs the user to
170/// upgrade.
171///
172/// # Errors
173///
174/// Returns an error string if the header is missing, malformed, has an
175/// unparseable version number, or the version exceeds [`CURRENT_VERSION`].
176///
177/// # Backward compatibility
178///
179/// All prior format versions are guaranteed to be readable by this version.
180/// Version-specific parsing is dispatched via the returned version number.
181pub fn detect_version(first_line: &str) -> Result<u32, String> {
182    let line = first_line.trim();
183    if !line.starts_with(HEADER_PREFIX) {
184        return Err(format!(
185            "Invalid event log header: expected '{HEADER_PREFIX}N', got '{line}'.\n\
186             This file may not be a bones event log, or it may be from \
187             a version of bones that predates format versioning."
188        ));
189    }
190    let version_str = &line[HEADER_PREFIX.len()..];
191    let version: u32 = version_str.parse().map_err(|_| {
192        format!(
193            "Invalid version number '{version_str}' in event log header.\n\
194             Expected a positive integer after '{HEADER_PREFIX}'."
195        )
196    })?;
197    if version > CURRENT_VERSION {
198        return Err(format!(
199            "Event log version {version} is newer than this version of bones \
200             (supports up to v{CURRENT_VERSION}).\n\
201             Please upgrade bones: cargo install bones-cli\n\
202             Or download the latest release from: \
203             https://github.com/bobisme/bones/releases"
204        ));
205    }
206    Ok(version)
207}
208
209// ---------------------------------------------------------------------------
210// Parsed output types
211// ---------------------------------------------------------------------------
212
213/// The result of parsing a single line from a TSJSON shard file.
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum ParsedLine {
216    /// A comment line (starts with `#`). The text includes the `#` prefix.
217    Comment(String),
218    /// A blank or whitespace-only line.
219    Blank,
220    /// A successfully parsed event (boxed to reduce enum size).
221    Event(Box<Event>),
222}
223
224/// A partially-parsed event that borrows from the input line.
225///
226/// Extracts the fixed header fields (`wall_ts_us` through `item_id`) without
227/// parsing the JSON data payload or verifying the event hash. Useful for
228/// filtering and scanning.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct PartialEvent<'a> {
231    /// Wall-clock timestamp in microseconds.
232    pub wall_ts_us: i64,
233    /// Agent identifier.
234    pub agent: &'a str,
235    /// ITC clock stamp.
236    pub itc: &'a str,
237    /// Raw parents field (comma-separated hashes or empty).
238    pub parents_raw: &'a str,
239    /// Event type.
240    pub event_type: EventType,
241    /// Item ID (raw string, not yet validated as `ItemId`).
242    pub item_id_raw: &'a str,
243    /// Raw data JSON (unparsed).
244    pub data_raw: &'a str,
245    /// Raw event hash.
246    pub event_hash_raw: &'a str,
247}
248
249/// The result of partially parsing a single line.
250#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum PartialParsedLine<'a> {
252    /// A comment line.
253    Comment(&'a str),
254    /// A blank or whitespace-only line.
255    Blank,
256    /// A partially-parsed event.
257    Event(PartialEvent<'a>),
258}
259
260// ---------------------------------------------------------------------------
261// Validation helpers
262// ---------------------------------------------------------------------------
263
264/// Compute the BLAKE3 event hash from the first 7 fields joined by tabs.
265///
266/// Hash input: `{f1}\t{f2}\t{f3}\t{f4}\t{f5}\t{f6}\t{f7}\n`
267fn compute_event_hash(fields: &[&str; 7]) -> String {
268    let mut input = String::new();
269    for (i, field) in fields.iter().enumerate() {
270        if i > 0 {
271            input.push('\t');
272        }
273        input.push_str(field);
274    }
275    input.push('\n');
276    let hash = blake3::hash(input.as_bytes());
277    encode_blake3_hash(&hash)
278}
279
280/// Split a line on tab characters. Returns an iterator of field slices.
281fn split_fields(line: &str) -> impl Iterator<Item = &str> {
282    line.split('\t')
283}
284
285/// Accept legacy terseid-like IDs where the suffix starts with a letter
286/// (older generator didn't enforce digit-first).  Format: `prefix-suffix`
287/// where prefix is 1-4 lowercase alpha and suffix uses crockford base32
288/// charset (`0-9a-hjkmnp-tv-z`, no `i/l/o/u`) with optional `.` for child IDs.
289fn parse_legacy_item_id(raw: &str) -> Option<ItemId> {
290    const fn is_crockford(c: char) -> bool {
291        matches!(c, '0'..='9' | 'a'..='h' | 'j' | 'k' | 'm' | 'n' | 'p'..='t' | 'v'..='z')
292    }
293    let normalized = raw.trim().to_lowercase();
294    let (prefix, rest) = normalized.split_once('-')?;
295    if prefix.is_empty() || prefix.len() > 4 || !prefix.chars().all(|c| c.is_ascii_lowercase()) {
296        return None;
297    }
298    if rest.is_empty() || rest.len() > 20 {
299        return None;
300    }
301    // Each segment (split on `.`) must be non-empty crockford base32
302    for seg in rest.split('.') {
303        if seg.is_empty() || !seg.chars().all(is_crockford) {
304            return None;
305        }
306    }
307    Some(ItemId::new_unchecked(normalized))
308}
309
310// ---------------------------------------------------------------------------
311// Partial parse (zero-copy)
312// ---------------------------------------------------------------------------
313
314/// Parse a TSJSON line into a [`PartialParsedLine`] without deserializing
315/// the JSON payload or verifying the event hash.
316///
317/// This is the fast path for filtering and scanning. It validates:
318/// - Field count (exactly 8)
319/// - `wall_ts_us` is a valid i64
320/// - `event_type` is a known variant
321///
322/// It does **not** validate: agent format, ITC format, parent hashes,
323/// item ID format, JSON validity, or event hash.
324///
325/// # Errors
326///
327/// Returns [`ParseError`] if the line cannot be parsed.
328pub fn parse_line_partial(line: &str) -> Result<PartialParsedLine<'_>, ParseError> {
329    let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
330
331    // Comment line
332    if trimmed.starts_with('#') {
333        return Ok(PartialParsedLine::Comment(trimmed));
334    }
335
336    // Blank line
337    if trimmed.trim().is_empty() {
338        return Ok(PartialParsedLine::Blank);
339    }
340
341    // Split on tabs
342    let fields: Vec<&str> = split_fields(trimmed).collect();
343    if fields.len() != 8 {
344        return Err(ParseError::FieldCount {
345            found: fields.len(),
346            expected: 8,
347        });
348    }
349
350    // Parse wall_ts_us
351    let wall_ts_us: i64 = fields[0]
352        .parse()
353        .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
354
355    // Parse event type
356    let event_type: EventType = fields[4]
357        .parse()
358        .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
359
360    Ok(PartialParsedLine::Event(PartialEvent {
361        wall_ts_us,
362        agent: fields[1],
363        itc: fields[2],
364        parents_raw: fields[3],
365        event_type,
366        item_id_raw: fields[5],
367        data_raw: fields[6],
368        event_hash_raw: fields[7],
369    }))
370}
371
372// ---------------------------------------------------------------------------
373// Full parse
374// ---------------------------------------------------------------------------
375
376/// Fully parse and validate a TSJSON line into a [`ParsedLine`].
377///
378/// Performs all validations including:
379/// - Field count (exactly 8 tab-separated fields)
380/// - `wall_ts_us` is a valid i64
381/// - `agent` is non-empty and contains no whitespace
382/// - `itc` is non-empty
383/// - `parents` are valid `blake3:<payload>` hashes (or empty)
384/// - `event_type` is a known `item.<verb>`
385/// - `item_id` is a valid bones ID
386/// - `data` is valid JSON matching the event type schema
387/// - `event_hash` is `blake3:<payload>` and matches the recomputed hash
388///
389/// # Errors
390///
391/// Returns [`ParseError`] with a specific variant for each validation failure.
392pub fn parse_line(line: &str) -> Result<ParsedLine, ParseError> {
393    let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
394
395    // Comment line
396    if trimmed.starts_with('#') {
397        return Ok(ParsedLine::Comment(trimmed.to_string()));
398    }
399
400    // Blank line
401    if trimmed.trim().is_empty() {
402        return Ok(ParsedLine::Blank);
403    }
404
405    // Split on tabs
406    let fields: Vec<&str> = split_fields(trimmed).collect();
407    if fields.len() != 8 {
408        return Err(ParseError::FieldCount {
409            found: fields.len(),
410            expected: 8,
411        });
412    }
413
414    // --- Field 1: wall_ts_us ---
415    let wall_ts_us: i64 = fields[0]
416        .parse()
417        .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
418
419    // --- Field 2: agent ---
420    let agent = fields[1];
421    if agent.is_empty() || agent.chars().any(|c| c == '\t' || c == '\n' || c == '\r') {
422        return Err(ParseError::InvalidAgent(agent.to_string()));
423    }
424
425    // --- Field 3: itc ---
426    let itc = fields[2];
427    if itc.is_empty() {
428        return Err(ParseError::EmptyItc);
429    }
430
431    // --- Field 4: parents ---
432    let parents_raw = fields[3];
433    let parents: Vec<String> = if parents_raw.is_empty() {
434        Vec::new()
435    } else {
436        let parts: Vec<&str> = parents_raw.split(',').collect();
437        for p in &parts {
438            if !is_valid_blake3_hash(p) {
439                return Err(ParseError::InvalidParentHash((*p).to_string()));
440            }
441        }
442        parts.iter().map(|s| (*s).to_string()).collect()
443    };
444
445    // --- Field 5: event type ---
446    let event_type: EventType = fields[4]
447        .parse()
448        .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
449
450    // --- Field 6: item_id ---
451    // Accept any valid terseid prefix (not just bn-) to support migrated IDs.
452    // Fall back to unchecked for legacy IDs (e.g. letter-first terseids from
453    // older generators where the validator and generator were out of sync).
454    let item_id = match ItemId::parse_any_prefix(fields[5]) {
455        Ok(id) => id,
456        Err(_) => parse_legacy_item_id(fields[5])
457            .ok_or_else(|| ParseError::InvalidItemId(fields[5].to_string()))?,
458    };
459
460    // --- Field 7: data (JSON) ---
461    let data_json = fields[6];
462    // Validate JSON syntax first
463    let _: serde_json::Value =
464        serde_json::from_str(data_json).map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
465    // Deserialize into typed payload
466    let data = EventData::deserialize_for(event_type, data_json).map_err(|e| {
467        ParseError::DataSchemaMismatch {
468            event_type: event_type.to_string(),
469            details: e.to_string(),
470        }
471    })?;
472
473    // --- Field 8: event_hash ---
474    let event_hash = fields[7];
475    if !is_valid_blake3_hash(event_hash) {
476        return Err(ParseError::InvalidEventHash(event_hash.to_string()));
477    }
478
479    // Verify hash matches recomputed value.
480    // The canonical data JSON is used for hashing (keys sorted).
481    // Safety: we already validated `data_json` above so this cannot fail.
482    let canonical_data = serde_json::from_str::<serde_json::Value>(data_json)
483        .map(|v| canonicalize_json(&v))
484        .map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
485    let hash_fields: [&str; 7] = [
486        fields[0],
487        fields[1],
488        fields[2],
489        fields[3],
490        fields[4],
491        fields[5],
492        &canonical_data,
493    ];
494    let computed = compute_event_hash(&hash_fields);
495    let expected_bytes = decode_blake3_hash(event_hash)
496        .ok_or_else(|| ParseError::InvalidEventHash(event_hash.to_string()))?;
497    let computed_bytes = decode_blake3_hash(&computed)
498        .ok_or_else(|| ParseError::InvalidEventHash(computed.clone()))?;
499    if expected_bytes != computed_bytes {
500        return Err(ParseError::HashMismatch {
501            expected: event_hash.to_string(),
502            computed,
503        });
504    }
505
506    Ok(ParsedLine::Event(Box::new(Event {
507        wall_ts_us,
508        agent: agent.to_string(),
509        itc: itc.to_string(),
510        parents,
511        event_type,
512        item_id,
513        data,
514        event_hash: event_hash.to_string(),
515    })))
516}
517
518/// Streaming event parser.
519///
520/// Wraps an iterator of lines and yields successfully parsed events,
521/// handling version detection and migrations automatically.
522pub struct EventParser<I> {
523    lines: I,
524    version_checked: bool,
525    shard_version: u32,
526    line_no: usize,
527}
528
529impl<I> EventParser<I>
530where
531    I: Iterator<Item = String>,
532{
533    /// Create a new streaming parser from a line iterator.
534    pub const fn new(lines: I) -> Self {
535        Self {
536            lines,
537            version_checked: false,
538            shard_version: CURRENT_VERSION,
539            line_no: 0,
540        }
541    }
542}
543
544impl<I> Iterator for EventParser<I>
545where
546    I: Iterator<Item = String>,
547{
548    type Item = Result<Event, (usize, ParseError)>;
549
550    fn next(&mut self) -> Option<Self::Item> {
551        loop {
552            let line = self.lines.next()?;
553            self.line_no += 1;
554
555            if !self.version_checked && line.trim_start().starts_with(HEADER_PREFIX) {
556                self.version_checked = true;
557                match detect_version(&line) {
558                    Ok(v) => self.shard_version = v,
559                    Err(msg) => return Some(Err((self.line_no, ParseError::VersionMismatch(msg)))),
560                }
561                continue;
562            }
563
564            match parse_line(&line) {
565                Ok(ParsedLine::Event(event)) => match migrate_event(*event, self.shard_version) {
566                    Ok(migrated) => return Some(Ok(migrated)),
567                    Err(e) => {
568                        return Some(Err((
569                            self.line_no,
570                            ParseError::VersionMismatch(e.to_string()),
571                        )));
572                    }
573                },
574                Ok(ParsedLine::Comment(_) | ParsedLine::Blank) => {}
575                Err(ParseError::InvalidEventType(raw)) => {
576                    warn!(
577                        line = self.line_no,
578                        event_type = %raw,
579                        "skipping line with unknown event type (forward-compatibility)"
580                    );
581                }
582                Err(e) => return Some(Err((self.line_no, e))),
583            }
584        }
585    }
586}
587
588/// Parse multiple TSJSON lines, skipping comments and blanks.
589///
590/// Returns a `Vec` of successfully parsed events. Stops at the first error,
591/// **except** for unknown event types which are skipped with a [`tracing`]
592/// warning (forward-compatibility policy: new event types may be added
593/// without a format version bump).
594///
595/// If the first non-blank content line looks like a shard header
596/// (`# bones event log v<N>`), the version is checked via [`detect_version`]
597/// and an error is returned immediately if the file was written by a newer
598/// version of bones.
599///
600/// # Errors
601///
602/// Returns `(line_number, ParseError)` on the first malformed data line
603/// (excluding unknown event types, which are warned and skipped).
604/// Line numbers are 1-indexed.
605pub fn parse_lines(input: &str) -> Result<Vec<Event>, (usize, ParseError)> {
606    let lines = input.lines().map(String::from);
607    let parser = EventParser::new(lines);
608    parser.collect()
609}
610
611// ---------------------------------------------------------------------------
612// Tests
613// ---------------------------------------------------------------------------
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::event::canonical::canonicalize_json;
619    use crate::event::data::{CreateData, MoveData};
620    use crate::model::item::{Kind, Size, State, Urgency};
621    use std::collections::BTreeMap;
622
623    // -----------------------------------------------------------------------
624    // Test helpers
625    // -----------------------------------------------------------------------
626
627    /// Build a valid TSJSON line with a correct event hash.
628    fn make_line(
629        wall_ts_us: i64,
630        agent: &str,
631        itc: &str,
632        parents: &str,
633        event_type: &str,
634        item_id: &str,
635        data_json: &str,
636    ) -> String {
637        let canonical_data = canonicalize_json(
638            &serde_json::from_str::<serde_json::Value>(data_json).expect("test JSON"),
639        );
640        let hash_input = format!(
641            "{wall_ts_us}\t{agent}\t{itc}\t{parents}\t{event_type}\t{item_id}\t{canonical_data}\n"
642        );
643        let hash = blake3::hash(hash_input.as_bytes());
644        let event_hash = encode_blake3_hash(&hash);
645        format!(
646            "{wall_ts_us}\t{agent}\t{itc}\t{parents}\t{event_type}\t{item_id}\t{canonical_data}\t{event_hash}"
647        )
648    }
649
650    fn sample_create_json() -> String {
651        canonicalize_json(&serde_json::json!({
652            "title": "Fix auth retry",
653            "kind": "task",
654            "size": "m",
655            "labels": ["backend"]
656        }))
657    }
658
659    fn sample_move_json() -> String {
660        canonicalize_json(&serde_json::json!({
661            "state": "doing"
662        }))
663    }
664
665    fn sample_comment_json() -> String {
666        canonicalize_json(&serde_json::json!({
667            "body": "Root cause found"
668        }))
669    }
670
671    // -----------------------------------------------------------------------
672    // Comment and blank lines
673    // -----------------------------------------------------------------------
674
675    #[test]
676    fn parse_comment_line() {
677        let result = parse_line("# bones event log v1").expect("should parse");
678        assert_eq!(result, ParsedLine::Comment("# bones event log v1".into()));
679    }
680
681    #[test]
682    fn parse_comment_with_whitespace_prefix() {
683        // Lines starting with # are comments even if the rest looks odd
684        let result = parse_line("# fields: wall_ts_us \\t agent").expect("should parse");
685        assert!(matches!(result, ParsedLine::Comment(_)));
686    }
687
688    #[test]
689    fn parse_blank_line() {
690        assert_eq!(parse_line("").expect("should parse"), ParsedLine::Blank);
691        assert_eq!(parse_line("  ").expect("should parse"), ParsedLine::Blank);
692        assert_eq!(parse_line("\t").expect("should parse"), ParsedLine::Blank);
693    }
694
695    #[test]
696    fn parse_newline_only() {
697        assert_eq!(parse_line("\n").expect("should parse"), ParsedLine::Blank);
698        assert_eq!(parse_line("\r\n").expect("should parse"), ParsedLine::Blank);
699    }
700
701    // -----------------------------------------------------------------------
702    // Partial parse
703    // -----------------------------------------------------------------------
704
705    #[test]
706    fn partial_parse_comment() {
707        let result = parse_line_partial("# comment").expect("should parse");
708        assert_eq!(result, PartialParsedLine::Comment("# comment"));
709    }
710
711    #[test]
712    fn partial_parse_blank() {
713        let result = parse_line_partial("").expect("should parse");
714        assert_eq!(result, PartialParsedLine::Blank);
715    }
716
717    #[test]
718    fn partial_parse_valid_line() {
719        let line = make_line(
720            1_000_000,
721            "agent-1",
722            "itc:AQ",
723            "",
724            "item.create",
725            "bn-a7x",
726            &sample_create_json(),
727        );
728        let result = parse_line_partial(&line).expect("should parse");
729        match result {
730            PartialParsedLine::Event(pe) => {
731                assert_eq!(pe.wall_ts_us, 1_000_000);
732                assert_eq!(pe.agent, "agent-1");
733                assert_eq!(pe.itc, "itc:AQ");
734                assert_eq!(pe.parents_raw, "");
735                assert_eq!(pe.event_type, EventType::Create);
736                assert_eq!(pe.item_id_raw, "bn-a7x");
737            }
738            other => panic!("expected Event, got {other:?}"),
739        }
740    }
741
742    #[test]
743    fn partial_parse_does_not_validate_json() {
744        // Partial parse should succeed even with invalid JSON in data field
745        let line = "1000\tagent\titc:A\t\titem.create\tbn-a7x\tNOT_JSON\tblake3:aaa";
746        let result = parse_line_partial(line).expect("should parse");
747        assert!(matches!(result, PartialParsedLine::Event(_)));
748    }
749
750    #[test]
751    fn partial_parse_wrong_field_count() {
752        let err = parse_line_partial("a\tb\tc").expect_err("should fail");
753        assert!(matches!(
754            err,
755            ParseError::FieldCount {
756                found: 3,
757                expected: 8
758            }
759        ));
760    }
761
762    #[test]
763    fn partial_parse_bad_timestamp() {
764        let line = "not_a_number\tagent\titc\t\titem.create\tbn-a7x\t{}\tblake3:abc";
765        let err = parse_line_partial(line).expect_err("should fail");
766        assert!(matches!(err, ParseError::InvalidTimestamp(_)));
767    }
768
769    #[test]
770    fn partial_parse_bad_event_type() {
771        let line = "1000\tagent\titc\t\titem.unknown\tbn-a7x\t{}\tblake3:abc";
772        let err = parse_line_partial(line).expect_err("should fail");
773        assert!(matches!(err, ParseError::InvalidEventType(_)));
774    }
775
776    // -----------------------------------------------------------------------
777    // Full parse — valid lines
778    // -----------------------------------------------------------------------
779
780    #[test]
781    fn parse_valid_create_event() {
782        let line = make_line(
783            1_708_012_200_123_456,
784            "claude-abc",
785            "itc:AQ",
786            "",
787            "item.create",
788            "bn-a7x",
789            &sample_create_json(),
790        );
791        let result = parse_line(&line).expect("should parse");
792        match result {
793            ParsedLine::Event(event) => {
794                assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
795                assert_eq!(event.agent, "claude-abc");
796                assert_eq!(event.itc, "itc:AQ");
797                assert!(event.parents.is_empty());
798                assert_eq!(event.event_type, EventType::Create);
799                assert_eq!(event.item_id.as_str(), "bn-a7x");
800                match &event.data {
801                    EventData::Create(d) => {
802                        assert_eq!(d.title, "Fix auth retry");
803                        assert_eq!(d.kind, Kind::Task);
804                        assert_eq!(d.size, Some(Size::M));
805                    }
806                    other => panic!("expected Create data, got {other:?}"),
807                }
808            }
809            other => panic!("expected Event, got {other:?}"),
810        }
811    }
812
813    #[test]
814    fn parse_valid_move_event_with_parent() {
815        let parent_hash = "blake3:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd";
816        let line = make_line(
817            1_708_012_201_000_000,
818            "claude-abc",
819            "itc:AQ.1",
820            parent_hash,
821            "item.move",
822            "bn-a7x",
823            &sample_move_json(),
824        );
825        let result = parse_line(&line).expect("should parse");
826        match result {
827            ParsedLine::Event(event) => {
828                assert_eq!(event.parents, vec![parent_hash]);
829                assert_eq!(event.event_type, EventType::Move);
830                match &event.data {
831                    EventData::Move(d) => assert_eq!(d.state, State::Doing),
832                    other => panic!("expected Move data, got {other:?}"),
833                }
834            }
835            other => panic!("expected Event, got {other:?}"),
836        }
837    }
838
839    #[test]
840    fn parse_valid_event_with_multiple_parents() {
841        let p1 = encode_blake3_hash(&blake3::hash(b"p1"));
842        let p2 = encode_blake3_hash(&blake3::hash(b"p2"));
843        let parents = format!("{p1},{p2}");
844        let line = make_line(
845            1_000,
846            "agent",
847            "itc:X",
848            &parents,
849            "item.comment",
850            "bn-a7x",
851            &sample_comment_json(),
852        );
853        let result = parse_line(&line).expect("should parse");
854        match result {
855            ParsedLine::Event(event) => {
856                assert_eq!(event.parents, vec![p1.as_str(), p2.as_str()]);
857            }
858            other => panic!("expected Event, got {other:?}"),
859        }
860    }
861
862    #[test]
863    fn parse_negative_timestamp() {
864        let line = make_line(
865            -1_000_000,
866            "agent",
867            "itc:AQ",
868            "",
869            "item.comment",
870            "bn-a7x",
871            &sample_comment_json(),
872        );
873        let result = parse_line(&line).expect("should parse");
874        match result {
875            ParsedLine::Event(event) => assert_eq!(event.wall_ts_us, -1_000_000),
876            other => panic!("expected Event, got {other:?}"),
877        }
878    }
879
880    #[test]
881    fn parse_line_with_trailing_newline() {
882        let line = make_line(
883            1_000,
884            "agent",
885            "itc:AQ",
886            "",
887            "item.comment",
888            "bn-a7x",
889            &sample_comment_json(),
890        );
891        let with_newline = format!("{line}\n");
892        let result = parse_line(&with_newline).expect("should parse");
893        assert!(matches!(result, ParsedLine::Event(_)));
894    }
895
896    #[test]
897    fn parse_line_with_crlf() {
898        let line = make_line(
899            1_000,
900            "agent",
901            "itc:AQ",
902            "",
903            "item.comment",
904            "bn-a7x",
905            &sample_comment_json(),
906        );
907        let with_crlf = format!("{line}\r\n");
908        let result = parse_line(&with_crlf).expect("should parse");
909        assert!(matches!(result, ParsedLine::Event(_)));
910    }
911
912    // -----------------------------------------------------------------------
913    // Full parse — field validation errors
914    // -----------------------------------------------------------------------
915
916    #[test]
917    fn parse_wrong_field_count_too_few() {
918        let err = parse_line("only\ttwo\tfields").expect_err("should fail");
919        assert!(matches!(
920            err,
921            ParseError::FieldCount {
922                found: 3,
923                expected: 8
924            }
925        ));
926    }
927
928    #[test]
929    fn parse_wrong_field_count_too_many() {
930        let err = parse_line("1\t2\t3\t4\t5\t6\t7\t8\t9").expect_err("should fail");
931        assert!(matches!(
932            err,
933            ParseError::FieldCount {
934                found: 9,
935                expected: 8
936            }
937        ));
938    }
939
940    #[test]
941    fn parse_invalid_timestamp_not_number() {
942        let line = "abc\tagent\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
943        let err = parse_line(line).expect_err("should fail");
944        assert!(matches!(err, ParseError::InvalidTimestamp(_)));
945    }
946
947    #[test]
948    fn parse_invalid_timestamp_float() {
949        let line = "1.5\tagent\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
950        let err = parse_line(line).expect_err("should fail");
951        assert!(matches!(err, ParseError::InvalidTimestamp(_)));
952    }
953
954    #[test]
955    fn parse_empty_agent() {
956        let line = "1000\t\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
957        let err = parse_line(line).expect_err("should fail");
958        assert!(matches!(err, ParseError::InvalidAgent(_)));
959    }
960
961    #[test]
962    fn parse_empty_itc() {
963        let line = "1000\tagent\t\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
964        let err = parse_line(line).expect_err("should fail");
965        assert!(matches!(err, ParseError::EmptyItc));
966    }
967
968    #[test]
969    fn parse_invalid_parent_hash_no_prefix() {
970        let line = "1000\tagent\titc:A\tabc123\titem.create\tbn-a7x\t{}\tblake3:aaa";
971        let err = parse_line(line).expect_err("should fail");
972        assert!(matches!(err, ParseError::InvalidParentHash(_)));
973    }
974
975    #[test]
976    fn parse_invalid_parent_hash_non_hex() {
977        let line = "1000\tagent\titc:A\tblake3:xyz!\titem.create\tbn-a7x\t{}\tblake3:aaa";
978        let err = parse_line(line).expect_err("should fail");
979        assert!(matches!(err, ParseError::InvalidParentHash(_)));
980    }
981
982    #[test]
983    fn parse_invalid_event_type() {
984        let line = "1000\tagent\titc:A\t\titem.unknown\tbn-a7x\t{}\tblake3:aaa";
985        let err = parse_line(line).expect_err("should fail");
986        assert!(matches!(err, ParseError::InvalidEventType(_)));
987    }
988
989    #[test]
990    fn parse_invalid_item_id() {
991        // "noid" has no dash separator, so terseid rejects it
992        let line = "1000\tagent\titc:A\t\titem.create\tnoid\t{}\tblake3:aaa";
993        let err = parse_line(line).expect_err("should fail");
994        assert!(matches!(err, ParseError::InvalidItemId(_)));
995    }
996
997    #[test]
998    fn parse_invalid_json() {
999        let line = "1000\tagent\titc:A\t\titem.create\tbn-a7x\t{not json}\tblake3:aaa";
1000        let err = parse_line(line).expect_err("should fail");
1001        assert!(matches!(err, ParseError::InvalidDataJson(_)));
1002    }
1003
1004    #[test]
1005    fn parse_json_schema_mismatch() {
1006        // Valid JSON but doesn't match CreateData schema (missing title)
1007        let line = make_line(
1008            1000,
1009            "agent",
1010            "itc:A",
1011            "",
1012            "item.create",
1013            "bn-a7x",
1014            r#"{"kind":"task"}"#,
1015        );
1016        let err = parse_line(&line).expect_err("should fail");
1017        assert!(matches!(err, ParseError::DataSchemaMismatch { .. }));
1018    }
1019
1020    #[test]
1021    fn parse_invalid_event_hash_format() {
1022        // Build a line manually with bad hash format
1023        let line = "1000\tagent\titc:A\t\titem.comment\tbn-a7x\t{\"body\":\"hi\"}\tsha256:abc";
1024        let err = parse_line(line).expect_err("should fail");
1025        assert!(matches!(err, ParseError::InvalidEventHash(_)));
1026    }
1027
1028    #[test]
1029    fn parse_hash_mismatch() {
1030        // Valid format but wrong hash value
1031        let line = format!(
1032            "1000\tagent\titc:A\t\titem.comment\tbn-a7x\t{}\tblake3:{}",
1033            &sample_comment_json(),
1034            "0".repeat(64)
1035        );
1036        let err = parse_line(&line).expect_err("should fail");
1037        assert!(matches!(err, ParseError::HashMismatch { .. }));
1038    }
1039
1040    // -----------------------------------------------------------------------
1041    // Round-trip with writer (conceptual — write then parse)
1042    // -----------------------------------------------------------------------
1043
1044    #[test]
1045    fn roundtrip_create_event() {
1046        let data = CreateData {
1047            title: "Fix auth retry".into(),
1048            kind: Kind::Task,
1049            size: Some(Size::M),
1050            urgency: Urgency::Default,
1051            labels: vec!["backend".into()],
1052            parent: None,
1053            causation: None,
1054            description: None,
1055            extra: BTreeMap::new(),
1056        };
1057
1058        let data_json = canonicalize_json(&serde_json::to_value(&data).expect("serialize"));
1059
1060        let line = make_line(
1061            1_708_012_200_123_456,
1062            "claude-abc",
1063            "itc:AQ",
1064            "",
1065            "item.create",
1066            "bn-a7x",
1067            &data_json,
1068        );
1069
1070        let parsed = parse_line(&line).expect("should parse");
1071        match parsed {
1072            ParsedLine::Event(event) => {
1073                assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
1074                assert_eq!(event.agent, "claude-abc");
1075                assert_eq!(event.event_type, EventType::Create);
1076                assert_eq!(event.data, EventData::Create(data));
1077            }
1078            other => panic!("expected Event, got {other:?}"),
1079        }
1080    }
1081
1082    #[test]
1083    fn roundtrip_move_event() {
1084        let data = MoveData {
1085            state: State::Doing,
1086            reason: None,
1087            extra: BTreeMap::new(),
1088        };
1089
1090        let data_json = canonicalize_json(&serde_json::to_value(&data).expect("serialize"));
1091
1092        let parent_hash = encode_blake3_hash(&blake3::hash(b"parent"));
1093        let line = make_line(
1094            1_708_012_201_000_000,
1095            "agent-x",
1096            "itc:AQ.1",
1097            &parent_hash,
1098            "item.move",
1099            "bn-a7x",
1100            &data_json,
1101        );
1102
1103        let parsed = parse_line(&line).expect("should parse");
1104        match parsed {
1105            ParsedLine::Event(event) => {
1106                assert_eq!(event.parents, vec![parent_hash.as_str()]);
1107                assert_eq!(event.event_type, EventType::Move);
1108                assert_eq!(event.data, EventData::Move(data));
1109            }
1110            other => panic!("expected Event, got {other:?}"),
1111        }
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // parse_lines
1116    // -----------------------------------------------------------------------
1117
1118    #[test]
1119    fn parse_lines_mixed_content() {
1120        let line1 = make_line(
1121            1_000,
1122            "agent",
1123            "itc:AQ",
1124            "",
1125            "item.comment",
1126            "bn-a7x",
1127            &sample_comment_json(),
1128        );
1129        let line2 = make_line(
1130            2_000,
1131            "agent",
1132            "itc:AQ.1",
1133            "",
1134            "item.comment",
1135            "bn-a7x",
1136            &sample_comment_json(),
1137        );
1138
1139        let input = format!("# bones event log v1\n# fields: ...\n\n{line1}\n{line2}\n");
1140
1141        let events = parse_lines(&input).expect("should parse");
1142        assert_eq!(events.len(), 2);
1143        assert_eq!(events[0].wall_ts_us, 1_000);
1144        assert_eq!(events[1].wall_ts_us, 2_000);
1145    }
1146
1147    #[test]
1148    fn parse_lines_error_reports_line_number() {
1149        let good = make_line(
1150            1_000,
1151            "agent",
1152            "itc:AQ",
1153            "",
1154            "item.comment",
1155            "bn-a7x",
1156            &sample_comment_json(),
1157        );
1158        let input = format!("# header\n{good}\nbad_line\n");
1159        let err = parse_lines(&input).expect_err("should fail");
1160        assert_eq!(err.0, 3); // 1-indexed line number
1161    }
1162
1163    #[test]
1164    fn parse_lines_empty_input() {
1165        let events = parse_lines("").expect("should parse");
1166        assert!(events.is_empty());
1167    }
1168
1169    // -----------------------------------------------------------------------
1170    // detect_version
1171    // -----------------------------------------------------------------------
1172
1173    #[test]
1174    fn detect_version_valid_v1() {
1175        let version = detect_version("# bones event log v1").expect("should parse");
1176        assert_eq!(version, 1);
1177    }
1178
1179    #[test]
1180    fn detect_version_with_leading_whitespace() {
1181        // trim() handles leading/trailing whitespace
1182        let version = detect_version("  # bones event log v1  ").expect("should parse");
1183        assert_eq!(version, 1);
1184    }
1185
1186    #[test]
1187    fn detect_version_future_version_errors() {
1188        let err = detect_version("# bones event log v99").expect_err("should fail");
1189        assert!(err.contains("99"), "should mention version in error: {err}");
1190        assert!(
1191            err.to_lowercase().contains("upgrade")
1192                || err.to_lowercase().contains("install")
1193                || err.to_lowercase().contains("newer"),
1194            "should give upgrade advice: {err}"
1195        );
1196    }
1197
1198    #[test]
1199    fn detect_version_invalid_header() {
1200        let err = detect_version("not a valid header").expect_err("should fail");
1201        assert!(err.contains("Invalid") || err.contains("invalid"), "{err}");
1202    }
1203
1204    #[test]
1205    fn detect_version_non_numeric_version() {
1206        let err = detect_version("# bones event log vX").expect_err("should fail");
1207        assert!(!err.is_empty());
1208    }
1209
1210    #[test]
1211    fn detect_version_empty_version() {
1212        let err = detect_version("# bones event log v").expect_err("should fail");
1213        assert!(!err.is_empty());
1214    }
1215
1216    // -----------------------------------------------------------------------
1217    // parse_lines — version detection
1218    // -----------------------------------------------------------------------
1219
1220    #[test]
1221    fn parse_lines_version_header_v1_accepted() {
1222        let line = make_line(
1223            1_000,
1224            "agent",
1225            "itc:AQ",
1226            "",
1227            "item.comment",
1228            "bn-a7x",
1229            &sample_comment_json(),
1230        );
1231        let input = format!("# bones event log v1\n{line}\n");
1232        let events = parse_lines(&input).expect("v1 should be accepted");
1233        assert_eq!(events.len(), 1);
1234    }
1235
1236    #[test]
1237    fn parse_lines_future_version_rejected() {
1238        let line = make_line(
1239            1_000,
1240            "agent",
1241            "itc:AQ",
1242            "",
1243            "item.comment",
1244            "bn-a7x",
1245            &sample_comment_json(),
1246        );
1247        let input = format!("# bones event log v999\n{line}\n");
1248        let (line_no, err) = parse_lines(&input).expect_err("future version should fail");
1249        assert_eq!(line_no, 1);
1250        assert!(
1251            matches!(err, ParseError::VersionMismatch(_)),
1252            "expected VersionMismatch, got {err:?}"
1253        );
1254        let msg = err.to_string();
1255        assert!(msg.contains("999"), "error should mention version: {msg}");
1256    }
1257
1258    // -----------------------------------------------------------------------
1259    // parse_lines — forward compatibility (unknown event types)
1260    // -----------------------------------------------------------------------
1261
1262    #[test]
1263    fn parse_lines_skips_unknown_event_type() {
1264        // An event line with an unknown type should be warned and skipped,
1265        // not cause an error.
1266        let good_line = make_line(
1267            1_000,
1268            "agent",
1269            "itc:AQ",
1270            "",
1271            "item.comment",
1272            "bn-a7x",
1273            &sample_comment_json(),
1274        );
1275        // Construct a line with an unknown event type (simulating a future
1276        // event type).  We build it manually since make_line only handles
1277        // known types.
1278        let unknown_data = r#"{"body":"future"}"#;
1279        let canonical_unknown = serde_json::from_str::<serde_json::Value>(unknown_data)
1280            .map(|v| canonicalize_json(&v))
1281            .unwrap();
1282        let unknown_fields = [
1283            "2000",
1284            "agent",
1285            "itc:AQ.1",
1286            "",
1287            "item.future_type",
1288            "bn-a7x",
1289            canonical_unknown.as_str(),
1290        ];
1291        let unknown_line = format!(
1292            "2000\tagent\titc:AQ.1\t\titem.future_type\tbn-a7x\t{canonical_unknown}\t{}",
1293            compute_event_hash(&unknown_fields)
1294        );
1295
1296        let input = format!("# bones event log v1\n{good_line}\n{unknown_line}\n");
1297        // Should succeed, skipping the unknown event type
1298        let events = parse_lines(&input).expect("unknown event type should be skipped");
1299        assert_eq!(events.len(), 1, "only the known event should be returned");
1300        assert_eq!(events[0].wall_ts_us, 1_000);
1301    }
1302
1303    #[test]
1304    fn parse_lines_unknown_type_does_not_stop_parsing() {
1305        // Multiple unknown event types should all be skipped, and known
1306        // events following them should still be parsed.
1307        let known1 = make_line(
1308            1_000,
1309            "agent",
1310            "itc:AQ",
1311            "",
1312            "item.comment",
1313            "bn-a7x",
1314            &sample_comment_json(),
1315        );
1316        let known2 = make_line(
1317            3_000,
1318            "agent",
1319            "itc:AQ.2",
1320            "",
1321            "item.comment",
1322            "bn-a7x",
1323            &sample_comment_json(),
1324        );
1325
1326        // Build two unknown-type lines manually
1327        let unknown_data = r#"{"x":1}"#;
1328        let canonical_u =
1329            canonicalize_json(&serde_json::from_str::<serde_json::Value>(unknown_data).unwrap());
1330        let mk_unknown = |ts: i64, et: &str| -> String {
1331            let ts_s = ts.to_string();
1332            let fields = [
1333                ts_s.as_str(),
1334                "agent",
1335                "itc:X",
1336                "",
1337                et,
1338                "bn-a7x",
1339                canonical_u.as_str(),
1340            ];
1341            format!(
1342                "{ts}\tagent\titc:X\t\t{et}\tbn-a7x\t{canonical_u}\t{}",
1343                compute_event_hash(&fields)
1344            )
1345        };
1346        let unknown1 = mk_unknown(2_000, "item.new_future_type");
1347        let unknown2 = mk_unknown(2_500, "item.another_future_type");
1348
1349        let input = format!("# bones event log v1\n{known1}\n{unknown1}\n{unknown2}\n{known2}\n");
1350        let events = parse_lines(&input).expect("should succeed skipping unknowns");
1351        assert_eq!(events.len(), 2, "only known events returned");
1352        assert_eq!(events[0].wall_ts_us, 1_000);
1353        assert_eq!(events[1].wall_ts_us, 3_000);
1354    }
1355
1356    // -----------------------------------------------------------------------
1357    // Constants
1358    // -----------------------------------------------------------------------
1359
1360    #[test]
1361    fn shard_header_constant() {
1362        assert_eq!(SHARD_HEADER, "# bones event log v1");
1363    }
1364
1365    #[test]
1366    fn current_version_constant() {
1367        assert_eq!(CURRENT_VERSION, 1);
1368        // The SHARD_HEADER must embed the current version number.
1369        assert!(
1370            SHARD_HEADER.ends_with(&CURRENT_VERSION.to_string()),
1371            "SHARD_HEADER '{SHARD_HEADER}' must end with CURRENT_VERSION {CURRENT_VERSION}"
1372        );
1373    }
1374
1375    #[test]
1376    fn field_comment_constant() {
1377        assert!(FIELD_COMMENT.starts_with("# fields:"));
1378        assert!(FIELD_COMMENT.contains("wall_ts_us"));
1379        assert!(FIELD_COMMENT.contains("event_hash"));
1380    }
1381
1382    // -----------------------------------------------------------------------
1383    // is_valid_blake3_hash
1384    // -----------------------------------------------------------------------
1385
1386    #[test]
1387    fn valid_blake3_hashes() {
1388        let digest = blake3::hash(b"parser-hash");
1389        let new_style = encode_blake3_hash(&digest);
1390        let legacy_style = format!("blake3:{}", digest.to_hex());
1391        assert!(is_valid_blake3_hash(&new_style));
1392        assert!(is_valid_blake3_hash(&legacy_style));
1393    }
1394
1395    #[test]
1396    fn invalid_blake3_hashes() {
1397        assert!(!is_valid_blake3_hash("blake3:"));
1398        assert!(!is_valid_blake3_hash("sha256:abc")); // wrong prefix
1399        assert!(!is_valid_blake3_hash("abc123")); // no prefix
1400        assert!(!is_valid_blake3_hash("blake3:xyz!")); // invalid chars
1401        assert!(!is_valid_blake3_hash("")); // empty
1402    }
1403
1404    // -----------------------------------------------------------------------
1405    // compute_event_hash
1406    // -----------------------------------------------------------------------
1407
1408    #[test]
1409    fn compute_hash_deterministic() {
1410        let fields: [&str; 7] = ["1000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1411        let h1 = compute_event_hash(&fields);
1412        let h2 = compute_event_hash(&fields);
1413        assert_eq!(h1, h2);
1414        assert!(h1.starts_with("blake3:"));
1415    }
1416
1417    #[test]
1418    fn compute_hash_changes_with_different_fields() {
1419        let fields1: [&str; 7] = ["1000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1420        let fields2: [&str; 7] = ["2000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1421        assert_ne!(compute_event_hash(&fields1), compute_event_hash(&fields2));
1422    }
1423
1424    // -----------------------------------------------------------------------
1425    // Error Display
1426    // -----------------------------------------------------------------------
1427
1428    #[test]
1429    fn error_display_field_count() {
1430        let err = ParseError::FieldCount {
1431            found: 3,
1432            expected: 8,
1433        };
1434        let msg = err.to_string();
1435        assert!(msg.contains("8"));
1436        assert!(msg.contains("3"));
1437    }
1438
1439    #[test]
1440    fn error_display_hash_mismatch() {
1441        let err = ParseError::HashMismatch {
1442            expected: "blake3:aaa".into(),
1443            computed: "blake3:bbb".into(),
1444        };
1445        let msg = err.to_string();
1446        assert!(msg.contains("aaa"));
1447        assert!(msg.contains("bbb"));
1448    }
1449
1450    // -----------------------------------------------------------------------
1451    // All 11 event types parse successfully
1452    // -----------------------------------------------------------------------
1453
1454    #[test]
1455    fn parse_all_event_types() {
1456        let test_cases = vec![
1457            ("item.create", r#"{"title":"T","kind":"task"}"#),
1458            ("item.update", r#"{"field":"title","value":"New"}"#),
1459            ("item.move", r#"{"state":"doing"}"#),
1460            ("item.assign", r#"{"agent":"alice","action":"assign"}"#),
1461            ("item.comment", r#"{"body":"Hello"}"#),
1462            ("item.link", r#"{"target":"bn-b8y","link_type":"blocks"}"#),
1463            ("item.unlink", r#"{"target":"bn-b8y"}"#),
1464            ("item.delete", r#"{}"#),
1465            ("item.compact", r#"{"summary":"TL;DR"}"#),
1466            ("item.snapshot", r#"{"state":{"id":"bn-a7x"}}"#),
1467            (
1468                "item.redact",
1469                r#"{"target_hash":"blake3:abc","reason":"oops"}"#,
1470            ),
1471        ];
1472
1473        for (event_type, data_json) in test_cases {
1474            let line = make_line(1000, "agent", "itc:AQ", "", event_type, "bn-a7x", data_json);
1475            let result = parse_line(&line);
1476            assert!(
1477                result.is_ok(),
1478                "failed to parse {event_type}: {:?}",
1479                result.err()
1480            );
1481            match result.expect("just checked") {
1482                ParsedLine::Event(event) => {
1483                    assert_eq!(event.event_type.as_str(), event_type);
1484                }
1485                other => panic!("expected Event for {event_type}, got {other:?}"),
1486            }
1487        }
1488    }
1489
1490    // -----------------------------------------------------------------------
1491    // No panics on adversarial input
1492    // -----------------------------------------------------------------------
1493
1494    #[test]
1495    fn no_panic_on_garbage() {
1496        let long_string = "a".repeat(10_000);
1497        let inputs = vec![
1498            "",
1499            "\t",
1500            "\t\t\t\t\t\t\t",
1501            "\t\t\t\t\t\t\t\t",
1502            "🎉🎉🎉",
1503            "\0\0\0",
1504            &long_string,
1505            "1\t2\t3\t4\t5\t6\t7\t8",
1506            "-1\t\t\t\t\t\t\t",
1507        ];
1508
1509        for input in inputs {
1510            // Should not panic, errors are fine
1511            let _ = parse_line(input);
1512            let _ = parse_line_partial(input);
1513        }
1514    }
1515}