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