Files
wanderbooks/crates/ledger-store/tests/format.rs
T
swaits 02cb7f0254 feat(parties): an email and default terms per side (P4b S4.2)
A customer's email is copied onto an invoice's bill-to snapshot at issue,
and a party's default terms (one as our customer, one as our vendor) are
the fallback a document's due date takes when the document itself names
none (A-13, rules.md B-39). Both are journaled as party.updated facts and
guarded by documents_v2: in books that have not declared it the write is
refused with the upgrade remedy and journaling nothing, and replay gives
the same directory back.

An email must be one plain address: one @, no spaces, no full TIN (the
party text rule already refuses nine digits). Terms are typed and already
canonical, so there is no second spelling to check. A party.updated naming
a field this version does not know is now NewerJournal, never corrupt
(B-27): an older version must say update, not damage; a known field with a
malformed value is still corrupt.
2026-10-27 03:44:24 -06:00

1471 lines
55 KiB
Rust

//! The format gate: books written by a newer wanderbooks are refused as
//! newer, never as corrupt, and never read in part.
//!
//! ```text
//! GOAL: an older wanderbooks tells the human to update, not that the
//! books are damaged, and never shows books that differ from the
//! record without saying so.
//! SOURCE: rules.md B-27; plan/served.md 5.1 (format gate).
//! INVARIANTS:
//! 1. A whole, chained line of a type this version doesn't know, or a
//! journal.format_set newer than it reads, is NewerJournal: from
//! Store::open, Snapshot::load and Snapshot::refresh alike.
//! 2. Damage (a bad hash, a bad seq, a line that isn't JSON) stays
//! JournalCorrupt, whatever the damaged line's type.
//! 3. A declared format this version reads replays like any other line.
//! ```
use chrono::NaiveDate;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::format::{EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
use ledger_store::journal::{canonical_json, sha256_hex};
use ledger_store::{
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
StoreError, db_file,
};
use rust_decimal::Decimal;
use serde_json::json;
use std::path::{Path, PathBuf};
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(PathBuf);
impl std::ops::Deref for TestDir {
type Target = std::path::Path;
fn deref(&self) -> &std::path::Path {
&self.0
}
}
impl AsRef<std::path::Path> for TestDir {
fn as_ref(&self) -> &std::path::Path {
&self.0
}
}
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir =
std::env::temp_dir().join(format!("wanderbooks-format-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
/// Books with one posted entry, closed again: in journal format 1, as
/// every version before the format gate wrote them (this version's own
/// declare its format as they are created).
fn books(dir: &Path) {
let mut store = Store::create_in_format(dir, Currency::USD, &JournalFormat::default()).unwrap();
let cash = store
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let equity = store
.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let e = store
.create_entry(NaiveDate::from_ymd_opt(2026, 10, 1).unwrap(), "Capital")
.unwrap();
let m = Money::new(Decimal::from(500), Currency::USD);
store.add_line(e, cash, Side::Debit, m).unwrap();
store.add_line(e, equity, Side::Credit, m).unwrap();
store.post_entry(e).unwrap();
}
/// Append whole, correctly chained lines, as a newer wanderbooks would,
/// returning their seqs.
fn append(dir: &Path, lines: &[(&str, serde_json::Value)]) -> Vec<u64> {
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
lines
.iter()
.map(|(t, p)| w.append(t, p.clone()).unwrap().seq)
.collect()
}
/// NewerJournal for line `want_seq`. `want_verified`: the next line
/// chains onto it, so that line is intact (and the message vouches for
/// nothing after it); otherwise the message says newer or damaged, never
/// "intact".
fn assert_newer<T>(
got: Result<T, StoreError>,
want_seq: u64,
want_type: &str,
want_need: &str,
want_verified: bool,
) {
match got {
Err(e @ StoreError::NewerJournal { .. }) => {
let text = e.to_string();
let StoreError::NewerJournal {
seq,
event_type,
need,
verified,
} = e
else {
unreachable!()
};
assert_eq!(seq, want_seq);
assert_eq!(event_type, want_type);
assert_eq!(need, want_need);
assert_eq!(verified, want_verified, "{text}");
assert!(text.contains("newer version of wanderbooks"), "{text}");
assert!(text.contains("Update wanderbooks"), "{text}");
assert!(!text.contains("corrupt"), "{text}");
assert!(!text.contains("not damaged"), "{text}");
if verified {
assert!(text.contains(&format!("Line {seq} is intact")), "{text}");
assert!(text.contains("can't check the lines after it"), "{text}");
} else {
assert!(!text.contains("intact"), "{text}");
assert!(text.contains("or the line is damaged"), "{text}");
assert!(text.contains("run verify"), "{text}");
}
}
Err(e) => panic!("expected NewerJournal, got {e:?}"),
Ok(_) => panic!("expected NewerJournal, the books opened"),
}
}
fn assert_corrupt<T>(got: Result<T, StoreError>) {
match got {
Err(StoreError::JournalCorrupt(_)) => {}
Err(e) => panic!("expected JournalCorrupt, got {e:?}"),
Ok(_) => panic!("expected JournalCorrupt, the books opened"),
}
}
/// Oracle (plan/served.md 5.1): an older reader meets books a newer
/// wanderbooks wrote. A valid line of an unknown type, and a declared
/// format 3, are each NewerJournal, from the writer and the viewer alike,
/// and the refusal reads nothing further: the database isn't rebuilt and
/// the journal is untouched.
#[test]
fn old_reader_gets_newer_journal() {
let cases = [
(
"period.attested",
json!({"key": "company.name", "value": "X"}),
"an event type this version doesn't know",
),
(
FORMAT_SET,
json!({"format": 3, "features": []}),
"journal format 3",
),
(
FORMAT_SET,
json!({"format": 1, "features": ["actors"]}),
"journal features actors",
),
];
for (i, (line_type, payload, need)) in cases.into_iter().enumerate() {
let dir = test_dir(&format!("newer-{i}"));
books(&dir);
// A later line of a known type changes nothing: the first line
// this version can't read is the one named.
let seq = append(
&dir,
&[
(line_type, payload),
(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
),
],
)[0];
std::fs::remove_file(dir.join(db_file())).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
assert_newer(Store::open(&dir), seq, line_type, need, true);
assert!(
!dir.join(db_file()).exists(),
"a refused open rebuilt the database"
);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
assert_newer(Snapshot::load(&dir), seq, line_type, need, true);
}
}
/// A viewer already following the books meets a newer line mid-stream:
/// refresh says NewerJournal (every time, until it is updated), applies
/// none of the lines written with it (as the writer refuses the books
/// whole), and keeps showing the books as it last read them.
#[test]
fn a_viewer_following_the_books_gets_newer_journal() {
let dir = test_dir("follow");
books(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
let before = ledger_core::reports::snapshot(snap.ledger()).unwrap();
let declared = append(&dir, &[(FORMAT_SET, json!({"format": 1, "features": []}))])[0];
assert_eq!(snap.refresh().unwrap(), 1);
let seq = append(
&dir,
&[
(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
),
("entry.evidence_inherited", json!({"entry_id": 1})),
],
)[1];
for _ in 0..2 {
assert_newer(
snap.refresh(),
seq,
"entry.evidence_inherited",
"an event type this version doesn't know",
false,
);
assert_eq!(snap.journal_seq(), declared);
assert_eq!(
ledger_core::reports::snapshot(snap.ledger()).unwrap(),
before
);
}
}
/// The writer and the viewer refuse the same journal the same way, even
/// when an earlier line's facts are bad too: every line passes the gate
/// before any is replayed.
#[test]
fn the_writer_and_the_viewer_agree_on_newer_books() {
let dir = test_dir("agree");
books(&dir);
let seq = append(
&dir,
&[
// Chained and of a known type, but replay refuses it.
("entry.posted", json!({"id": 99})),
("period.attested", json!({"key": "company.name"})),
],
)[1];
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
}
/// Damage stays corruption, whatever the damaged line's type: the gate
/// sees only lines that parsed, chained and kept their seq, so damage up
/// to and including the first line this version can't read is
/// JournalCorrupt. (Lines after that one are the newer version's to judge:
/// its format may frame them differently.)
#[test]
fn damaged_journals_are_still_corrupt() {
type Damage = fn(&mut Vec<String>);
let damages: [(&str, Damage); 4] = [
("hash", |lines| {
let last = lines.last_mut().unwrap();
*last = last.replace("\"prev\":\"", "\"prev\":\"0");
}),
("seq", |lines| {
let n = lines.len();
let last = lines.last_mut().unwrap();
*last = last.replace(&format!("\"seq\":{n}"), &format!("\"seq\":{}", n + 1));
}),
("json", |lines| {
let last = lines.last_mut().unwrap();
last.truncate(last.len() / 2);
}),
("payload", |lines| {
// The line before, edited after it was written: the last
// line's prev no longer matches.
let n = lines.len();
lines[n - 2] = lines[n - 2].replace("\"Capital\"", "\"Capitol\"");
}),
];
let newer = [
(FORMAT_SET, json!({"format": 3, "features": []})),
("period.attested", json!({"key": "company.name"})),
];
for (name, damage) in damages {
for (i, last) in newer.iter().enumerate() {
let dir = test_dir(&format!("damaged-{name}-{i}"));
books(&dir);
append(&dir, std::slice::from_ref(last));
let path = dir.join(JOURNAL_FILE);
let mut lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(str::to_string)
.collect();
let whole = lines.clone();
damage(&mut lines);
assert_ne!(lines, whole, "{name} damaged nothing");
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
}
}
/// A declared format this version reads opens, verifies, rebuilds, and
/// reads the same through the viewer; the format in force is recorded.
#[test]
fn a_declared_format_1_replays() {
let dir = test_dir("format-1");
books(&dir);
let mut store = Store::open(&dir).unwrap();
assert_eq!(store.journal_format(), &JournalFormat::default());
let before = ledger_core::reports::snapshot(store.ledger()).unwrap();
store.declare_journal_format(1, &[]).unwrap();
assert_eq!(store.journal_format().format, 1);
store.verify().unwrap();
drop(store);
let mut store = Store::open(&dir).unwrap();
store.verify().unwrap();
store.rebuild().unwrap();
store.verify().unwrap();
assert_eq!(store.journal_format(), &JournalFormat::default());
assert_eq!(
ledger_core::reports::snapshot(store.ledger()).unwrap(),
before
);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.journal_format(), &JournalFormat::default());
assert_eq!(snap.journal().last().unwrap().line_type, FORMAT_SET);
assert_eq!(
ledger_core::reports::snapshot(snap.ledger()).unwrap(),
before
);
}
/// A writer never declares a format it couldn't read back: refused, and
/// the journal is byte-identical.
#[test]
fn a_format_this_version_cannot_read_is_never_declared() {
let dir = test_dir("declare-refused");
books(&dir);
let mut store = Store::open(&dir).unwrap();
let journal = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let unknown = ["no-such-feature"];
assert!(!KNOWN_FEATURES.contains(&unknown[0]));
for (format, features) in [
(SUPPORTED_JOURNAL_FORMAT + 1, &[][..]),
(0, &[][..]),
(1, &unknown[..]),
] {
assert!(
matches!(
store.declare_journal_format(format, features),
Err(StoreError::FormatRefused(_))
),
"declared {format} {features:?}"
);
assert_eq!(std::fs::read(dir.join(JOURNAL_FILE)).unwrap(), journal);
}
assert_eq!(store.journal_format(), &JournalFormat::default());
}
/// Every type the store dispatches on is one the gate admits: a type with
/// a replay or projection arm in store.rs that the list lacked would be
/// refused as newer in the books that hold it. (The converse, every listed
/// type has both arms, is checked inside the crate.)
#[test]
fn every_event_the_store_replays_passes_the_gate() {
let src = include_str!("../src/store.rs");
let mut arms: Vec<&str> = src
.lines()
.map(str::trim_start)
.map(|l| l.strip_prefix("| ").unwrap_or(l))
.filter_map(|l| l.strip_prefix('"'))
.filter_map(|l| l.split_once('"'))
.filter(|(_, rest)| {
let rest = rest.trim_start();
rest.starts_with("=>") || rest.starts_with('|') || rest.is_empty()
})
.map(|(t, _)| t)
.filter(|t| {
t.split_once('.').is_some_and(|(a, b)| {
!a.is_empty()
&& !b.is_empty()
&& t.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '.'
})
})
})
.collect();
arms.sort_unstable();
arms.dedup();
assert!(arms.len() > 50, "found only {arms:?}");
for t in &arms {
assert!(
EVENT_TYPES.contains(t),
"{t} is replayed but not in EVENT_TYPES"
);
}
for t in EVENT_TYPES {
assert!(
arms.contains(t),
"{t} is in EVENT_TYPES but has no arm in store.rs"
);
}
}
/// Rewrite the journal's lines with `edit`, as damage on disk would.
fn edit_journal(dir: &Path, edit: impl FnOnce(&mut Vec<String>)) {
let path = dir.join(JOURNAL_FILE);
let mut lines: Vec<String> = std::fs::read_to_string(&path)
.unwrap()
.lines()
.map(str::to_string)
.collect();
let whole = lines.clone();
edit(&mut lines);
assert_ne!(lines, whole, "the edit changed nothing");
std::fs::write(&path, lines.join("\n") + "\n").unwrap();
}
/// A line's own hash is checked only by the next line's prev, so the
/// gate's refusal waits for it (rules.md B-27): a flipped bit in a middle
/// line's type, or a declared format edited from 1 to 2, reads like books
/// from a newer wanderbooks until the next line shows the line changed.
/// That is corruption, from the writer and the viewer alike.
#[test]
fn a_damaged_line_that_looks_newer_is_corrupt_when_the_next_line_checks_it() {
type Edit = fn(&mut Vec<String>);
let edits: [(&str, Edit); 2] = [
("type", |lines| {
// account.added (seq 2) becomes a type no version writes.
lines[1] = lines[1].replace("\"account.added\"", "\"account.addee\"");
}),
("format", |lines| {
let at = lines.iter().position(|l| l.contains(FORMAT_SET)).unwrap();
lines[at] = lines[at].replace("\"format\":1", "\"format\":2");
}),
];
for (name, edit) in edits {
let dir = test_dir(&format!("flipped-{name}"));
books(&dir);
let mut store = Store::open(&dir).unwrap();
store.declare_journal_format(1, &[]).unwrap();
store
.add_account("1010", "Savings", AccountType::Asset, None, false)
.unwrap();
drop(store);
edit_journal(&dir, edit);
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
}
/// When the line the gate refuses is the journal's last, nothing checks
/// its bytes: a damaged type and a genuinely newer one look the same. The
/// refusal says exactly that (newer or damaged; update, then verify),
/// never that the books are not damaged.
#[test]
fn an_unreadable_last_line_is_reported_as_newer_or_damaged() {
let dir = test_dir("flipped-last");
books(&dir);
let seq = append(
&dir,
&[(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)],
)[0];
edit_journal(&dir, |lines| {
let last = lines.last_mut().unwrap();
*last = last.replace("\"fx.rate_set\"", "\"fx.rate_sex\"");
});
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "fx.rate_sex", need, false);
assert_newer(Snapshot::load(&dir), seq, "fx.rate_sex", need, false);
}
/// A genuinely newer line with a whole, chained line after it: its bytes
/// are checked, so the books are newer and that line is intact.
#[test]
fn a_newer_line_the_next_line_chains_onto_is_newer_and_intact() {
let dir = test_dir("newer-verified");
books(&dir);
let seq = append(
&dir,
&[
("entry.evidence_inherited", json!({"entry_id": 1})),
("entry.evidence_inherited", json!({"entry_id": 1})),
],
)[0];
let need = "an event type this version doesn't know";
assert_newer(
Store::open(&dir),
seq,
"entry.evidence_inherited",
need,
true,
);
assert_newer(
Snapshot::load(&dir),
seq,
"entry.evidence_inherited",
need,
true,
);
}
/// A line the gate refuses, followed by a complete line this version
/// can't even parse as JSON (or a blank one), is unverified: nothing
/// checks the refused line, so it is newer or damaged, never corrupt for
/// certain (a newer format may frame its lines its own way; rules.md
/// B-27), and never intact.
#[test]
fn a_refused_line_followed_by_a_garbled_line_is_unverified() {
for (i, garbled) in ["{not json", "", "[1,2]", "{\"prev\":7}"]
.iter()
.enumerate()
{
let dir = test_dir(&format!("newer-then-garbled-{i}"));
books(&dir);
let seq = append(
&dir,
&[("entry.evidence_inherited", json!({"entry_id": 1}))],
)[0];
edit_journal(&dir, |lines| lines.push(garbled.to_string()));
let need = "an event type this version doesn't know";
let t = "entry.evidence_inherited";
assert_newer(Store::open(&dir), seq, t, need, false);
assert_newer(Snapshot::load(&dir), seq, t, need, false);
}
}
/// The journal's last line, as raw bytes, and its hash: what the next
/// line's prev must be.
fn last_line(dir: &Path) -> (u64, String) {
let raw = std::fs::read(dir.join(JOURNAL_FILE)).unwrap();
let body = raw.strip_suffix(b"\n").unwrap();
let last = body.rsplit(|&b| b == b'\n').next().unwrap();
let v: serde_json::Value = serde_json::from_slice(last).unwrap();
(v["seq"].as_u64().unwrap(), sha256_hex(last))
}
/// Append one line's bytes and a newline, as another writer would.
fn append_raw(dir: &Path, bytes: &[u8]) {
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(dir.join(JOURNAL_FILE))
.unwrap();
std::io::Write::write_all(&mut f, bytes).unwrap();
}
/// A newer format frames the lines after its declaration its own way
/// (rules.md B-27): a float, a string seq, a key of its own. This version
/// reads only the next line's prev, so it never calls such books corrupt:
/// the declaration is intact when that prev checks it, unverified when the
/// next line has no prev it can read, and corrupt only when a prev it can
/// read doesn't match. The writer and the viewer agree.
#[test]
fn a_newer_format_frames_its_lines_its_own_way() {
type Next = fn(u64, &str) -> serde_json::Value;
let nexts: [(&str, Next, Option<bool>); 6] = [
(
"float",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}),
Some(true),
),
(
"string-seq",
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}),
Some(true),
),
(
"envelope-key",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}),
Some(true),
),
(
"no-prev",
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}),
Some(false),
),
(
"prev-not-text",
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}),
Some(false),
),
(
"wrong-prev",
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}),
None,
),
];
for (name, next, verified) in nexts {
let dir = test_dir(&format!("v2-{name}"));
books(&dir);
let seq = append(
&dir,
&[(FORMAT_SET, json!({"format": 3, "features": ["actors"]}))],
)[0];
let (_, hash) = last_line(&dir);
append_raw(
&dir,
format!("{}\n", canonical_json(&next(seq + 1, &hash))).as_bytes(),
);
match verified {
Some(v) => {
assert_newer(Store::open(&dir), seq, FORMAT_SET, "journal format 3", v);
assert_newer(Snapshot::load(&dir), seq, FORMAT_SET, "journal format 3", v);
}
None => {
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
}
}
}
/// A key beside the envelope's is never dropped (rules.md B-27): the line
/// is refused as newer, and its hash covers the key, so the next line
/// still checks it (intact), a next line hashed without the key shows it
/// was added (corrupt), and as the last line it is unverified. The writer
/// and the viewer agree, and JournalLine::parse refuses such a line.
#[test]
fn an_unknown_envelope_key_is_newer_and_checked_through() {
for case in ["chained", "hashed-without-the-key", "last"] {
let dir = test_dir(&format!("envelope-{case}"));
books(&dir);
let (seq, prev) = last_line(&dir);
let seq = seq + 1;
let line = json!({"seq": seq, "ts": "2026-10-16T00:00:00Z", "type": "fx.rate_set",
"payload": {"currency": "EUR", "date": "2026-10-01", "rate": "1.10"},
"prev": prev, "actor": "agent"});
let raw = canonical_json(&line);
let err = ledger_store::JournalLine::parse(raw.as_bytes()).unwrap_err();
assert!(err.contains("actor"), "{err}");
append_raw(&dir, format!("{raw}\n").as_bytes());
let without = {
let mut l = line.clone();
l.as_object_mut().unwrap().remove("actor");
sha256_hex(canonical_json(&l).as_bytes())
};
let next_prev = match case {
"chained" => Some(sha256_hex(raw.as_bytes())),
"hashed-without-the-key" => Some(without),
_ => None,
};
if let Some(p) = &next_prev {
let next = json!({"seq": seq + 1, "ts": "t", "type": "fx.rate_set",
"payload": {"currency": "EUR", "date": "2026-10-02", "rate": "1.10"}, "prev": p});
append_raw(&dir, format!("{}\n", canonical_json(&next)).as_bytes());
}
let need = "journal line fields this version doesn't know (actor)";
match case {
"hashed-without-the-key" => {
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
_ => {
let v = case == "chained";
assert_newer(Store::open(&dir), seq, "fx.rate_set", need, v);
assert_newer(Snapshot::load(&dir), seq, "fx.rate_set", need, v);
}
}
}
}
/// A line that isn't UTF-8 is damage, for the writer as for the viewer
/// (they read the journal the same way: bytes, split on newlines), never
/// an I/O error.
#[test]
fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
for at in ["end", "after-a-newer-line"] {
let dir = test_dir(&format!("not-utf8-{at}"));
books(&dir);
let path = dir.join(JOURNAL_FILE);
if at == "end" {
append_raw(&dir, b"{\"seq\":99,\"x\":\"\xff\xfe\"}\n");
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
assert_corrupt(ledger_store::journal::read_and_verify_journal(&path));
} else {
// After a refused line, it is a next line without a readable
// prev: unverified, for both.
let seq = append(&dir, &[("period.attested", json!({}))])[0];
append_raw(&dir, b"{\"prev\":\"\xff\"}\n");
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
}
}
}
/// An older version never changes books it can't read: a newer line
/// followed by a cut-off last line (a newer version mid-append) is
/// refused as newer, and the journal is byte for byte as it was, with
/// nothing set aside. Books this version reads still get the repair.
#[test]
fn an_old_reader_never_repairs_newer_books() {
let dir = test_dir("newer-torn");
books(&dir);
let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0];
append_raw(&dir, b"{\"seq\":");
let path = dir.join(JOURNAL_FILE);
let before = std::fs::read(&path).unwrap();
let need = "an event type this version doesn't know";
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
assert_eq!(std::fs::read(&path).unwrap(), before);
let set_aside: Vec<_> = std::fs::read_dir(&dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".torn."))
.collect();
assert!(set_aside.is_empty(), "{set_aside:?}");
// The same cut-off tail after lines this version reads is repaired,
// and the repair is journaled.
let dir = test_dir("known-torn");
books(&dir);
append_raw(&dir, b"{\"seq\":");
let store = Store::open(&dir).unwrap();
assert_eq!(store.tail_repairs().unwrap().len(), 1);
}
/// B-27 ("a cut-off last line is set aside only when the lines before it
/// pass"; Store::open SCENARIO 3): a complete line that chains and passes
/// the gate but can't be applied (a post of an entry that doesn't exist),
/// or a first line that isn't store.created, refuses the open with the
/// journal byte for byte as it was and nothing set aside, whatever comes
/// after it: a cut-off tail stays where it is, for a human to look at.
#[test]
fn a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was() {
let unapplied = test_dir("bad-then-torn");
books(&unapplied);
append(&unapplied, &[("entry.posted", json!({"entry_id": 999}))]);
let headless = test_dir("headless-then-torn");
append(
&headless,
&[(
"account.added",
json!({"id": 1, "code": "1000", "name": "Cash", "account_type": "Asset"}),
)],
);
for dir in [&unapplied, &headless] {
append_raw(dir, b"{\"seq\":");
let path = dir.join(JOURNAL_FILE);
let before = std::fs::read(&path).unwrap();
let had_db = dir.join(ledger_store::db_file()).exists();
assert!(Store::open(dir).is_err(), "{}", dir.display());
assert_eq!(std::fs::read(&path).unwrap(), before, "{}", dir.display());
let set_aside: Vec<_> = std::fs::read_dir(dir)
.unwrap()
.filter_map(Result::ok)
.filter(|e| e.file_name().to_string_lossy().contains(".torn."))
.collect();
assert!(set_aside.is_empty(), "{set_aside:?}");
assert_eq!(
dir.join(ledger_store::db_file()).exists(),
had_db,
"no database made for books that can't open"
);
}
assert!(matches!(
Store::open(&headless),
Err(StoreError::JournalCorrupt(_))
));
}
/// The viewer applies the writer's rule for the first line (B-27: the
/// two read the same journal the same way): it must be a store.created
/// naming a currency. Any other first line, or a store.created without a
/// sound base_currency, is corrupt for both; neither guesses a currency.
#[test]
fn the_first_line_is_store_created_for_the_viewer_and_the_writer() {
let firsts = [
(
"account.added",
json!({"id": 1, "code": "1000", "name": "Cash", "account_type": "Asset"}),
),
("store.created", json!({})),
("store.created", json!({"base_currency": 5})),
("store.created", json!({"base_currency": "dollars"})),
("journal.tail_repaired", json!({"bytes": 1, "sha256": "00"})),
];
for (n, (t, payload)) in firsts.into_iter().enumerate() {
let dir = test_dir(&format!("first-line-{n}"));
append(&dir, &[(t, payload.clone())]);
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
// A sound one opens for both, in its own currency.
let dir = test_dir("first-line-ok");
append(&dir, &[("store.created", json!({"base_currency": "EUR"}))]);
assert_eq!(Store::open(&dir).unwrap().base_currency(), Currency::EUR);
assert_eq!(Snapshot::load(&dir).unwrap().base_currency(), Currency::EUR);
}
/// A blank complete line is damage, for the viewer as for the writer: the
/// writer never writes one, and the two read the same journal the same
/// way (S9 b, 2026-10-16).
#[test]
fn a_blank_line_is_corrupt_for_the_viewer_and_the_writer() {
for at in ["middle", "end"] {
let dir = test_dir(&format!("blank-{at}"));
books(&dir);
edit_journal(&dir, |lines| match at {
"middle" => lines.insert(2, String::new()),
_ => lines.push(String::new()),
});
assert_corrupt(Store::open(&dir));
assert_corrupt(Snapshot::load(&dir));
}
// A viewer following the books meets one appended later.
let dir = test_dir("blank-follow");
books(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
let mut f = std::fs::OpenOptions::new()
.append(true)
.open(dir.join(JOURNAL_FILE))
.unwrap();
std::io::Write::write_all(&mut f, b"\n").unwrap();
assert_corrupt(snap.refresh());
}
/// The writer and the viewer, opening and following, say the same thing,
/// word for word, about the same damaged line (the newline that ends it
/// is no part of the line): a JSON value cut off where its line ends.
#[test]
fn the_viewer_and_the_writer_say_the_same_of_a_damaged_line() {
for (tag, raw) in [
("string", &b"{\"seq\":9,\"note\":\"cut"[..]),
("object", &b"{\"seq\":9,"[..]),
] {
let dir = test_dir(&format!("same-words-{tag}"));
books(&dir);
let mut snap = Snapshot::load(&dir).unwrap();
append_raw(&dir, &[raw, b"\n"].concat());
let writer = Store::open(&dir).err().expect("damaged books opened");
assert!(matches!(writer, StoreError::JournalCorrupt(_)), "{writer}");
let writer = writer.to_string();
assert!(writer.contains("unparseable line"), "{writer}");
let viewer = Snapshot::load(&dir).err().expect("damaged books loaded");
assert_eq!(viewer.to_string(), writer, "{tag}: opening");
let following = snap.refresh().expect_err("damaged books followed");
assert_eq!(following.to_string(), writer, "{tag}: following");
}
}
/// read_journal_and_format reads every line, in order, as the plain
/// verified read does, with the format in force.
#[test]
fn read_journal_and_format_gives_every_line_and_the_format() {
let dir = test_dir("read-and-format");
books(&dir);
append(
&dir,
&[(
FORMAT_SET,
json!({"format": SUPPORTED_JOURNAL_FORMAT, "features": []}),
)],
);
let path = dir.join(JOURNAL_FILE);
let (lines, format) = ledger_store::journal::read_journal_and_format(&path).unwrap();
let want: Vec<(u64, String)> = ledger_store::journal::read_and_verify_journal(&path)
.unwrap()
.into_iter()
.map(|l| (l.seq, l.line_type))
.collect();
assert!(want.len() > 5, "{want:?}");
assert_eq!(
lines
.into_iter()
.map(|l| (l.seq, l.line_type))
.collect::<Vec<_>>(),
want
);
assert_eq!(
format,
JournalFormat {
format: SUPPORTED_JOURNAL_FORMAT,
features: vec![]
}
);
}
/// P3's journal extensions (rules.md B-27; plan D-39): each is announced
/// by a feature this version knows, `account_renamed` for an
/// `account.renamed`, `require_tax_category` for a `settings.set` naming
/// `policy.require_tax_category`. Books an older version made (books():
/// format 1, nothing declared) refuse both, journaling nothing, until the
/// owner's format upgrade declares this version's whole format at once;
/// then both are taken, under that one declaration. A reader whose
/// features don't include them (every earlier version: none) stops at the
/// declaration as newer, never at the line, and never calls it corrupt.
#[test]
fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
for f in ["account_renamed", "require_tax_category", "projects"] {
assert!(KNOWN_FEATURES.contains(&f), "{f}");
}
let dir = test_dir("p3-features");
books(&dir);
let mut store = Store::open(&dir).unwrap();
assert!(store.journal_format().features.is_empty());
let policy = || ledger_core::settings::Change {
key: "policy.require_tax_category".into(),
value: Some(json!("refuse")),
};
let source = ledger_core::settings::Source {
decided_by: "Owner".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 20).unwrap(),
citation: None,
note: None,
};
let head = store.head();
let waits = |r: Result<(), StoreError>, need: &str| match r {
Err(StoreError::FormatUpgradePending { need: n }) => assert_eq!(n, need),
other => panic!("expected FormatUpgradePending, got {other:?}"),
};
waits(
store.rename_account(1, "Business Checking", None),
"the journal feature account_renamed",
);
waits(
store.change_settings(&[policy()], &source).map(|_| ()),
"the journal feature require_tax_category",
);
let facts = ledger_core::ProjectFacts {
kind: Some(ledger_core::ProjectKind::OnSpec),
..Default::default()
};
waits(
store.add_project("Harbor", &facts).map(|_| ()),
"the journal feature projects",
);
assert_eq!(store.head(), head, "refused whole");
assert!(store.upgrade_format().unwrap().is_some());
store.rename_account(1, "Business Checking", None).unwrap();
store.rename_account(1, "Checking", None).unwrap();
store.change_settings(&[policy()], &source).unwrap();
store.add_project("Harbor", &facts).unwrap();
assert_eq!(*store.journal_format(), ledger_store::format::current());
drop(store);
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
let lines: Vec<serde_json::Value> = text
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect();
let kinds: Vec<(&str, serde_json::Value)> = lines
.iter()
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
.filter(|(t, _)| {
matches!(
*t,
FORMAT_SET | "account.renamed" | "settings.set" | "project.set"
)
})
.collect();
let current = ledger_store::format::current();
assert_eq!(kinds[0].0, FORMAT_SET);
assert_eq!(
kinds[0].1,
json!({"format": current.format, "features": current.features})
);
assert_eq!(kinds[1].0, "account.renamed");
assert_eq!(kinds[2].0, "account.renamed");
assert_eq!(kinds[3].0, "settings.set");
assert_eq!(kinds[4].0, "project.set");
assert_eq!(kinds.len(), 5);
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
// so the gate stops it at the declaration, before the rename: the
// gate's refusal of a feature it doesn't know is pinned by
// an_unknown_feature_before_a_rename_is_newer_at_the_declaration.
// This version opens them, in the writer and the viewer.
let store = Store::open(&dir).unwrap();
store.verify().unwrap();
assert_eq!(store.ledger().chart().get(1).unwrap().name, "Checking");
drop(store);
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(snap.ledger().chart().get(1).unwrap().name, "Checking");
}
/// A feature this version doesn't know, declared before an account.renamed
/// (as a later version would), is newer at the declaration: the line after
/// it is never read.
#[test]
fn an_unknown_feature_before_a_rename_is_newer_at_the_declaration() {
let dir = test_dir("p3-unknown-feature");
books(&dir);
let seqs = append(
&dir,
&[
(
FORMAT_SET,
json!({"format": 2, "features": ["account_renamed", "later"]}),
),
(
"account.renamed",
json!({"account_id": 1, "code": "1000", "name": "x", "previous": "Cash", "reason": null}),
),
],
);
let _ = std::fs::remove_file(dir.join(db_file()));
match Store::open(&dir) {
Err(StoreError::NewerJournal { seq, need, .. }) => {
assert_eq!(seq, seqs[0]);
assert_eq!(need, "journal features later");
}
other => panic!("expected NewerJournal, got {:?}", other.map(|_| ())),
}
}
/// Books with a prepaid account and an expense to schedule against;
/// returns (prepaid, insurance).
fn schedule_books(dir: &Path) -> (u64, u64) {
books(dir);
let mut store = Store::open(dir).unwrap();
let prepaid = store
.add_account("1300", "Prepaid", AccountType::Asset, None, false)
.unwrap();
let insurance = store
.add_account("6300", "Insurance", AccountType::Expense, None, false)
.unwrap();
(prepaid, insurance)
}
fn insurance(
accounts: (u64, u64),
start: NaiveDate,
convention: ledger_core::ScheduleConvention,
) -> ledger_core::NewSchedule {
ledger_core::NewSchedule {
description: "Liability insurance".into(),
kind: ledger_core::ScheduleKind::PrepaidExpense,
total: Money::new(Decimal::from(1200), Currency::USD),
start,
months: 12,
balance_account: accounts.0,
pl_account: accounts.1,
convention,
}
}
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, d).unwrap()
}
/// The journal's lines, parsed.
fn lines(dir: &Path) -> Vec<serde_json::Value> {
std::fs::read_to_string(dir.join(JOURNAL_FILE))
.unwrap()
.lines()
.map(|l| serde_json::from_str(l).unwrap())
.collect()
}
/// P3 (defect 9; rules.md B-27, D-39): a daily schedule's
/// schedule.registered, which carries convention "daily", needs journal
/// feature schedule_convention. Books an older version made (books():
/// format 1) refuse one, journaling nothing, until the owner's format
/// upgrade, whose one declaration is on the line before it. A version
/// before P3 knew no features, so it stops at that declaration
/// (NewerJournal; format.rs's an_older_reader_stops_at_p3s_features),
/// never reading the schedule as equal months. This version reads it back,
/// posts by the day, and verifies; a second daily schedule declares
/// nothing more.
#[test]
fn a_daily_schedule_waits_for_its_feature_and_an_older_reader_stops_there() {
let dir = test_dir("daily-feature");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
let daily = |start| insurance(accounts, start, ledger_core::ScheduleConvention::Daily);
let head = store.head();
match store.register_schedule(daily(day(2026, 3, 15))) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature schedule_convention")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(store.head(), head, "refused whole");
store.upgrade_format().unwrap();
let id = store.register_schedule(daily(day(2026, 3, 15))).unwrap();
let journal = lines(&dir);
let n = journal.len();
assert_eq!(journal[n - 1]["type"], "schedule.registered");
assert_eq!(journal[n - 1]["payload"]["convention"], "daily");
assert_eq!(journal[n - 2]["type"], FORMAT_SET);
let current = ledger_store::format::current();
assert_eq!(
journal[n - 2]["payload"],
json!({"format": current.format, "features": current.features})
);
store.run_schedules(day(2026, 5, 31)).unwrap();
store
.register_schedule(insurance(
accounts,
day(2026, 4, 2),
ledger_core::ScheduleConvention::Daily,
))
.unwrap();
let declared = lines(&dir)
.iter()
.filter(|l| l["type"] == FORMAT_SET)
.count();
assert_eq!(declared, 1, "declared once");
store.verify().unwrap();
let posted: Vec<NaiveDate> = store
.ledger()
.get_schedule(id)
.unwrap()
.posted
.iter()
.copied()
.collect();
assert_eq!(
posted,
[day(2026, 3, 31), day(2026, 4, 30), day(2026, 5, 31)]
);
drop(store);
// Reopened, rebuilt, and through the viewer: the same plan, posted.
let mut store = Store::open(&dir).unwrap();
store.rebuild().unwrap();
store.verify().unwrap();
let s = store.ledger().get_schedule(id).unwrap();
assert_eq!(s.convention, ledger_core::ScheduleConvention::Daily);
assert_eq!(s.plan().unwrap()[0].1, Decimal::new(5484, 2));
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(
snap.ledger().differs_from(store.ledger()),
Vec::<&str>::new()
);
}
/// P3: an equal-months schedule (journals before P3 hold only these)
/// declares no feature: books that never use a daily schedule stay
/// readable by versions before it. Its line names its convention.
#[test]
fn a_monthly_even_schedule_declares_no_feature() {
let dir = test_dir("monthly-even-no-feature");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
store
.register_schedule(insurance(
accounts,
day(2026, 3, 1),
ledger_core::ScheduleConvention::MonthlyEven,
))
.unwrap();
let journal = lines(&dir);
assert!(journal.iter().all(|l| l["type"] != FORMAT_SET));
assert_eq!(
journal.last().unwrap()["payload"]["convention"],
"monthly_even"
);
assert_eq!(store.journal_format(), &JournalFormat::default());
store.verify().unwrap();
}
/// P3: a schedule convention this version doesn't know (one a newer
/// version adds) is newer, never corrupt (rules.md B-27); one that isn't a
/// string is damage.
#[test]
fn an_unknown_schedule_convention_is_newer_not_corrupt() {
let dir = test_dir("unknown-convention");
let accounts = schedule_books(&dir);
let registered = |convention: serde_json::Value| {
json!({
"id": 1,
"description": "Insurance",
"kind": "prepaid_expense",
"total": {"amount": "1200", "currency": "USD"},
"start": "2026-03-15",
"months": 12,
"balance_account": accounts.0,
"pl_account": accounts.1,
"convention": convention,
})
};
let seq = append(
&dir,
&[("schedule.registered", registered(json!("weekly")))],
)[0];
std::fs::remove_file(dir.join(db_file())).unwrap();
for got in [Store::open(&dir).err(), Snapshot::load(&dir).err()] {
match got {
Some(StoreError::NewerJournal { seq: s, need, .. }) => {
assert_eq!(s, seq);
assert_eq!(need, "the schedule convention weekly");
}
other => panic!("expected NewerJournal, got {other:?}"),
}
}
let dir = test_dir("bad-convention");
let accounts2 = schedule_books(&dir);
assert_eq!(accounts2, accounts);
append(&dir, &[("schedule.registered", registered(json!(7)))]);
std::fs::remove_file(dir.join(db_file())).unwrap();
assert_corrupt(Store::open(&dir));
}
/// P4b S4.2 (rules.md B-27): a `party.updated` naming a field this version
/// doesn't know is newer, never corrupt; a known field with a malformed
/// value is still damage.
#[test]
fn an_unknown_party_field_is_newer_not_corrupt() {
let dir = test_dir("unknown-party-field");
books(&dir);
let seq = append(
&dir,
&[
(
"party.updated",
json!({"id": 0, "change": {"field": "ssn", "value": "x"}}),
),
(
"fx.rate_set",
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
),
],
)[0];
std::fs::remove_file(dir.join(db_file())).unwrap();
assert_newer(
Store::open(&dir),
seq,
"party.updated",
"the party field ssn",
false,
);
assert_newer(
Snapshot::load(&dir),
seq,
"party.updated",
"the party field ssn",
false,
);
let dir = test_dir("bad-party-field-value");
books(&dir);
append(
&dir,
&[(
"party.updated",
json!({"id": 0, "change": {"field": "email", "value": 5}}),
)],
);
std::fs::remove_file(dir.join(db_file())).unwrap();
assert_corrupt(Store::open(&dir));
}
/// P3 (R3; rules.md B-27): a schedule month reversed within its month is
/// posted again only in books that declare journal feature month_reposting
/// (an older version would refuse the second schedule.posted as posted
/// twice): books an older version made wait for the format upgrade, and
/// the books replay to the same ledger. A month reversed after it ended is
/// left by the run, listed with why (it would count twice until the
/// reversal), and journals nothing. Hand check: equal months of 100.00
/// from 2026-03-01; March posted, reversed on Mar 31, run again: one
/// March, 100.00; April posted, reversed on May 2: April held.
#[test]
fn a_schedule_month_posted_again_waits_for_month_reposting_and_replays() {
let dir = test_dir("schedule-repost");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
let id = store
.register_schedule(insurance(
accounts,
day(2026, 3, 1),
ledger_core::ScheduleConvention::MonthlyEven,
))
.unwrap();
let mar = store.post_schedule_month(id, day(2026, 3, 31)).unwrap();
store
.reverse_entry(mar, day(2026, 3, 31), "posted early")
.unwrap();
// Books an older version made (books(): format 1) wait for the
// upgrade before March posts again; the owner's go-ahead declares it.
assert!(store.journal_format().features.is_empty());
let head = store.head();
assert!(matches!(
store.run_schedules(day(2026, 3, 31)),
Err(StoreError::FormatUpgradePending { need }) if need == "the journal feature month_reposting"
));
assert_eq!(store.head(), head, "refused whole");
store.upgrade_format().unwrap();
let posted = store.run_schedules(day(2026, 3, 31)).unwrap();
assert_eq!(posted.len(), 1);
assert!(
store
.journal_format()
.has_feature(ledger_store::format::MONTH_REPOSTING)
);
let apr = store.post_schedule_month(id, day(2026, 4, 30)).unwrap();
store
.reverse_entry(apr, day(2026, 5, 2), "wrong policy")
.unwrap();
let head = store.head();
let posted = store.run_schedules(day(2026, 4, 30)).unwrap();
assert!(posted.is_empty(), "{posted:?}");
assert_eq!(store.head(), head);
let held = store.schedule_months_held(day(2026, 4, 30)).unwrap();
assert_eq!(held.len(), 1);
assert_eq!((held[0].0, held[0].1), (id, day(2026, 4, 30)));
assert!(held[0].2.contains("2026-05-02"), "{}", held[0].2);
store.verify().unwrap();
drop(store);
let mut store = Store::open(&dir).unwrap();
store.rebuild().unwrap();
store.verify().unwrap();
let s = store.ledger().get_schedule(id).unwrap();
assert!(s.posted.contains(&day(2026, 3, 31)));
assert!(!s.posted.contains(&day(2026, 4, 30)));
let snap = Snapshot::load(&dir).unwrap();
assert_eq!(
snap.ledger().differs_from(store.ledger()),
Vec::<&str>::new()
);
}
/// P3 review round 2, and the format-upgrade gate (D-39): in books an
/// older version wrote, every command that needs a journal feature is
/// refused (FormatUpgradePending) before its first journaled line, so the
/// refusal journals nothing: never a depreciation entry without the
/// `asset.depreciation_posted` that makes it the period's run, nor a
/// disposal's final depreciation without its line. Each command: a period
/// run again after its run was reversed (month_reposting), a disposal whose
/// month's run was reversed (its final depreciation, month_reposting), a
/// daily schedule (schedule_convention), and a schedule month posted again
/// (month_reposting). After the owner's upgrade each journals, and the
/// books verify.
#[test]
fn a_write_waiting_for_the_upgrade_journals_nothing() {
let dir = test_dir("feature-refused");
let accounts = schedule_books(&dir);
let mut store = Store::open(&dir).unwrap();
let add = |s: &mut Store, code: &str, t| s.add_account(code, code, t, None, false).unwrap();
let cost = add(&mut store, "1500", AccountType::Asset);
let accum = add(&mut store, "1501", AccountType::Asset);
let expense = add(&mut store, "6100", AccountType::Expense);
let gain = add(&mut store, "4900", AccountType::Revenue);
let cash = store.ledger().chart().find_by_code("1000").unwrap().id;
let usd = |a: i64| Money::new(Decimal::new(a, 2), Currency::USD);
let lathe = |s: &mut Store, what: &str| {
s.register_asset(ledger_core::NewAsset {
description: what.into(),
in_service_date: day(2026, 1, 1),
cost: usd(1_200_000),
salvage_value: usd(0),
useful_life_months: 12,
cost_account: cost,
accum_depr_account: accum,
expense_account: expense,
})
.unwrap()
};
// One asset's January run, reversed within January: due again.
let asset = lathe(&mut store, "Lathe");
let run = store.run_depreciation(asset, "2026-01").unwrap();
store
.reverse_entry(run.entry_id.unwrap(), day(2026, 1, 31), "doubted")
.unwrap();
// Another's January to March run, March reversed within March: a
// disposal on Mar 15 posts March's final depreciation again.
let sold = lathe(&mut store, "Old lathe");
for m in ["2026-01", "2026-02"] {
store.run_depreciation(sold, m).unwrap();
}
let mar = store.run_depreciation(sold, "2026-03").unwrap();
store
.reverse_entry(mar.entry_id.unwrap(), day(2026, 3, 31), "sold")
.unwrap();
let monthly = store
.register_schedule(insurance(
accounts,
day(2026, 1, 1),
ledger_core::ScheduleConvention::MonthlyEven,
))
.unwrap();
let jan = store
.post_schedule_month(monthly, day(2026, 1, 31))
.unwrap();
store
.reverse_entry(jan, day(2026, 1, 31), "doubted")
.unwrap();
// Books as an older version made them (format 1, books()): nothing
// declared, and everything above needs nothing new.
assert!(store.journal_format().features.is_empty());
let head = store.head();
let refused = |r: Result<(), StoreError>, what: &str| match r {
Err(StoreError::FormatUpgradePending { need }) => {
assert!(need.starts_with("the journal feature "), "{what}: {need}")
}
other => panic!("{what}: {other:?}"),
};
refused(
store.run_depreciation(asset, "2026-01").map(|_| ()),
"a period run again",
);
refused(
store
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
.map(|_| ()),
"a disposal's final depreciation",
);
refused(
store
.register_schedule(insurance(
accounts,
day(2026, 3, 15),
ledger_core::ScheduleConvention::Daily,
))
.map(|_| ()),
"a daily schedule",
);
refused(
store
.post_schedule_month(monthly, day(2026, 1, 31))
.map(|_| ()),
"a schedule month posted again",
);
assert_eq!(store.head(), head, "nothing journaled");
assert_eq!(store.ledger().next_entry_id(), {
let snap = Snapshot::load(&dir).unwrap();
snap.ledger().next_entry_id()
});
store.verify().unwrap();
assert!(store.upgrade_format().unwrap().is_some());
assert!(
store
.run_depreciation(asset, "2026-01")
.unwrap()
.entry_id
.is_some()
);
store
.post_schedule_month(monthly, day(2026, 1, 31))
.unwrap();
store
.dispose_asset(sold, day(2026, 3, 15), usd(900_000), cash, gain)
.unwrap();
assert_eq!(*store.journal_format(), ledger_store::format::current());
store.verify().unwrap();
}
/// The replay fuzz target (`fuzz_store_replay`: the writer and the viewer
/// agree on every journal) draws its lines from every event type this
/// version reads, P3's included (`asset.basis_set`, `party.creditor_set`
/// were missing: the fuzzer never wrote them, so it never checked the two
/// readers agree on them).
#[test]
fn the_replay_fuzzer_writes_every_event_type() {
let target = include_str!("../../../fuzz/fuzz_targets/fuzz_store_replay.rs");
let list = target
.split("const TYPES: &[&str] = &[")
.nth(1)
.and_then(|rest| rest.split("];").next())
.expect("the fuzz target's TYPES");
let missing: Vec<&str> = EVENT_TYPES
.iter()
.copied()
.filter(|t| *t != "store.created" && !list.contains(&format!("\"{t}\"")))
.collect();
assert!(
missing.is_empty(),
"the replay fuzzer never writes {missing:?}"
);
}