Files
wanderbooks/crates/ledger-mcp/tests/startup.rs
T
swaitsandClaude Opus 5.5 9f9f00ed9a Store: say "newer wanderbooks", not "corrupt", for books from a newer version
An unknown event type on a whole, chained line, or a journal.format_set
declaring a newer journal format, now raises StoreError::NewerJournal
(update wanderbooks) instead of JournalCorrupt. Corruption (bad hash,
seq, or JSON) is still corruption. The gate runs in the store, the
Snapshot and the GUI, after each line's parse, seq and hash checks, and
never skips a line it can't read. Adds the journal.format_set event
(format 1 today; nothing writes it yet) and a registry of every event
type this version reads, tested against the replay and projection arms.

Review fixes (P0 review, 2026-10-16):
- A line's own hash is checked only by the next line's prev, and the
  gate used to refuse a line before reading the next: a flipped bit in a
  middle line's type (or a format_set edited from 1 to 2) was reported
  as "newer, the books are not damaged". Now the reader settles every
  refusal against the next line (format::settle): a next line that
  doesn't chain makes it corrupt; one that chains makes it newer and
  verified; with no next line the message says "newer or damaged; update,
  then run verify", never "not damaged" (NewerJournal.verified).
- A newer format number is judged newer before its features are parsed:
  a newer format may shape them differently.
- Snapshot treats a blank complete line as corrupt, as the writer does.
- docs/spec/rules.md B-27, which the code cites, now exists.

Round-2 review fixes (2026-10-16):
- The envelope {seq, ts, type, payload, prev} and its hash (SHA-256 of
  the canonical JSON) are frozen across formats (rules.md B-27); new
  fields go in payload. settle reads only the next line's prev, leniently,
  so a newer format's next line (a float, a string seq, keys of its own)
  is never called corrupt: no readable prev means unverified newer.
- A key beside the envelope's was silently dropped (and the chain then
  broke as "corrupt"): it is now NewerJournal, hashed over the raw
  canonical JSON so the next line still checks it; JournalLine::parse
  refuses such a line.
- "the books are not damaged" overclaimed: a verified refusal says line N
  is intact and this version can't check the lines after it.
- The writer read the journal with BufRead::lines (invalid UTF-8 was an
  I/O error); it now splits bytes on newlines as the Snapshot does, so a
  non-UTF-8 line is JournalCorrupt in both.
- Store::open truncated a torn tail before the gate ran, altering newer
  books (perhaps mid-append) before refusing them. The gate now runs over
  the complete lines first; the tail is set aside (and journaled) only
  when they pass.
- Mutation testing (docs/testing.md, P0): the viewer and the writer say
  the same words about the same damaged line (the newline is no part of
  it); read_journal_and_format returns every line and the format in
  force; the journal page tells a format declaration.
Plan: plan/served.md P0 and 5.1.

Review round 3 (S6, S7). Store::open replays every complete line in
memory (the first line's currency, then the ledger) before a cut-off
last line is set aside, so a complete line the books can't apply
refuses the open with the journal byte for byte as it was and nothing
set aside (B-27 SCENARIO 3; test
a_bad_complete_line_before_a_torn_tail_leaves_the_journal_as_it_was);
catch_up now only projects. The viewer
applies the writer's first-line rule (created_currency, shared) instead
of guessing USD: any other first line, or a store.created without a
sound base_currency, is corrupt to both (test
the_first_line_is_store_created_for_the_viewer_and_the_writer). fuzz_store_replay varies the
first line (14,696 runs clean).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 00:32:42 -06:00

214 lines
7.2 KiB
Rust

//! Starting the server: arguments, where the books and settings are found,
//! and what happens when another writer has the books.
use std::io::{BufRead, BufReader, Write};
use std::process::{Command, Stdio};
use std::time::{Duration, Instant};
const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp");
fn temp(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).unwrap();
d
}
/// Start the server, initialize, call one tool, and return its text
/// result (or the error text), then end the session.
fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String {
let mut child = cmd
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let mut out = BufReader::new(child.stdout.take().unwrap());
let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap();
send(
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
);
let mut line = String::new();
out.read_line(&mut line).unwrap();
send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}));
send(
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}),
);
line.clear();
out.read_line(&mut line).unwrap();
drop(stdin);
let _ = child.wait();
line
}
#[test]
fn help_names_the_options() {
let out = Command::new(BIN).arg("--help").output().unwrap();
assert!(out.status.success());
let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr);
for opt in ["--data-dir", "--books-toml", "--config", "--http"] {
assert!(text.contains(opt), "--help lacks {opt}: {text}");
}
}
/// With no --data-dir the books live in ~/.local/share/wanderbooks.
#[test]
fn the_default_data_dir_is_under_home() {
let home = temp("home");
one_call(
Command::new(BIN).env("HOME", &home),
"books.chart",
serde_json::json!({}),
);
assert!(
home.join(".local/share/wanderbooks/journal.jsonl").exists(),
"no books under {}",
home.display()
);
}
/// wanderbooks.toml beside the data folder is read without --books-toml:
/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt,
/// which the default ($25.00) would post.
#[test]
fn the_settings_beside_the_books_are_read() {
let dir = temp("beside");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\nreceipt_threshold = \"0.00\"\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
let cash = store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let rent = store
.add_account(
"6000",
"Rent",
ledger_core::AccountType::Expense,
None,
false,
)
.unwrap();
let e = store
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x")
.unwrap();
let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD);
store
.add_line(e, rent, ledger_core::Side::Debit, m)
.unwrap();
store
.add_line(e, cash, ledger_core::Side::Credit, m)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.post_entry",
serde_json::json!({"entry_id": e, "confirmed": true}),
);
assert!(
reply.contains("receipt_required"),
"posted without a receipt: {reply}"
);
}
/// The cash flow roles in wanderbooks.toml beside the data folder are
/// read without --books-toml: its cash account lets cash_flow run with no
/// cash_accounts of its own.
#[test]
fn the_cash_roles_beside_the_books_are_read() {
let dir = temp("roles");
std::fs::write(
dir.join("wanderbooks.toml"),
"[books]\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
let data = dir.join("books-data");
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
store
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
drop(store);
let reply = one_call(
Command::new(BIN).arg("--data-dir").arg(&data),
"books.cash_flow",
serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}),
);
assert!(reply.contains("\"isError\":false"), "{reply}");
}
/// A writer that keeps the books is a conflict: the server gives up with
/// an error (after its ten-second wait), never hangs.
#[test]
fn a_held_lock_is_refused_not_waited_on_forever() {
let dir = temp("locked");
let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
let start = Instant::now();
let mut child = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::piped())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
.unwrap();
let status = loop {
if let Some(s) = child.try_wait().unwrap() {
break s;
}
if start.elapsed() > Duration::from_secs(60) {
let _ = child.kill();
panic!("the server waited on a held lock for a minute");
}
std::thread::sleep(Duration::from_millis(100));
};
assert!(!status.success());
assert!(
start.elapsed() >= Duration::from_secs(9),
"gave up before waiting"
);
}
/// Books written by a newer wanderbooks: the server won't start on them,
/// and says to update it, not that the books are corrupt (rules.md B-27).
#[test]
fn newer_books_are_refused_as_newer_not_corrupt() {
let dir = temp("newer");
drop(ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap());
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
w.append(
"journal.format_set",
serde_json::json!({"format": 2, "features": []}),
)
.unwrap();
// The next line chains onto it, so its bytes are checked (B-27).
w.append(
"fx.rate_set",
serde_json::json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
)
.unwrap();
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
let out = Command::new(BIN)
.arg("--data-dir")
.arg(&dir)
.stdin(Stdio::null())
.output()
.unwrap();
assert!(!out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("written by a newer version of wanderbooks"),
"{err}"
);
assert!(err.contains("Update wanderbooks"), "{err}");
assert!(!err.contains("corrupt"), "{err}");
assert_eq!(
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
journal
);
}