1use 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
35pub const SHARD_HEADER: &str = "# bones event log v1";
41
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";
44
45pub const CURRENT_VERSION: u32 = 1;
47
48const HEADER_PREFIX: &str = "# bones event log v";
50
51#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ParseError {
58 FieldCount {
60 found: usize,
62 expected: usize,
64 },
65 InvalidTimestamp(String),
67 InvalidAgent(String),
69 EmptyItc,
71 InvalidParentHash(String),
73 InvalidEventType(String),
75 InvalidItemId(String),
77 InvalidDataJson(String),
79 DataSchemaMismatch {
81 event_type: String,
83 details: String,
85 },
86 InvalidEventHash(String),
88 HashMismatch {
90 expected: String,
92 computed: String,
94 },
95 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
148pub 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#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum ParsedLine {
216 Comment(String),
218 Blank,
220 Event(Box<Event>),
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct PartialEvent<'a> {
231 pub wall_ts_us: i64,
233 pub agent: &'a str,
235 pub itc: &'a str,
237 pub parents_raw: &'a str,
239 pub event_type: EventType,
241 pub item_id_raw: &'a str,
243 pub data_raw: &'a str,
245 pub event_hash_raw: &'a str,
247}
248
249#[derive(Debug, Clone, PartialEq, Eq)]
251pub enum PartialParsedLine<'a> {
252 Comment(&'a str),
254 Blank,
256 Event(PartialEvent<'a>),
258}
259
260fn 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
280fn split_fields(line: &str) -> impl Iterator<Item = &str> {
282 line.split('\t')
283}
284
285pub fn parse_line_partial(line: &str) -> Result<PartialParsedLine<'_>, ParseError> {
304 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
305
306 if trimmed.starts_with('#') {
308 return Ok(PartialParsedLine::Comment(trimmed));
309 }
310
311 if trimmed.trim().is_empty() {
313 return Ok(PartialParsedLine::Blank);
314 }
315
316 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 let wall_ts_us: i64 = fields[0]
327 .parse()
328 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
329
330 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
347pub fn parse_line(line: &str) -> Result<ParsedLine, ParseError> {
368 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
369
370 if trimmed.starts_with('#') {
372 return Ok(ParsedLine::Comment(trimmed.to_string()));
373 }
374
375 if trimmed.trim().is_empty() {
377 return Ok(ParsedLine::Blank);
378 }
379
380 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 let wall_ts_us: i64 = fields[0]
391 .parse()
392 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
393
394 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 let itc = fields[2];
402 if itc.is_empty() {
403 return Err(ParseError::EmptyItc);
404 }
405
406 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 let event_type: EventType = fields[4]
422 .parse()
423 .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
424
425 let item_id = ItemId::parse_any_prefix(fields[5])
428 .map_err(|_| ParseError::InvalidItemId(fields[5].to_string()))?;
429
430 let data_json = fields[6];
432 let _: serde_json::Value =
434 serde_json::from_str(data_json).map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
435 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 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 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
488pub 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 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
558pub 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#[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 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 #[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 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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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); }
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 #[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 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 #[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 #[test]
1233 fn parse_lines_skips_unknown_event_type() {
1234 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 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 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 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 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 #[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 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 #[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")); assert!(!is_valid_blake3_hash("abc123")); assert!(!is_valid_blake3_hash("blake3:xyz!")); assert!(!is_valid_blake3_hash("")); }
1373
1374 #[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 #[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 #[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 #[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 let _ = parse_line(input);
1482 let _ = parse_line_partial(input);
1483 }
1484 }
1485}