//! The Honcho ledger's file (M5c spec, section 5): read through at start, a line cut off by a //! crash dropped and cut from the file, a damaged line refused with its number and the runbook //! pointer, the file's mode, and a failed write that changes nothing. From M5c-1 task 15 every //! ledger that is read back starts with when sync began. Do not edit. #[path = "support/ledger.rs"] mod ledger; #[path = "support/tmp.rs"] mod tmp; use std::os::unix::fs::PermissionsExt; use ledger::{ Home, began_line, conclude, decided, edit, failed, line, sent, skip, to_trusted, turn, unconclude, }; use memoryd::honcho::ledger::{Counts, Item, Ledger, LedgerError, Workspace}; const POINTER: &str = "; see docs/runbook.md#honcho-ledger-damaged"; /// `open`'s error for the file of `home`, which must be `Damaged`: its line and its display. fn damaged(home: &Home) -> (usize, String) { let err = Ledger::open(&home.path).unwrap_err(); let shown = err.to_string(); match err { LedgerError::Damaged { path, line, .. } => { assert_eq!(path, home.path); (line, shown) } other => panic!("not Damaged: {other:?}"), } } #[test] fn a_missing_file_is_an_empty_ledger_and_nothing_is_created() { let home = Home::new("ledger-missing"); let ledger = home.open(); assert_eq!(ledger.counts(), Counts::default()); assert!(ledger.outbox().is_empty()); assert!(!ledger.repaired()); assert!(!home.path.exists()); let nowhere = home.dir.path().join("memory").join("honcho.jsonl"); let ledger = Ledger::open(&nowhere).unwrap(); assert_eq!(ledger.counts(), Counts::default()); assert!(!nowhere.parent().unwrap().exists()); } #[test] fn an_empty_file_is_an_empty_ledger() { let home = Home::new("ledger-empty"); home.write(""); let ledger = home.open(); assert_eq!(ledger.counts(), Counts::default()); assert!(!ledger.repaired()); assert_eq!(home.text(), ""); } #[test] fn reopening_reads_the_ledger_through() { let home = Home::new("ledger-reopen"); let mut ledger = home.begun(); let (a, b, c, d, e) = ( turn("chat-1", 1), turn("chat-1", 2), edit("2026-09-28-0001", 0), edit("2026-09-28-0002", 0), turn("chat-2", 1), ); ledger.append(decided(&b, to_trusted("kyle"))).unwrap(); ledger.append(decided(&a, skip("no speaker"))).unwrap(); ledger .append(decided(&c, conclude(Workspace::Trusted, "x"))) .unwrap(); ledger .append(decided(&d, unconclude(Workspace::Trusted, "x"))) .unwrap(); ledger.append(decided(&e, to_trusted("ana_b"))).unwrap(); ledger.append(sent(&c, &["c-1"])).unwrap(); ledger.append(failed(&d, Some(404))).unwrap(); let again = home.open(); assert!(!again.repaired()); assert_eq!(again.outbox(), ledger.outbox()); assert_eq!(again.counts(), ledger.counts()); assert_eq!(again.sent_ids(&c), Some(&["c-1".to_string()][..])); assert_eq!( again.conclusion(Workspace::Trusted, "kyle", "x"), Some((c.clone(), "c-1".to_string())) ); for item in [&a, &b, &c, &d, &e] { assert!(again.is_decided(item), "{item:?}"); } let mut again = again; let err = again.append(decided(&b, skip("again"))).unwrap_err(); assert!(matches!(err, LedgerError::AlreadyDecided(_)), "{err:?}"); let err = again.append(sent(&c, &["c-2"])).unwrap_err(); assert!(matches!(err, LedgerError::NotWaiting(_)), "{err:?}"); again.append(sent(&b, &["m-1", "m-2"])).unwrap(); let third = home.open(); assert_eq!(third.outbox(), vec![(e, to_trusted("ana_b"))]); assert_eq!( third.counts(), Counts { decided: 5, sent: 2, waiting: 1, failed: 1, skipped: 1, } ); } #[test] fn a_cut_off_last_line_is_dropped_and_cut_from_the_file() { let (a, b, c) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3)); let whole = format!( "{}{}{}", began_line(), line(&decided(&a, to_trusted("kyle"))), line(&decided(&b, to_trusted("kyle"))) ); let third = line(&sent(&a, &["m-1"])); // Cut in the middle, and one byte short of the newline: a complete record without its // newline is still a cut-off line. for cut in [third.len() / 2, third.len() - 1] { let home = Home::new("ledger-torn"); home.write(&format!("{whole}{}", &third[..cut])); let mut ledger = home.open(); assert!(ledger.repaired()); assert_eq!(home.text(), whole); assert_eq!(ledger.outbox().len(), 2, "the cut-off send counted"); assert_eq!(ledger.sent_ids(&a), None); ledger.append(decided(&c, to_trusted("kyle"))).unwrap(); ledger.append(sent(&a, &["m-2"])).unwrap(); let again = home.open(); assert!(!again.repaired()); assert_eq!(again.counts().decided, 3); assert_eq!(again.sent_ids(&a), Some(&["m-2".to_string()][..])); } } #[test] fn a_cut_off_first_line_leaves_an_empty_file() { let home = Home::new("ledger-torn-first"); let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle"))); home.write(&first[..10]); let ledger = home.open(); assert!(ledger.repaired()); assert_eq!(ledger.counts(), Counts::default()); assert_eq!(std::fs::metadata(&home.path).unwrap().len(), 0); } #[test] fn a_line_cut_inside_a_character_is_dropped_too() { let home = Home::new("ledger-torn-utf8"); let a = edit("2026-09-28-0001", 0); let first = format!( "{}{}", began_line(), line(&decided(&turn("chat-1", 1), to_trusted("kyle"))) ); let second = line(&decided(&a, conclude(Workspace::Trusted, "Kyle's café"))); let at = second.find('é').unwrap() + 1; let mut bytes = first.clone().into_bytes(); bytes.extend_from_slice(&second.as_bytes()[..at]); std::fs::write(&home.path, &bytes).unwrap(); let ledger = home.open(); assert!(ledger.repaired()); assert_eq!(home.text(), first); assert!(!ledger.is_decided(&a)); } #[test] fn a_damaged_line_stops_the_ledger_with_its_number_and_the_pointer() { let first = line(&decided(&turn("chat-1", 1), to_trusted("kyle"))); let third = line(&decided(&turn("chat-1", 2), to_trusted("kyle"))); let extra = first.replacen("\"speaker\"", "\"extra\":1,\"speaker\"", 1); for (second, why) in [ ("{\"type\":\"decided\"\n".to_string(), "EOF while parsing"), ("\n".to_string(), "EOF while parsing"), ("not json\n".to_string(), "expected"), (extra, "unknown field `extra`"), ] { for tail in ["", "{\"type\":\"se"] { let home = Home::new("ledger-damaged"); let text = format!("{}{first}{second}{third}{tail}", began_line()); home.write(&text); let (number, shown) = damaged(&home); assert_eq!(number, 3, "{second}"); let start = format!("{}:3: ", home.path.display()); assert!(shown.starts_with(&start), "{shown}"); assert!(shown.contains(why), "{shown}"); assert!(shown.ends_with(POINTER), "{shown}"); assert_eq!(home.text(), text, "a damaged ledger was changed"); } } } #[test] fn a_line_that_breaks_the_rules_is_damage() { let (a, b) = (turn("chat-1", 1), edit("2026-09-28-0001", 2)); let cases = [ ( vec![sent(&a, &["m"])], 2, "turn 1 of session chat-1: not waiting to be sent", ), ( vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))], 3, "turn 1 of session chat-1: decided already", ), ( vec![decided(&b, skip("flagged")), failed(&b, Some(400))], 3, "part 2 of edit 2026-09-28-0001: not waiting to be sent", ), ( vec![ decided(&a, to_trusted("kyle")), sent(&a, &["m"]), decided(&b, to_trusted("kyle")), failed(&a, None), ], 5, "turn 1 of session chat-1: not waiting to be sent", ), ]; for (records, number, why) in cases { let home = Home::new("ledger-rules"); let text = format!( "{}{}", began_line(), records.iter().map(line).collect::() ); home.write(&text); let (got, shown) = damaged(&home); assert_eq!(got, number, "{text}"); let expected = format!("{}:{number}: {why}{POINTER}", home.path.display()); assert_eq!(shown, expected); assert_eq!(home.text(), text); } } #[test] fn the_file_is_made_0600() { let home = Home::new("ledger-mode"); let mut ledger = home.open(); ledger .append(decided(&turn("chat-1", 1), to_trusted("kyle"))) .unwrap(); let mode = std::fs::metadata(&home.path).unwrap().permissions().mode(); assert_eq!(mode & 0o777, 0o600, "{mode:o}"); } #[test] fn a_missing_directory_is_an_io_error_and_changes_nothing() { let home = Home::new("ledger-nodir"); let path = home.dir.path().join("memory").join("honcho.jsonl"); let mut ledger = Ledger::open(&path).unwrap(); let item = turn("chat-1", 1); let err = ledger .append(decided(&item, to_trusted("kyle"))) .unwrap_err(); assert!( matches!(&err, LedgerError::Io(p, _) if *p == path), "{err:?}" ); assert!( err.to_string() .starts_with(&format!("{}: ", path.display())) ); assert!(!ledger.is_decided(&item)); assert_eq!(ledger.counts(), Counts::default()); assert!(!path.parent().unwrap().exists(), "the directory was made"); } #[test] fn a_failed_write_changes_nothing_in_memory() { let home = Home::new("ledger-failed-write"); let mut ledger = home.begun(); let (a, b) = (turn("chat-1", 1), turn("chat-1", 2)); ledger.append(decided(&a, to_trusted("kyle"))).unwrap(); let kept = home.text(); // The ledger's path turns into a directory: every write to it fails. std::fs::remove_file(&home.path).unwrap(); std::fs::create_dir(&home.path).unwrap(); let records = [decided(&b, to_trusted("kyle")), sent(&a, &["m-1"])]; for record in records.clone() { let err = ledger.append(record).unwrap_err(); assert!(matches!(err, LedgerError::Io(..)), "{err:?}"); } assert!(!ledger.is_decided(&b)); assert_eq!(ledger.sent_ids(&a), None); assert_eq!(ledger.outbox(), vec![(a.clone(), to_trusted("kyle"))]); std::fs::remove_dir(&home.path).unwrap(); home.write(&kept); for record in records { ledger.append(record).unwrap(); } let outbox: Vec = ledger.outbox().into_iter().map(|(i, _)| i).collect(); assert_eq!(outbox, vec![b]); assert_eq!(home.open().counts().sent, 1); } #[test] fn a_ledger_that_cannot_be_read_is_an_io_error() { let home = Home::new("ledger-unreadable"); std::fs::create_dir(&home.path).unwrap(); let err = Ledger::open(&home.path).unwrap_err(); assert!( matches!(&err, LedgerError::Io(p, _) if *p == home.path), "{err:?}" ); }