//! The Honcho ledger, `memory/honcho.jsonl` (M5c spec, section 5): its lines, exactly, and the //! rules of `append`, the outbox, the sent ids, the live conclusions and the counts. Reading the //! file back is in `honcho_ledger_file.rs`. Do not edit. #[path = "support/ledger.rs"] mod ledger; #[path = "support/tmp.rs"] mod tmp; use ledger::{ Home, conclude, decided, edit, failed, sent, sid, skip, to_trusted, turn, unconclude, }; use memoryd::honcho::ledger::{Action, Counts, Item, LedgerError, Record, Workspace}; /// One record of each kind, and of each action, with its line exactly. fn examples() -> Vec<(Record, &'static str)> { vec![ ( decided(&turn("chat-1", 3), to_trusted("kyle")), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"action":{"type":"turn","workspace":"trusted","speaker":"kyle"}}"#, ), ( decided( &turn("chat-2", 1), Action::Turn { workspace: Workspace::Untrusted, speaker: "ana_b".to_string(), }, ), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"action":{"type":"turn","workspace":"untrusted","speaker":"ana_b"}}"#, ), ( decided( &edit("2026-09-28-0001", 0), conclude(Workspace::Trusted, "Kyle takes his coffee black."), ), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"action":{"type":"conclude","workspace":"trusted","peer":"kyle","session":"chat-1","text":"Kyle takes his coffee black."}}"#, ), ( decided( &edit("2026-09-28-0002", 1), unconclude(Workspace::Trusted, "Kyle takes his coffee black."), ), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0002","part":1},"action":{"type":"unconclude","workspace":"trusted","text":"Kyle takes his coffee black."}}"#, ), ( decided( &edit("2026-09-28-0003", 0), Action::Fleet { session: sid("chat-2"), text: "The build host is \"straylight\".\nIt runs NixOS.".to_string(), }, ), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0003","part":0},"action":{"type":"fleet","session":"chat-2","text":"The build host is \"straylight\".\nIt runs NixOS."}}"#, ), ( decided( &edit("2026-09-28-0004", 0), Action::Retract { session: sid("chat-2"), text: "The build host is titan.".to_string(), }, ), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0004","part":0},"action":{"type":"retract","session":"chat-2","text":"The build host is titan."}}"#, ), ( decided(&turn("chat-3", 7), skip("the session is secret")), r#"{"type":"decided","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-3","turn":7},"action":{"type":"skip","why":"the session is secret"}}"#, ), ( sent(&turn("chat-1", 3), &["msg-a1", "msg-a2"]), r#"{"type":"sent","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-1","turn":3},"ids":["msg-a1","msg-a2"]}"#, ), ( failed(&edit("2026-09-28-0001", 0), Some(422)), r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"edit","id":"2026-09-28-0001","part":0},"status":422,"kind":"refused"}"#, ), ( failed(&turn("chat-2", 1), None), r#"{"type":"failed","time":"2026-09-28T10:00:00.000Z","item":{"type":"turn","session":"chat-2","turn":1},"status":null,"kind":"refused"}"#, ), ] } #[test] fn each_record_is_one_line_exactly_as_given() { for (record, text) in examples() { assert_eq!(serde_json::to_string(&record).unwrap(), text); assert_eq!( serde_json::from_str::(text).unwrap(), record, "{text}" ); } for (workspace, text) in [ (Workspace::Trusted, "\"trusted\""), (Workspace::Untrusted, "\"untrusted\""), (Workspace::Fleet, "\"fleet\""), ] { assert_eq!(serde_json::to_string(&workspace).unwrap(), text); assert_eq!(serde_json::from_str::(text).unwrap(), workspace); } } #[test] fn append_writes_the_line_and_a_newline() { let home = Home::new("ledger-append"); let mut ledger = home.open(); let (first, second) = (examples().remove(0), examples().remove(7)); ledger.append(first.0).unwrap(); assert_eq!(home.text(), format!("{}\n", first.1)); ledger.append(second.0).unwrap(); assert_eq!(home.text(), format!("{}\n{}\n", first.1, second.1)); } #[test] fn an_unknown_field_or_value_is_refused() { for (_, text) in examples() { let mut broken = vec![format!("{},\"extra\":1}}", &text[..text.len() - 1])]; broken.push(text.replacen("\"item\":{", "\"item\":{\"extra\":1,", 1)); if text.contains("\"action\":{") { broken.push(text.replacen("\"action\":{", "\"action\":{\"extra\":1,", 1)); } for line in broken { let err = serde_json::from_str::(&line).unwrap_err(); assert!( err.to_string().contains("unknown field `extra`"), "{line}: {err}" ); } } let (_, text) = examples().remove(0); for (from, to) in [ ("\"trusted\"", "\"shared\""), ("\"type\":\"decided\"", "\"type\":\"queued\""), ( "\"type\":\"turn\",\"session\"", "\"type\":\"call\",\"session\"", ), ("\"session\":\"chat-1\"", "\"session\":\"Chat 1\""), ( "\"time\":\"2026-09-28T10:00:00.000Z\"", "\"time\":\"today\"", ), ] { assert_eq!(text.matches(from).count(), 1, "{from}"); let line = text.replace(from, to); assert!(serde_json::from_str::(&line).is_err(), "{line}"); } } #[test] fn the_outbox_is_what_is_decided_and_not_skipped_sent_or_failed() { let home = Home::new("ledger-outbox"); 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-1", 3), ); ledger.append(decided(&a, to_trusted("kyle"))).unwrap(); ledger.append(decided(&b, skip("no speaker"))).unwrap(); ledger .append(decided(&c, conclude(Workspace::Trusted, "one"))) .unwrap(); ledger .append(decided(&d, conclude(Workspace::Trusted, "two"))) .unwrap(); ledger.append(decided(&e, to_trusted("ana_b"))).unwrap(); ledger.append(sent(&c, &["c-1"])).unwrap(); ledger.append(failed(&d, Some(400))).unwrap(); assert_eq!( ledger.outbox(), vec![ (a.clone(), to_trusted("kyle")), (e.clone(), to_trusted("ana_b")) ] ); for item in [&a, &b, &c, &d, &e] { assert!(ledger.is_decided(item), "{item:?}"); } assert!(!ledger.is_decided(&turn("chat-1", 4))); assert!(!ledger.is_decided(&edit("2026-09-28-0001", 1))); } #[test] fn the_outbox_keeps_the_order_of_decision() { let home = Home::new("ledger-order"); let mut ledger = home.open(); let items = [ turn("chat-b", 2), turn("chat-a", 9), edit("2026-09-28-0002", 0), turn("chat-b", 1), edit("2026-09-28-0001", 3), edit("2026-09-28-0001", 0), ]; for item in &items { ledger.append(decided(item, skip("placeholder"))).unwrap(); } assert!(ledger.outbox().is_empty()); let home = Home::new("ledger-order-2"); let mut ledger = home.open(); for item in &items { ledger.append(decided(item, to_trusted("kyle"))).unwrap(); } let order: Vec = ledger.outbox().into_iter().map(|(item, _)| item).collect(); assert_eq!(order, items.to_vec()); ledger.append(sent(&items[1], &["m"])).unwrap(); let order: Vec = ledger.outbox().into_iter().map(|(item, _)| item).collect(); let mut left = items.to_vec(); left.remove(1); assert_eq!(order, left); } #[test] fn a_second_decision_is_refused_and_writes_nothing() { let home = Home::new("ledger-twice"); let mut ledger = home.open(); let item = turn("chat-1", 1); ledger.append(decided(&item, to_trusted("kyle"))).unwrap(); let before = home.text(); for action in [to_trusted("kyle"), skip("again")] { let err = ledger.append(decided(&item, action)).unwrap_err(); assert!( matches!(&err, LedgerError::AlreadyDecided(i) if *i == item), "{err:?}" ); assert_eq!(err.to_string(), "turn 1 of session chat-1: decided already"); } assert_eq!(home.text(), before); assert_eq!(ledger.outbox(), vec![(item, to_trusted("kyle"))]); assert_eq!(ledger.counts().decided, 1); } #[test] fn sent_or_failed_before_the_decision_is_refused() { let home = Home::new("ledger-undecided"); let mut ledger = home.open(); let item = edit("2026-09-28-0001", 2); for record in [sent(&item, &["x"]), failed(&item, Some(404))] { let err = ledger.append(record).unwrap_err(); assert!( matches!(&err, LedgerError::NotWaiting(i) if *i == item), "{err:?}" ); assert_eq!( err.to_string(), "part 2 of edit 2026-09-28-0001: not waiting to be sent" ); } assert!(!home.path.exists(), "a refused append wrote the file"); assert_eq!(ledger.counts(), Counts::default()); assert_eq!(ledger.sent_ids(&item), None); } #[test] fn an_item_is_sent_or_failed_once_and_never_when_skipped() { let home = Home::new("ledger-once"); let mut ledger = home.open(); let (s, f, k) = (turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3)); ledger.append(decided(&s, to_trusted("kyle"))).unwrap(); ledger.append(decided(&f, to_trusted("kyle"))).unwrap(); ledger.append(decided(&k, skip("secret"))).unwrap(); ledger.append(sent(&s, &["m1", "m2"])).unwrap(); ledger.append(failed(&f, None)).unwrap(); let before = home.text(); for record in [ sent(&s, &["m3"]), failed(&s, Some(500)), sent(&f, &["m4"]), failed(&f, Some(400)), sent(&k, &["m5"]), failed(&k, None), ] { let err = ledger.append(record.clone()).unwrap_err(); assert!( matches!(err, LedgerError::NotWaiting(_)), "{record:?}: {err:?}" ); } assert_eq!(home.text(), before); assert_eq!( ledger.sent_ids(&s), Some(&["m1".to_string(), "m2".to_string()][..]) ); } #[test] fn sent_ids_are_those_of_the_sent_record() { let home = Home::new("ledger-ids"); let mut ledger = home.open(); let (waiting, done, refused, skipped) = ( turn("chat-1", 1), turn("chat-1", 2), turn("chat-1", 3), turn("chat-1", 4), ); for item in [&waiting, &done, &refused] { ledger.append(decided(item, to_trusted("kyle"))).unwrap(); } ledger .append(decided(&skipped, skip("no speaker"))) .unwrap(); ledger.append(sent(&done, &["m-1", "m-2"])).unwrap(); ledger.append(failed(&refused, Some(413))).unwrap(); assert_eq!( ledger.sent_ids(&done), Some(&["m-1".to_string(), "m-2".to_string()][..]) ); for item in [&waiting, &refused, &skipped, &turn("chat-9", 1)] { assert_eq!(ledger.sent_ids(item), None, "{item:?}"); } } #[test] fn a_conclusion_is_the_latest_one_not_undone() { let home = Home::new("ledger-latest"); let mut ledger = home.open(); let text = "Kyle lives in Oakland."; let (first, second, undo1, undo2) = ( edit("2026-09-28-0001", 0), edit("2026-09-28-0002", 0), edit("2026-09-28-0003", 0), edit("2026-09-28-0004", 0), ); ledger .append(decided(&first, conclude(Workspace::Trusted, text))) .unwrap(); assert_eq!(ledger.conclusion(Workspace::Trusted, text), None); ledger.append(sent(&first, &["c-1"])).unwrap(); let found = |id: &str, item: &Item| Some((item.clone(), id.to_string())); assert_eq!( ledger.conclusion(Workspace::Trusted, text), found("c-1", &first) ); ledger .append(decided(&second, conclude(Workspace::Trusted, text))) .unwrap(); ledger.append(sent(&second, &["c-2"])).unwrap(); assert_eq!( ledger.conclusion(Workspace::Trusted, text), found("c-2", &second) ); ledger .append(decided(&undo1, unconclude(Workspace::Trusted, text))) .unwrap(); assert_eq!( ledger.conclusion(Workspace::Trusted, text), found("c-2", &second) ); ledger.append(sent(&undo1, &[])).unwrap(); assert_eq!( ledger.conclusion(Workspace::Trusted, text), found("c-1", &first) ); ledger .append(decided(&undo2, unconclude(Workspace::Trusted, text))) .unwrap(); ledger.append(sent(&undo2, &[])).unwrap(); assert_eq!(ledger.conclusion(Workspace::Trusted, text), None); // The latest is the one sent last, not the one decided last. let (early, late) = (edit("2026-09-28-0005", 0), edit("2026-09-28-0006", 0)); for item in [&early, &late] { ledger .append(decided(item, conclude(Workspace::Trusted, "y"))) .unwrap(); } ledger.append(sent(&late, &["c-late"])).unwrap(); ledger.append(sent(&early, &["c-early"])).unwrap(); assert_eq!( ledger.conclusion(Workspace::Trusted, "y"), found("c-early", &early) ); } #[test] fn a_conclusion_is_found_only_by_its_workspace_and_text_once_sent() { let home = Home::new("ledger-where"); let mut ledger = home.open(); let (sent_one, unsent, empty, other, undo_elsewhere, undo_unsent) = ( edit("2026-09-28-0001", 0), edit("2026-09-28-0002", 0), edit("2026-09-28-0003", 0), edit("2026-09-28-0004", 0), edit("2026-09-28-0005", 0), edit("2026-09-28-0006", 0), ); let actions = [ (&sent_one, conclude(Workspace::Trusted, "a")), (&unsent, conclude(Workspace::Trusted, "b")), (&empty, conclude(Workspace::Trusted, "c")), (&other, conclude(Workspace::Untrusted, "a")), (&undo_elsewhere, unconclude(Workspace::Untrusted, "a")), (&undo_unsent, unconclude(Workspace::Trusted, "a")), ]; for (item, action) in actions { ledger.append(decided(item, action)).unwrap(); } ledger.append(sent(&sent_one, &["c-a"])).unwrap(); ledger.append(sent(&empty, &[])).unwrap(); ledger.append(sent(&other, &["c-other"])).unwrap(); ledger.append(sent(&undo_elsewhere, &[])).unwrap(); let a = Some((sent_one.clone(), "c-a".to_string())); assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), a); assert_eq!(ledger.conclusion(Workspace::Trusted, "b"), None); assert_eq!(ledger.conclusion(Workspace::Trusted, "c"), None); assert_eq!(ledger.conclusion(Workspace::Trusted, "A"), None); assert_eq!(ledger.conclusion(Workspace::Untrusted, "a"), None); assert_eq!(ledger.conclusion(Workspace::Fleet, "a"), None); ledger.append(sent(&undo_unsent, &[])).unwrap(); assert_eq!(ledger.conclusion(Workspace::Trusted, "a"), None); } #[test] fn the_counts_add_up() { let home = Home::new("ledger-counts"); let mut ledger = home.open(); assert_eq!(ledger.counts(), Counts::default()); let items: Vec = (1..=9).map(|n| turn("chat-1", n)).collect(); for (n, item) in items.iter().enumerate() { let action = if n < 2 { skip("secret") } else { to_trusted("kyle") }; ledger.append(decided(item, action)).unwrap(); } for item in &items[2..5] { ledger.append(sent(item, &["m"])).unwrap(); } ledger.append(failed(&items[5], Some(400))).unwrap(); let counts = ledger.counts(); assert_eq!( counts, Counts { decided: 9, sent: 3, waiting: 3, failed: 1, skipped: 2, } ); assert_eq!( counts.decided, counts.sent + counts.waiting + counts.failed + counts.skipped ); assert_eq!(ledger.outbox().len(), counts.waiting); }