1use 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
34pub const SHARD_HEADER: &str = "# bones event log v1";
40
41pub 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
44pub const CURRENT_VERSION: u32 = 1;
46
47const HEADER_PREFIX: &str = "# bones event log v";
49
50#[derive(Debug, Clone, PartialEq, Eq)]
56pub enum ParseError {
57 FieldCount {
59 found: usize,
61 expected: usize,
63 },
64 InvalidTimestamp(String),
66 InvalidAgent(String),
68 EmptyItc,
70 InvalidParentHash(String),
72 InvalidEventType(String),
74 InvalidItemId(String),
76 InvalidDataJson(String),
78 DataSchemaMismatch {
80 event_type: String,
82 details: String,
84 },
85 InvalidEventHash(String),
87 HashMismatch {
89 expected: String,
91 computed: String,
93 },
94 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
147pub 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#[derive(Debug, Clone, PartialEq, Eq)]
214pub enum ParsedLine {
215 Comment(String),
217 Blank,
219 Event(Box<Event>),
221}
222
223#[derive(Debug, Clone, PartialEq, Eq)]
229pub struct PartialEvent<'a> {
230 pub wall_ts_us: i64,
232 pub agent: &'a str,
234 pub itc: &'a str,
236 pub parents_raw: &'a str,
238 pub event_type: EventType,
240 pub item_id_raw: &'a str,
242 pub data_raw: &'a str,
244 pub event_hash_raw: &'a str,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub enum PartialParsedLine<'a> {
251 Comment(&'a str),
253 Blank,
255 Event(PartialEvent<'a>),
257}
258
259fn 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
279fn split_fields(line: &str) -> impl Iterator<Item = &str> {
281 line.split('\t')
282}
283
284fn 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 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
309pub fn parse_line_partial(line: &str) -> Result<PartialParsedLine<'_>, ParseError> {
328 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
329
330 if trimmed.starts_with('#') {
332 return Ok(PartialParsedLine::Comment(trimmed));
333 }
334
335 if trimmed.trim().is_empty() {
337 return Ok(PartialParsedLine::Blank);
338 }
339
340 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 let wall_ts_us: i64 = fields[0]
351 .parse()
352 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
353
354 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
371pub fn parse_line(line: &str) -> Result<ParsedLine, ParseError> {
392 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
393
394 if trimmed.starts_with('#') {
396 return Ok(ParsedLine::Comment(trimmed.to_string()));
397 }
398
399 if trimmed.trim().is_empty() {
401 return Ok(ParsedLine::Blank);
402 }
403
404 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 let wall_ts_us: i64 = fields[0]
415 .parse()
416 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
417
418 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 let itc = fields[2];
426 if itc.is_empty() {
427 return Err(ParseError::EmptyItc);
428 }
429
430 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 let event_type: EventType = fields[4]
446 .parse()
447 .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
448
449 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 let data_json = fields[6];
461 let _: serde_json::Value =
463 serde_json::from_str(data_json).map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
464 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 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 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
506pub 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 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
576pub 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#[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 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 #[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 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 #[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 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 #[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 #[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 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 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 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 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 #[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 #[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); }
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 #[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 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 #[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 #[test]
1251 fn parse_lines_skips_unknown_event_type() {
1252 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 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 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 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 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 #[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 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 #[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")); assert!(!is_valid_blake3_hash("abc123")); assert!(!is_valid_blake3_hash("blake3:xyz!")); assert!(!is_valid_blake3_hash("")); }
1391
1392 #[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 #[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 #[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 #[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 let _ = parse_line(input);
1500 let _ = parse_line_partial(input);
1501 }
1502 }
1503}