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