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
285fn parse_legacy_item_id(raw: &str) -> Option<ItemId> {
290 const fn is_crockford(c: char) -> bool {
291 matches!(c, '0'..='9' | 'a'..='h' | 'j' | 'k' | 'm' | 'n' | 'p'..='t' | 'v'..='z')
292 }
293 let normalized = raw.trim().to_lowercase();
294 let (prefix, rest) = normalized.split_once('-')?;
295 if prefix.is_empty() || prefix.len() > 4 || !prefix.chars().all(|c| c.is_ascii_lowercase()) {
296 return None;
297 }
298 if rest.is_empty() || rest.len() > 20 {
299 return None;
300 }
301 for seg in rest.split('.') {
303 if seg.is_empty() || !seg.chars().all(is_crockford) {
304 return None;
305 }
306 }
307 Some(ItemId::new_unchecked(normalized))
308}
309
310pub fn parse_line_partial(line: &str) -> Result<PartialParsedLine<'_>, ParseError> {
329 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
330
331 if trimmed.starts_with('#') {
333 return Ok(PartialParsedLine::Comment(trimmed));
334 }
335
336 if trimmed.trim().is_empty() {
338 return Ok(PartialParsedLine::Blank);
339 }
340
341 let fields: Vec<&str> = split_fields(trimmed).collect();
343 if fields.len() != 8 {
344 return Err(ParseError::FieldCount {
345 found: fields.len(),
346 expected: 8,
347 });
348 }
349
350 let wall_ts_us: i64 = fields[0]
352 .parse()
353 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
354
355 let event_type: EventType = fields[4]
357 .parse()
358 .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
359
360 Ok(PartialParsedLine::Event(PartialEvent {
361 wall_ts_us,
362 agent: fields[1],
363 itc: fields[2],
364 parents_raw: fields[3],
365 event_type,
366 item_id_raw: fields[5],
367 data_raw: fields[6],
368 event_hash_raw: fields[7],
369 }))
370}
371
372pub fn parse_line(line: &str) -> Result<ParsedLine, ParseError> {
393 let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
394
395 if trimmed.starts_with('#') {
397 return Ok(ParsedLine::Comment(trimmed.to_string()));
398 }
399
400 if trimmed.trim().is_empty() {
402 return Ok(ParsedLine::Blank);
403 }
404
405 let fields: Vec<&str> = split_fields(trimmed).collect();
407 if fields.len() != 8 {
408 return Err(ParseError::FieldCount {
409 found: fields.len(),
410 expected: 8,
411 });
412 }
413
414 let wall_ts_us: i64 = fields[0]
416 .parse()
417 .map_err(|_| ParseError::InvalidTimestamp(fields[0].to_string()))?;
418
419 let agent = fields[1];
421 if agent.is_empty() || agent.chars().any(|c| c == '\t' || c == '\n' || c == '\r') {
422 return Err(ParseError::InvalidAgent(agent.to_string()));
423 }
424
425 let itc = fields[2];
427 if itc.is_empty() {
428 return Err(ParseError::EmptyItc);
429 }
430
431 let parents_raw = fields[3];
433 let parents: Vec<String> = if parents_raw.is_empty() {
434 Vec::new()
435 } else {
436 let parts: Vec<&str> = parents_raw.split(',').collect();
437 for p in &parts {
438 if !is_valid_blake3_hash(p) {
439 return Err(ParseError::InvalidParentHash((*p).to_string()));
440 }
441 }
442 parts.iter().map(|s| (*s).to_string()).collect()
443 };
444
445 let event_type: EventType = fields[4]
447 .parse()
448 .map_err(|_| ParseError::InvalidEventType(fields[4].to_string()))?;
449
450 let item_id = match ItemId::parse_any_prefix(fields[5]) {
455 Ok(id) => id,
456 Err(_) => parse_legacy_item_id(fields[5])
457 .ok_or_else(|| ParseError::InvalidItemId(fields[5].to_string()))?,
458 };
459
460 let data_json = fields[6];
462 let _: serde_json::Value =
464 serde_json::from_str(data_json).map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
465 let data = EventData::deserialize_for(event_type, data_json).map_err(|e| {
467 ParseError::DataSchemaMismatch {
468 event_type: event_type.to_string(),
469 details: e.to_string(),
470 }
471 })?;
472
473 let event_hash = fields[7];
475 if !is_valid_blake3_hash(event_hash) {
476 return Err(ParseError::InvalidEventHash(event_hash.to_string()));
477 }
478
479 let canonical_data = serde_json::from_str::<serde_json::Value>(data_json)
483 .map(|v| canonicalize_json(&v))
484 .map_err(|e| ParseError::InvalidDataJson(e.to_string()))?;
485 let hash_fields: [&str; 7] = [
486 fields[0],
487 fields[1],
488 fields[2],
489 fields[3],
490 fields[4],
491 fields[5],
492 &canonical_data,
493 ];
494 let computed = compute_event_hash(&hash_fields);
495 let expected_bytes = decode_blake3_hash(event_hash)
496 .ok_or_else(|| ParseError::InvalidEventHash(event_hash.to_string()))?;
497 let computed_bytes = decode_blake3_hash(&computed)
498 .ok_or_else(|| ParseError::InvalidEventHash(computed.clone()))?;
499 if expected_bytes != computed_bytes {
500 return Err(ParseError::HashMismatch {
501 expected: event_hash.to_string(),
502 computed,
503 });
504 }
505
506 Ok(ParsedLine::Event(Box::new(Event {
507 wall_ts_us,
508 agent: agent.to_string(),
509 itc: itc.to_string(),
510 parents,
511 event_type,
512 item_id,
513 data,
514 event_hash: event_hash.to_string(),
515 })))
516}
517
518pub struct EventParser<I> {
523 lines: I,
524 version_checked: bool,
525 shard_version: u32,
526 line_no: usize,
527}
528
529impl<I> EventParser<I>
530where
531 I: Iterator<Item = String>,
532{
533 pub const fn new(lines: I) -> Self {
535 Self {
536 lines,
537 version_checked: false,
538 shard_version: CURRENT_VERSION,
539 line_no: 0,
540 }
541 }
542}
543
544impl<I> Iterator for EventParser<I>
545where
546 I: Iterator<Item = String>,
547{
548 type Item = Result<Event, (usize, ParseError)>;
549
550 fn next(&mut self) -> Option<Self::Item> {
551 loop {
552 let line = self.lines.next()?;
553 self.line_no += 1;
554
555 if !self.version_checked && line.trim_start().starts_with(HEADER_PREFIX) {
556 self.version_checked = true;
557 match detect_version(&line) {
558 Ok(v) => self.shard_version = v,
559 Err(msg) => return Some(Err((self.line_no, ParseError::VersionMismatch(msg)))),
560 }
561 continue;
562 }
563
564 match parse_line(&line) {
565 Ok(ParsedLine::Event(event)) => match migrate_event(*event, self.shard_version) {
566 Ok(migrated) => return Some(Ok(migrated)),
567 Err(e) => {
568 return Some(Err((
569 self.line_no,
570 ParseError::VersionMismatch(e.to_string()),
571 )));
572 }
573 },
574 Ok(ParsedLine::Comment(_) | ParsedLine::Blank) => {}
575 Err(ParseError::InvalidEventType(raw)) => {
576 warn!(
577 line = self.line_no,
578 event_type = %raw,
579 "skipping line with unknown event type (forward-compatibility)"
580 );
581 }
582 Err(e) => return Some(Err((self.line_no, e))),
583 }
584 }
585 }
586}
587
588pub fn parse_lines(input: &str) -> Result<Vec<Event>, (usize, ParseError)> {
606 let lines = input.lines().map(String::from);
607 let parser = EventParser::new(lines);
608 parser.collect()
609}
610
611#[cfg(test)]
616mod tests {
617 use super::*;
618 use crate::event::canonical::canonicalize_json;
619 use crate::event::data::{CreateData, MoveData};
620 use crate::model::item::{Kind, Size, State, Urgency};
621 use std::collections::BTreeMap;
622
623 fn make_line(
629 wall_ts_us: i64,
630 agent: &str,
631 itc: &str,
632 parents: &str,
633 event_type: &str,
634 item_id: &str,
635 data_json: &str,
636 ) -> String {
637 let canonical_data = canonicalize_json(
638 &serde_json::from_str::<serde_json::Value>(data_json).expect("test JSON"),
639 );
640 let hash_input = format!(
641 "{wall_ts_us}\t{agent}\t{itc}\t{parents}\t{event_type}\t{item_id}\t{canonical_data}\n"
642 );
643 let hash = blake3::hash(hash_input.as_bytes());
644 let event_hash = encode_blake3_hash(&hash);
645 format!(
646 "{wall_ts_us}\t{agent}\t{itc}\t{parents}\t{event_type}\t{item_id}\t{canonical_data}\t{event_hash}"
647 )
648 }
649
650 fn sample_create_json() -> String {
651 canonicalize_json(&serde_json::json!({
652 "title": "Fix auth retry",
653 "kind": "task",
654 "size": "m",
655 "labels": ["backend"]
656 }))
657 }
658
659 fn sample_move_json() -> String {
660 canonicalize_json(&serde_json::json!({
661 "state": "doing"
662 }))
663 }
664
665 fn sample_comment_json() -> String {
666 canonicalize_json(&serde_json::json!({
667 "body": "Root cause found"
668 }))
669 }
670
671 #[test]
676 fn parse_comment_line() {
677 let result = parse_line("# bones event log v1").expect("should parse");
678 assert_eq!(result, ParsedLine::Comment("# bones event log v1".into()));
679 }
680
681 #[test]
682 fn parse_comment_with_whitespace_prefix() {
683 let result = parse_line("# fields: wall_ts_us \\t agent").expect("should parse");
685 assert!(matches!(result, ParsedLine::Comment(_)));
686 }
687
688 #[test]
689 fn parse_blank_line() {
690 assert_eq!(parse_line("").expect("should parse"), ParsedLine::Blank);
691 assert_eq!(parse_line(" ").expect("should parse"), ParsedLine::Blank);
692 assert_eq!(parse_line("\t").expect("should parse"), ParsedLine::Blank);
693 }
694
695 #[test]
696 fn parse_newline_only() {
697 assert_eq!(parse_line("\n").expect("should parse"), ParsedLine::Blank);
698 assert_eq!(parse_line("\r\n").expect("should parse"), ParsedLine::Blank);
699 }
700
701 #[test]
706 fn partial_parse_comment() {
707 let result = parse_line_partial("# comment").expect("should parse");
708 assert_eq!(result, PartialParsedLine::Comment("# comment"));
709 }
710
711 #[test]
712 fn partial_parse_blank() {
713 let result = parse_line_partial("").expect("should parse");
714 assert_eq!(result, PartialParsedLine::Blank);
715 }
716
717 #[test]
718 fn partial_parse_valid_line() {
719 let line = make_line(
720 1_000_000,
721 "agent-1",
722 "itc:AQ",
723 "",
724 "item.create",
725 "bn-a7x",
726 &sample_create_json(),
727 );
728 let result = parse_line_partial(&line).expect("should parse");
729 match result {
730 PartialParsedLine::Event(pe) => {
731 assert_eq!(pe.wall_ts_us, 1_000_000);
732 assert_eq!(pe.agent, "agent-1");
733 assert_eq!(pe.itc, "itc:AQ");
734 assert_eq!(pe.parents_raw, "");
735 assert_eq!(pe.event_type, EventType::Create);
736 assert_eq!(pe.item_id_raw, "bn-a7x");
737 }
738 other => panic!("expected Event, got {other:?}"),
739 }
740 }
741
742 #[test]
743 fn partial_parse_does_not_validate_json() {
744 let line = "1000\tagent\titc:A\t\titem.create\tbn-a7x\tNOT_JSON\tblake3:aaa";
746 let result = parse_line_partial(line).expect("should parse");
747 assert!(matches!(result, PartialParsedLine::Event(_)));
748 }
749
750 #[test]
751 fn partial_parse_wrong_field_count() {
752 let err = parse_line_partial("a\tb\tc").expect_err("should fail");
753 assert!(matches!(
754 err,
755 ParseError::FieldCount {
756 found: 3,
757 expected: 8
758 }
759 ));
760 }
761
762 #[test]
763 fn partial_parse_bad_timestamp() {
764 let line = "not_a_number\tagent\titc\t\titem.create\tbn-a7x\t{}\tblake3:abc";
765 let err = parse_line_partial(line).expect_err("should fail");
766 assert!(matches!(err, ParseError::InvalidTimestamp(_)));
767 }
768
769 #[test]
770 fn partial_parse_bad_event_type() {
771 let line = "1000\tagent\titc\t\titem.unknown\tbn-a7x\t{}\tblake3:abc";
772 let err = parse_line_partial(line).expect_err("should fail");
773 assert!(matches!(err, ParseError::InvalidEventType(_)));
774 }
775
776 #[test]
781 fn parse_valid_create_event() {
782 let line = make_line(
783 1_708_012_200_123_456,
784 "claude-abc",
785 "itc:AQ",
786 "",
787 "item.create",
788 "bn-a7x",
789 &sample_create_json(),
790 );
791 let result = parse_line(&line).expect("should parse");
792 match result {
793 ParsedLine::Event(event) => {
794 assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
795 assert_eq!(event.agent, "claude-abc");
796 assert_eq!(event.itc, "itc:AQ");
797 assert!(event.parents.is_empty());
798 assert_eq!(event.event_type, EventType::Create);
799 assert_eq!(event.item_id.as_str(), "bn-a7x");
800 match &event.data {
801 EventData::Create(d) => {
802 assert_eq!(d.title, "Fix auth retry");
803 assert_eq!(d.kind, Kind::Task);
804 assert_eq!(d.size, Some(Size::M));
805 }
806 other => panic!("expected Create data, got {other:?}"),
807 }
808 }
809 other => panic!("expected Event, got {other:?}"),
810 }
811 }
812
813 #[test]
814 fn parse_valid_move_event_with_parent() {
815 let parent_hash = "blake3:a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6abcd";
816 let line = make_line(
817 1_708_012_201_000_000,
818 "claude-abc",
819 "itc:AQ.1",
820 parent_hash,
821 "item.move",
822 "bn-a7x",
823 &sample_move_json(),
824 );
825 let result = parse_line(&line).expect("should parse");
826 match result {
827 ParsedLine::Event(event) => {
828 assert_eq!(event.parents, vec![parent_hash]);
829 assert_eq!(event.event_type, EventType::Move);
830 match &event.data {
831 EventData::Move(d) => assert_eq!(d.state, State::Doing),
832 other => panic!("expected Move data, got {other:?}"),
833 }
834 }
835 other => panic!("expected Event, got {other:?}"),
836 }
837 }
838
839 #[test]
840 fn parse_valid_event_with_multiple_parents() {
841 let p1 = encode_blake3_hash(&blake3::hash(b"p1"));
842 let p2 = encode_blake3_hash(&blake3::hash(b"p2"));
843 let parents = format!("{p1},{p2}");
844 let line = make_line(
845 1_000,
846 "agent",
847 "itc:X",
848 &parents,
849 "item.comment",
850 "bn-a7x",
851 &sample_comment_json(),
852 );
853 let result = parse_line(&line).expect("should parse");
854 match result {
855 ParsedLine::Event(event) => {
856 assert_eq!(event.parents, vec![p1.as_str(), p2.as_str()]);
857 }
858 other => panic!("expected Event, got {other:?}"),
859 }
860 }
861
862 #[test]
863 fn parse_negative_timestamp() {
864 let line = make_line(
865 -1_000_000,
866 "agent",
867 "itc:AQ",
868 "",
869 "item.comment",
870 "bn-a7x",
871 &sample_comment_json(),
872 );
873 let result = parse_line(&line).expect("should parse");
874 match result {
875 ParsedLine::Event(event) => assert_eq!(event.wall_ts_us, -1_000_000),
876 other => panic!("expected Event, got {other:?}"),
877 }
878 }
879
880 #[test]
881 fn parse_line_with_trailing_newline() {
882 let line = make_line(
883 1_000,
884 "agent",
885 "itc:AQ",
886 "",
887 "item.comment",
888 "bn-a7x",
889 &sample_comment_json(),
890 );
891 let with_newline = format!("{line}\n");
892 let result = parse_line(&with_newline).expect("should parse");
893 assert!(matches!(result, ParsedLine::Event(_)));
894 }
895
896 #[test]
897 fn parse_line_with_crlf() {
898 let line = make_line(
899 1_000,
900 "agent",
901 "itc:AQ",
902 "",
903 "item.comment",
904 "bn-a7x",
905 &sample_comment_json(),
906 );
907 let with_crlf = format!("{line}\r\n");
908 let result = parse_line(&with_crlf).expect("should parse");
909 assert!(matches!(result, ParsedLine::Event(_)));
910 }
911
912 #[test]
917 fn parse_wrong_field_count_too_few() {
918 let err = parse_line("only\ttwo\tfields").expect_err("should fail");
919 assert!(matches!(
920 err,
921 ParseError::FieldCount {
922 found: 3,
923 expected: 8
924 }
925 ));
926 }
927
928 #[test]
929 fn parse_wrong_field_count_too_many() {
930 let err = parse_line("1\t2\t3\t4\t5\t6\t7\t8\t9").expect_err("should fail");
931 assert!(matches!(
932 err,
933 ParseError::FieldCount {
934 found: 9,
935 expected: 8
936 }
937 ));
938 }
939
940 #[test]
941 fn parse_invalid_timestamp_not_number() {
942 let line = "abc\tagent\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
943 let err = parse_line(line).expect_err("should fail");
944 assert!(matches!(err, ParseError::InvalidTimestamp(_)));
945 }
946
947 #[test]
948 fn parse_invalid_timestamp_float() {
949 let line = "1.5\tagent\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
950 let err = parse_line(line).expect_err("should fail");
951 assert!(matches!(err, ParseError::InvalidTimestamp(_)));
952 }
953
954 #[test]
955 fn parse_empty_agent() {
956 let line = "1000\t\titc:A\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
957 let err = parse_line(line).expect_err("should fail");
958 assert!(matches!(err, ParseError::InvalidAgent(_)));
959 }
960
961 #[test]
962 fn parse_empty_itc() {
963 let line = "1000\tagent\t\t\titem.create\tbn-a7x\t{}\tblake3:aaa";
964 let err = parse_line(line).expect_err("should fail");
965 assert!(matches!(err, ParseError::EmptyItc));
966 }
967
968 #[test]
969 fn parse_invalid_parent_hash_no_prefix() {
970 let line = "1000\tagent\titc:A\tabc123\titem.create\tbn-a7x\t{}\tblake3:aaa";
971 let err = parse_line(line).expect_err("should fail");
972 assert!(matches!(err, ParseError::InvalidParentHash(_)));
973 }
974
975 #[test]
976 fn parse_invalid_parent_hash_non_hex() {
977 let line = "1000\tagent\titc:A\tblake3:xyz!\titem.create\tbn-a7x\t{}\tblake3:aaa";
978 let err = parse_line(line).expect_err("should fail");
979 assert!(matches!(err, ParseError::InvalidParentHash(_)));
980 }
981
982 #[test]
983 fn parse_invalid_event_type() {
984 let line = "1000\tagent\titc:A\t\titem.unknown\tbn-a7x\t{}\tblake3:aaa";
985 let err = parse_line(line).expect_err("should fail");
986 assert!(matches!(err, ParseError::InvalidEventType(_)));
987 }
988
989 #[test]
990 fn parse_invalid_item_id() {
991 let line = "1000\tagent\titc:A\t\titem.create\tnoid\t{}\tblake3:aaa";
993 let err = parse_line(line).expect_err("should fail");
994 assert!(matches!(err, ParseError::InvalidItemId(_)));
995 }
996
997 #[test]
998 fn parse_invalid_json() {
999 let line = "1000\tagent\titc:A\t\titem.create\tbn-a7x\t{not json}\tblake3:aaa";
1000 let err = parse_line(line).expect_err("should fail");
1001 assert!(matches!(err, ParseError::InvalidDataJson(_)));
1002 }
1003
1004 #[test]
1005 fn parse_json_schema_mismatch() {
1006 let line = make_line(
1008 1000,
1009 "agent",
1010 "itc:A",
1011 "",
1012 "item.create",
1013 "bn-a7x",
1014 r#"{"kind":"task"}"#,
1015 );
1016 let err = parse_line(&line).expect_err("should fail");
1017 assert!(matches!(err, ParseError::DataSchemaMismatch { .. }));
1018 }
1019
1020 #[test]
1021 fn parse_invalid_event_hash_format() {
1022 let line = "1000\tagent\titc:A\t\titem.comment\tbn-a7x\t{\"body\":\"hi\"}\tsha256:abc";
1024 let err = parse_line(line).expect_err("should fail");
1025 assert!(matches!(err, ParseError::InvalidEventHash(_)));
1026 }
1027
1028 #[test]
1029 fn parse_hash_mismatch() {
1030 let line = format!(
1032 "1000\tagent\titc:A\t\titem.comment\tbn-a7x\t{}\tblake3:{}",
1033 &sample_comment_json(),
1034 "0".repeat(64)
1035 );
1036 let err = parse_line(&line).expect_err("should fail");
1037 assert!(matches!(err, ParseError::HashMismatch { .. }));
1038 }
1039
1040 #[test]
1045 fn roundtrip_create_event() {
1046 let data = CreateData {
1047 title: "Fix auth retry".into(),
1048 kind: Kind::Task,
1049 size: Some(Size::M),
1050 urgency: Urgency::Default,
1051 labels: vec!["backend".into()],
1052 parent: None,
1053 causation: None,
1054 description: None,
1055 extra: BTreeMap::new(),
1056 };
1057
1058 let data_json = canonicalize_json(&serde_json::to_value(&data).expect("serialize"));
1059
1060 let line = make_line(
1061 1_708_012_200_123_456,
1062 "claude-abc",
1063 "itc:AQ",
1064 "",
1065 "item.create",
1066 "bn-a7x",
1067 &data_json,
1068 );
1069
1070 let parsed = parse_line(&line).expect("should parse");
1071 match parsed {
1072 ParsedLine::Event(event) => {
1073 assert_eq!(event.wall_ts_us, 1_708_012_200_123_456);
1074 assert_eq!(event.agent, "claude-abc");
1075 assert_eq!(event.event_type, EventType::Create);
1076 assert_eq!(event.data, EventData::Create(data));
1077 }
1078 other => panic!("expected Event, got {other:?}"),
1079 }
1080 }
1081
1082 #[test]
1083 fn roundtrip_move_event() {
1084 let data = MoveData {
1085 state: State::Doing,
1086 reason: None,
1087 extra: BTreeMap::new(),
1088 };
1089
1090 let data_json = canonicalize_json(&serde_json::to_value(&data).expect("serialize"));
1091
1092 let parent_hash = encode_blake3_hash(&blake3::hash(b"parent"));
1093 let line = make_line(
1094 1_708_012_201_000_000,
1095 "agent-x",
1096 "itc:AQ.1",
1097 &parent_hash,
1098 "item.move",
1099 "bn-a7x",
1100 &data_json,
1101 );
1102
1103 let parsed = parse_line(&line).expect("should parse");
1104 match parsed {
1105 ParsedLine::Event(event) => {
1106 assert_eq!(event.parents, vec![parent_hash.as_str()]);
1107 assert_eq!(event.event_type, EventType::Move);
1108 assert_eq!(event.data, EventData::Move(data));
1109 }
1110 other => panic!("expected Event, got {other:?}"),
1111 }
1112 }
1113
1114 #[test]
1119 fn parse_lines_mixed_content() {
1120 let line1 = make_line(
1121 1_000,
1122 "agent",
1123 "itc:AQ",
1124 "",
1125 "item.comment",
1126 "bn-a7x",
1127 &sample_comment_json(),
1128 );
1129 let line2 = make_line(
1130 2_000,
1131 "agent",
1132 "itc:AQ.1",
1133 "",
1134 "item.comment",
1135 "bn-a7x",
1136 &sample_comment_json(),
1137 );
1138
1139 let input = format!("# bones event log v1\n# fields: ...\n\n{line1}\n{line2}\n");
1140
1141 let events = parse_lines(&input).expect("should parse");
1142 assert_eq!(events.len(), 2);
1143 assert_eq!(events[0].wall_ts_us, 1_000);
1144 assert_eq!(events[1].wall_ts_us, 2_000);
1145 }
1146
1147 #[test]
1148 fn parse_lines_error_reports_line_number() {
1149 let good = make_line(
1150 1_000,
1151 "agent",
1152 "itc:AQ",
1153 "",
1154 "item.comment",
1155 "bn-a7x",
1156 &sample_comment_json(),
1157 );
1158 let input = format!("# header\n{good}\nbad_line\n");
1159 let err = parse_lines(&input).expect_err("should fail");
1160 assert_eq!(err.0, 3); }
1162
1163 #[test]
1164 fn parse_lines_empty_input() {
1165 let events = parse_lines("").expect("should parse");
1166 assert!(events.is_empty());
1167 }
1168
1169 #[test]
1174 fn detect_version_valid_v1() {
1175 let version = detect_version("# bones event log v1").expect("should parse");
1176 assert_eq!(version, 1);
1177 }
1178
1179 #[test]
1180 fn detect_version_with_leading_whitespace() {
1181 let version = detect_version(" # bones event log v1 ").expect("should parse");
1183 assert_eq!(version, 1);
1184 }
1185
1186 #[test]
1187 fn detect_version_future_version_errors() {
1188 let err = detect_version("# bones event log v99").expect_err("should fail");
1189 assert!(err.contains("99"), "should mention version in error: {err}");
1190 assert!(
1191 err.to_lowercase().contains("upgrade")
1192 || err.to_lowercase().contains("install")
1193 || err.to_lowercase().contains("newer"),
1194 "should give upgrade advice: {err}"
1195 );
1196 }
1197
1198 #[test]
1199 fn detect_version_invalid_header() {
1200 let err = detect_version("not a valid header").expect_err("should fail");
1201 assert!(err.contains("Invalid") || err.contains("invalid"), "{err}");
1202 }
1203
1204 #[test]
1205 fn detect_version_non_numeric_version() {
1206 let err = detect_version("# bones event log vX").expect_err("should fail");
1207 assert!(!err.is_empty());
1208 }
1209
1210 #[test]
1211 fn detect_version_empty_version() {
1212 let err = detect_version("# bones event log v").expect_err("should fail");
1213 assert!(!err.is_empty());
1214 }
1215
1216 #[test]
1221 fn parse_lines_version_header_v1_accepted() {
1222 let line = make_line(
1223 1_000,
1224 "agent",
1225 "itc:AQ",
1226 "",
1227 "item.comment",
1228 "bn-a7x",
1229 &sample_comment_json(),
1230 );
1231 let input = format!("# bones event log v1\n{line}\n");
1232 let events = parse_lines(&input).expect("v1 should be accepted");
1233 assert_eq!(events.len(), 1);
1234 }
1235
1236 #[test]
1237 fn parse_lines_future_version_rejected() {
1238 let line = make_line(
1239 1_000,
1240 "agent",
1241 "itc:AQ",
1242 "",
1243 "item.comment",
1244 "bn-a7x",
1245 &sample_comment_json(),
1246 );
1247 let input = format!("# bones event log v999\n{line}\n");
1248 let (line_no, err) = parse_lines(&input).expect_err("future version should fail");
1249 assert_eq!(line_no, 1);
1250 assert!(
1251 matches!(err, ParseError::VersionMismatch(_)),
1252 "expected VersionMismatch, got {err:?}"
1253 );
1254 let msg = err.to_string();
1255 assert!(msg.contains("999"), "error should mention version: {msg}");
1256 }
1257
1258 #[test]
1263 fn parse_lines_skips_unknown_event_type() {
1264 let good_line = make_line(
1267 1_000,
1268 "agent",
1269 "itc:AQ",
1270 "",
1271 "item.comment",
1272 "bn-a7x",
1273 &sample_comment_json(),
1274 );
1275 let unknown_data = r#"{"body":"future"}"#;
1279 let canonical_unknown = serde_json::from_str::<serde_json::Value>(unknown_data)
1280 .map(|v| canonicalize_json(&v))
1281 .unwrap();
1282 let unknown_fields = [
1283 "2000",
1284 "agent",
1285 "itc:AQ.1",
1286 "",
1287 "item.future_type",
1288 "bn-a7x",
1289 canonical_unknown.as_str(),
1290 ];
1291 let unknown_line = format!(
1292 "2000\tagent\titc:AQ.1\t\titem.future_type\tbn-a7x\t{canonical_unknown}\t{}",
1293 compute_event_hash(&unknown_fields)
1294 );
1295
1296 let input = format!("# bones event log v1\n{good_line}\n{unknown_line}\n");
1297 let events = parse_lines(&input).expect("unknown event type should be skipped");
1299 assert_eq!(events.len(), 1, "only the known event should be returned");
1300 assert_eq!(events[0].wall_ts_us, 1_000);
1301 }
1302
1303 #[test]
1304 fn parse_lines_unknown_type_does_not_stop_parsing() {
1305 let known1 = make_line(
1308 1_000,
1309 "agent",
1310 "itc:AQ",
1311 "",
1312 "item.comment",
1313 "bn-a7x",
1314 &sample_comment_json(),
1315 );
1316 let known2 = make_line(
1317 3_000,
1318 "agent",
1319 "itc:AQ.2",
1320 "",
1321 "item.comment",
1322 "bn-a7x",
1323 &sample_comment_json(),
1324 );
1325
1326 let unknown_data = r#"{"x":1}"#;
1328 let canonical_u =
1329 canonicalize_json(&serde_json::from_str::<serde_json::Value>(unknown_data).unwrap());
1330 let mk_unknown = |ts: i64, et: &str| -> String {
1331 let ts_s = ts.to_string();
1332 let fields = [
1333 ts_s.as_str(),
1334 "agent",
1335 "itc:X",
1336 "",
1337 et,
1338 "bn-a7x",
1339 canonical_u.as_str(),
1340 ];
1341 format!(
1342 "{ts}\tagent\titc:X\t\t{et}\tbn-a7x\t{canonical_u}\t{}",
1343 compute_event_hash(&fields)
1344 )
1345 };
1346 let unknown1 = mk_unknown(2_000, "item.new_future_type");
1347 let unknown2 = mk_unknown(2_500, "item.another_future_type");
1348
1349 let input = format!("# bones event log v1\n{known1}\n{unknown1}\n{unknown2}\n{known2}\n");
1350 let events = parse_lines(&input).expect("should succeed skipping unknowns");
1351 assert_eq!(events.len(), 2, "only known events returned");
1352 assert_eq!(events[0].wall_ts_us, 1_000);
1353 assert_eq!(events[1].wall_ts_us, 3_000);
1354 }
1355
1356 #[test]
1361 fn shard_header_constant() {
1362 assert_eq!(SHARD_HEADER, "# bones event log v1");
1363 }
1364
1365 #[test]
1366 fn current_version_constant() {
1367 assert_eq!(CURRENT_VERSION, 1);
1368 assert!(
1370 SHARD_HEADER.ends_with(&CURRENT_VERSION.to_string()),
1371 "SHARD_HEADER '{SHARD_HEADER}' must end with CURRENT_VERSION {CURRENT_VERSION}"
1372 );
1373 }
1374
1375 #[test]
1376 fn field_comment_constant() {
1377 assert!(FIELD_COMMENT.starts_with("# fields:"));
1378 assert!(FIELD_COMMENT.contains("wall_ts_us"));
1379 assert!(FIELD_COMMENT.contains("event_hash"));
1380 }
1381
1382 #[test]
1387 fn valid_blake3_hashes() {
1388 let digest = blake3::hash(b"parser-hash");
1389 let new_style = encode_blake3_hash(&digest);
1390 let legacy_style = format!("blake3:{}", digest.to_hex());
1391 assert!(is_valid_blake3_hash(&new_style));
1392 assert!(is_valid_blake3_hash(&legacy_style));
1393 }
1394
1395 #[test]
1396 fn invalid_blake3_hashes() {
1397 assert!(!is_valid_blake3_hash("blake3:"));
1398 assert!(!is_valid_blake3_hash("sha256:abc")); assert!(!is_valid_blake3_hash("abc123")); assert!(!is_valid_blake3_hash("blake3:xyz!")); assert!(!is_valid_blake3_hash("")); }
1403
1404 #[test]
1409 fn compute_hash_deterministic() {
1410 let fields: [&str; 7] = ["1000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1411 let h1 = compute_event_hash(&fields);
1412 let h2 = compute_event_hash(&fields);
1413 assert_eq!(h1, h2);
1414 assert!(h1.starts_with("blake3:"));
1415 }
1416
1417 #[test]
1418 fn compute_hash_changes_with_different_fields() {
1419 let fields1: [&str; 7] = ["1000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1420 let fields2: [&str; 7] = ["2000", "agent", "itc:A", "", "item.create", "bn-a7x", "{}"];
1421 assert_ne!(compute_event_hash(&fields1), compute_event_hash(&fields2));
1422 }
1423
1424 #[test]
1429 fn error_display_field_count() {
1430 let err = ParseError::FieldCount {
1431 found: 3,
1432 expected: 8,
1433 };
1434 let msg = err.to_string();
1435 assert!(msg.contains("8"));
1436 assert!(msg.contains("3"));
1437 }
1438
1439 #[test]
1440 fn error_display_hash_mismatch() {
1441 let err = ParseError::HashMismatch {
1442 expected: "blake3:aaa".into(),
1443 computed: "blake3:bbb".into(),
1444 };
1445 let msg = err.to_string();
1446 assert!(msg.contains("aaa"));
1447 assert!(msg.contains("bbb"));
1448 }
1449
1450 #[test]
1455 fn parse_all_event_types() {
1456 let test_cases = vec![
1457 ("item.create", r#"{"title":"T","kind":"task"}"#),
1458 ("item.update", r#"{"field":"title","value":"New"}"#),
1459 ("item.move", r#"{"state":"doing"}"#),
1460 ("item.assign", r#"{"agent":"alice","action":"assign"}"#),
1461 ("item.comment", r#"{"body":"Hello"}"#),
1462 ("item.link", r#"{"target":"bn-b8y","link_type":"blocks"}"#),
1463 ("item.unlink", r#"{"target":"bn-b8y"}"#),
1464 ("item.delete", r#"{}"#),
1465 ("item.compact", r#"{"summary":"TL;DR"}"#),
1466 ("item.snapshot", r#"{"state":{"id":"bn-a7x"}}"#),
1467 (
1468 "item.redact",
1469 r#"{"target_hash":"blake3:abc","reason":"oops"}"#,
1470 ),
1471 ];
1472
1473 for (event_type, data_json) in test_cases {
1474 let line = make_line(1000, "agent", "itc:AQ", "", event_type, "bn-a7x", data_json);
1475 let result = parse_line(&line);
1476 assert!(
1477 result.is_ok(),
1478 "failed to parse {event_type}: {:?}",
1479 result.err()
1480 );
1481 match result.expect("just checked") {
1482 ParsedLine::Event(event) => {
1483 assert_eq!(event.event_type.as_str(), event_type);
1484 }
1485 other => panic!("expected Event for {event_type}, got {other:?}"),
1486 }
1487 }
1488 }
1489
1490 #[test]
1495 fn no_panic_on_garbage() {
1496 let long_string = "a".repeat(10_000);
1497 let inputs = vec![
1498 "",
1499 "\t",
1500 "\t\t\t\t\t\t\t",
1501 "\t\t\t\t\t\t\t\t",
1502 "🎉🎉🎉",
1503 "\0\0\0",
1504 &long_string,
1505 "1\t2\t3\t4\t5\t6\t7\t8",
1506 "-1\t\t\t\t\t\t\t",
1507 ];
1508
1509 for input in inputs {
1510 let _ = parse_line(input);
1512 let _ = parse_line_partial(input);
1513 }
1514 }
1515}