Files
boxmaker/crates/memoryd/tests/honcho_ledger_file.rs

315 lines
11 KiB
Rust

//! 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::<String>()
);
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<Item> = 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:?}"
);
}