304 lines
11 KiB
Rust
304 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. Do not edit.
|
|
|
|
#[path = "support/ledger.rs"]
|
|
mod ledger;
|
|
#[path = "support/tmp.rs"]
|
|
mod tmp;
|
|
|
|
use std::os::unix::fs::PermissionsExt;
|
|
|
|
use ledger::{
|
|
Home, 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.open();
|
|
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, "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!(
|
|
"{}{}",
|
|
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 = 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}");
|
|
home.write(&text);
|
|
let (number, shown) = damaged(&home);
|
|
assert_eq!(number, 2, "{second}");
|
|
let start = format!("{}:2: ", 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"])],
|
|
1,
|
|
"turn 1 of session chat-1: not waiting to be sent",
|
|
),
|
|
(
|
|
vec![decided(&a, to_trusted("kyle")), decided(&a, skip("again"))],
|
|
2,
|
|
"turn 1 of session chat-1: decided already",
|
|
),
|
|
(
|
|
vec![decided(&b, skip("flagged")), failed(&b, Some(400))],
|
|
2,
|
|
"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),
|
|
],
|
|
4,
|
|
"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: String = 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.open();
|
|
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:?}"
|
|
);
|
|
}
|