Files
wanderbooks/crates/ledger-mcp/tests/spec_mcp.rs
T
swaitsandClaude Opus 5.5 b22a034746 MCP: short connect text, a handbook, tool annotations, safe local imports
- The 13K-character instructions (clients cut them short) become a
  connect text of at most 1,400 characters whose first 512 stand alone,
  plus books.handbook and books://handbook/{section}: the old guidance by
  section, with no helper commands, home paths, pull requests or
  file-reading suggestions (tool_text_lint).
- Every tool has a title and read-only/destructive hints; no
  requiresUserInteraction anywhere (owner decision 2026-10-16).
- books.import_local_file reads only regular files inside the import
  roots (the downloads folder by default, or --import-root), never the
  data dir, books dir, vault or the key's folder, no symlink escapes or
  hard links, and the file opened must be the file checked.
- The store lock is a tokio mutex taken off the runtime threads.
- get_info refreshes settings first; a bare books://digest reads.

Review fixes (P0 review, 2026-10-16):
- Fail closed: a tokio mutex never poisons and spawn_blocking caught the
  panic, so after a panic mid-mutation the server kept serving a Store
  behind its journal. A panic (the task, or while holding the store)
  now stops the shared store; every later request on every session is
  refused with a plain message; a restart re-verifies the journal. The
  join error goes through redact_refusal and never carries the panic's
  text.
- books.set_w9 w9_file_path read any path (a link named w9.pdf to the
  vault key was stored and get_w9 wrote it out): it now goes through the
  import roots' lockdown, its type judged on the canonical path.
- Sealed even inside a root: the key file in force, where a linked one
  leads, both folders, and the default key folder whatever the settings
  say. Refusals check the roots first, so outside them every path gets
  one message (no probing what exists); inside, other books-dir files say
  to copy them out first.
- The open is O_NOFOLLOW | O_NONBLOCK (a pipe swapped in after the check
  can't hang a thread) and must match the checked file's device and
  inode. New dependency: libc (unix only), for those two open flags,
  which std doesn't name.
- Default import roots are the downloads folder only (the plan); files
  with full TINs (get_w9, vendor_1099 include_tins, the CPA package with
  TINs) are never written into an import root, and the server refuses to
  import any file it wrote with TINs (by SHA-256).
- set_tin and set_w9 overwrite vault records: destructive_hint = true;
  annotations_complete pins the key tools' hints. The read-only hint is
  checked in behaviour on every spec_mcp call (journal and data-dir files
  unchanged).
- Text: the connect text names the settings file as the one local file
  to edit, says every entry balances on real accounts and names its
  party, says invoice/bill/payment/payroll tools post unchecked, rewords
  the exemptions, and shows the company name as one plain line; the
  handbook takes section "all", lists its ids in the schema and says to
  read contract first; books.settings drops the config.json path and no
  error names the data dir or the key file. tool_text_lint covers the
  whole tool JSON and resources and bans git/commit/WL-0001; tool
  descriptions over 700 characters are pinned to today's eight (P5).
- digest: an empty since= is bare; the default date is pinned on every
  day of a month.

Round-2 review fixes (2026-10-16):
- TIN files (B-26): the in-memory, per-session set of TIN-file hashes
  became one registry beside the vault (vault/tin-files.sha256, hashes
  only), shared by every session and every later server. It holds the
  W-9 set_w9 reads (recorded as read), get_w9 copies, 1099 worksheets
  with TINs, CPA packages with TINs and each file in one that holds a
  TIN; a file is recorded before it is written, and a failure refuses.
  import_local_file, attach_receipt and file_document (base64 was never
  checked) refuse a recorded file, and a text file (by type, name, or
  UTF-8/UTF-16 decoding) holding a full TIN. Not caught: an altered
  binary copy; the docs say so (P2/P7 remove the path tools).
- The text check is narrow (owner/engineer decision, 2026-10-16: bank
  exports are filed for reconciliation, and has_full_tin's "nine digits
  however separated" refused their routing numbers, long references and
  dates run into amounts). Text is refused when it holds (a) a TIN the
  vault holds for a party, anywhere, as nine digits, 3-2-4 joined by
  hyphens, spaces or dots, or 2-7 joined by a hyphen or space, compared
  on the server against the decrypted vault and never returned or
  logged (a vault that can't be opened leaves (b)); or (b) a stand-alone
  printed SSN (123-45-6789) or EIN (12-3456789) shape: no digit, and no
  hyphen or dot followed by a digit, beside it. Full-width digits and
  Unicode hyphens and spaces read as ASCII. has_full_tin still guards
  refusals and results (redact_refusal). Known limit, for P7: a refusal
  under (a) tells the agent a text it chose holds a vault TIN.
  New dependency: zeroize in ledger-mcp (already in the workspace
  through ledger-vault, same major version), so the vault's TINs decoded
  for the scan are wiped from memory on drop as the vault's own are.
- Mutation testing of the final P0 diff (docs/testing.md): tests for
  each law a survivor broke (handbook titles, a dropped default root, a
  .jpg W-9, the waker, the walk's folder flags against a link and a
  pipe, the 40-link limit, save paths refused as such, the package
  marking only its TIN files, a registry that can't be read, text types,
  the short receipt rule's counts and a 12-character code). Simpler
  code where only a race could tell: inspect places an existing path by
  canonicalize itself (no second comparison), read_checked's read limit
  is its one size check, openat fails on -1 as specified. The 25 MiB
  upload test removes its 50 MB of books however it ends.
- The GUI's stale-rule prompt no longer says to open a pull request: it
  asks the agent to find the figure from the rule's official authority,
  confirm it with the human, pass it explicitly, and tell the human so it
  can be reported upstream (as the handbook says); a lib test pins it and
  bans files, git, GitHub and pull requests from every attention prompt.
- Import roots (local_files.rs INVARIANT 0): XDG_DOWNLOAD_DIR="$HOME/"
  made $HOME (or /) the root. No root may be /, the home folder or hold
  it, or hold the books, data dir, vault or a key: a named one stops the
  server at startup, a default one is dropped; the default falls back to
  ~/Downloads only if that is a real folder other than $HOME; with no
  root, an import says so.
- The read is a component walk from / with openat(O_NOFOLLOW |
  O_DIRECTORY) per folder and O_NOFOLLOW | O_NONBLOCK for the file, so a
  folder swapped for a link after the check fails; no step may land on a
  protected inode (one renamed into a root); dev/ino and nlink stay. No
  /proc. Portable to macOS. (libc's openat, two small unsafe blocks.)
- One refusal for missing, dangling and outside paths: resolved()
  follows every link, dangling ones too, so a link's target existing or
  not no longer changes the answer.
- Sealing: the key files (in force, default) and their targets always;
  their folders only when not $HOME, not a root, not holding a root; and
  ~/.config/wanderbooks whatever XDG_CONFIG_HOME says.
- Agent text (I-12): RuleNotKnown names the authority (IRS, with its
  address) and says to tell the human, never a repo path, PR or GitHub;
  the digest's stale_rules drop the repo catalog and path for it; vault,
  outside-books, STOPPED and local-file refusals name no git, path or
  flag. tool_text_lint bans docs/, .md, /home, data dir, --data-dir,
  --import-root, vault.key, .jsonl, sqlite, .gitignore, github, pull
  request, and also lints every error template (scanned from source),
  built error texts, and the server's refusal constants.
- The receipt rule told (ReceiptPolicy::rule) is the one enforced: the
  exact threshold in the base currency (no "$", no rounding), required
  and exempt accounts, reversals exempt; the connect text fits 1,400
  characters by dropping reasons, then codes, never the rule.
- The connect text follows the parties setting; "offer books.digest"
  everywhere. shown_name drops Unicode format characters (bidi, zero
  width).
- read_only_tools_change_nothing compares file contents, the database
  by every row, the vault and the settings file (raw database bytes did
  not change either; rows are the stable comparison).
- import_local_file takes the name and type from the canonical path;
  the digest's journal repair says when the tail was set aside, not the
  journal's file name; "version-history messages" back in the TIN rule;
  openWorldHint false on every tool, pinned in annotations_complete.
- Outside the MCP crate, for the same texts: ledger-core's DatedRule
  gains `authority` (the statute or publication with its address) and
  RuleNotKnown carries it; ledger-store's "sqlite error" reads "database
  error"; ledger-config's ReceiptPolicy::rule takes the base currency.
  New dev-dependency: rusqlite in ledger-mcp (already in the workspace,
  same version and features as ledger-store), to read the database's
  rows in read_only_tools_change_nothing.
- The filing scan is no TIN oracle (tin_files.rs INVARIANT 4): text
  holding more than two TIN-shaped numbers (any rendering rule (a)
  reads) that end in a known TIN's last four (the books' TIN and W-9
  facts, via known_tin_last4, before the vault is opened; then the
  vault's own) is refused with TIN_PROBE, the same words whether or not
  one is the vault's TIN; only one or two candidates are compared
  against the vault. Tests: unit
  text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it,
  one_or_two_candidates_are_compared_against_the_vault; over HTTP
  text_probing_for_a_tin_is_refused_alike_by_every_filing_tool.

Plan: plan/served.md P0, section 6 "Immediate hole (P0)", 4.1, 4.3, 4.9.

Review round 3. S1: a panic stops the server for every session, so no
input may reach one. Dates are four-digit years (new ledger_core::
calendar, 0001-01-01..9999-12-31): the MCP's parse_date is strict
YYYY-MM-DD, periods are bounded, last_day_of_month returns a refusal
(server and store); schedules, assets, recurring entries, periods,
documents, disposals and entries refuse dates outside the calendar, and
replay refuses such journal dates. Every amount a command takes is at
most a line's (draft lines by size, document lines, payouts, payroll,
statements, shares, tax basis, proceeds, recurring templates), on
replay too, so sums are exact; the 1099 vendor part and the K-1
property share use checked products (TooLargeToCompute); a recurring
run makes at most 1,000 drafts. tests/hostile_args.rs drives every tool
with schema-derived hostile arguments and every resource with generated
URIs, asserting the server never stops; the audit's panics are pinned.
S2: a `.` or `..` path name is refused as NO_SUCH_FILE up front. S3:
NUL-interleaved UTF-16 without a byte-order mark is scanned both ways.
S4: resource-read refusals are TIN-redacted. S5: set_w9 records its W-9
only once the call passes every check, just before the vault write.
S8: idempotentHint only on verify, run_depreciation, run_schedules,
run_recurring, set_party_roles (a repeat leaves journal and vault
alone; spec_vault checks every candidate twice); every tool's hints
pinned in one table. C1: books.settings lists receipt_required_accounts
and receipt_exempt_accounts uncut. C2: the default company name reads
"a new company". C3: the parties rule names no-party reasons. C4/C5:
the handbook names the settings file only as books.settings' books_toml,
and restores "check books.digest and offer it". C6: an import root
inside the books, data dir or vault is refused at startup. The lint
catches `just` however it ends and where the books are;
digest_resource_bare_reads reads the server's month.

Review round 4 (2026-10-17): round 3's bounds ran in replay too, so
journals main wrote and reopened (an entry dated 0000-06-01 or
+10000-01-01, a draft line of 2e15, an asset ending past 9999, a basis
or share past the line limit, sub-cent document and payroll lines) no
longer opened. Posted is forever: the bounds are the commands' only.
- Replay applies main's rule exactly: parse_date takes any date chrono
  reads; replay_add_line(_fx) has no size bound (add_line/add_line_fx
  do); replay_register_asset/correct_asset (chrono overflow only),
  replay_set_asset_tax_basis (check_journaled), replay_set_entry_parties
  (check_shares_journaled), replay_register_recurring
  (validate_recurring_journaled), replay_register_schedule (months 1 to
  600), replay_create_period (order only), replay_dispose_asset, and no
  line, date, payment or payroll range checks in replay_invoice/bill,
  their payments and replay_payroll_run.
- Derived figures over such books refuse (TooLargeToCompute,
  DateOutOfRange), never panic: amortization plans, depreciation into
  chrono's last month (and the true-up for lives past 9999), intervals,
  document totals and aging, net pay and payroll plans, money flows and
  share resolution, the asset register, receipt-policy debits; the MCP
  tools and the viewer show what can't be computed.
- tests/fixtures/old-books: the review's 13 journals, its control and 15
  more edges, written by main's Store API with main's figures
  (expected.json); every one opens in the writer and the viewer to those
  figures, every derived figure computes or refuses, every MCP tool and
  GUI page answers; hostile_args restarts the server after each
  property; each bound has a command/replay test pair.

Final review: a schedule's plan refuses a total of 10^26 or more
(TooLargeToCompute) instead of letting Decimal round its cents silently,
and a null `remaining` in the schedule output is always explained by
plan_error. Replay is unaffected: no journaled schedule month can belong
to a total that large (the line limit bounds every posted month).

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

2500 lines
88 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Spec-blind tests for `ledger-mcp`, driven over stdio against the real
//! binary.
//!
//! Expected behavior comes only from the GOAL/SOURCE/INVARIANTS blocks of
//! `src/server.rs` and `src/policy.rs`, the server's connect text and handbook,
//! `docs/mcp-interface.md`, `docs/spec/sources/{mcp-tools,jsonrpc-2.0,
//! irs-records}.md`, and the server's own `tools/list` (the declared
//! interface: names, input and output schemas). Where those are silent the
//! tests don't assert.
//!
//! Every tool call goes through [`Mcp::tool`], which checks, whatever the
//! test: one response with the request's id, result xor error (JSON-RPC
//! 2.0 §5); a refused call (isError or a JSON-RPC error) leaves
//! `journal.jsonl` byte-identical (server invariant 2); a success has
//! `structuredContent` that validates against the tool's `outputSchema`
//! and the same JSON in a text block (invariant 1); no money key holds
//! a JSON number or a string outside `^-?\d+\.\d{2,}$` (invariant 3);
//! and a tool annotated readOnlyHint leaves the journal and the set of
//! files under the data dir as they were, success or refusal (plan 4.9,
//! "Annotations (P0)": the hint is true in behaviour).
use proptest::prelude::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
use std::time::Duration;
/// How long any single response may take before the server counts as hung.
/// Long enough for a server's first answer while a mutation run's jobs
/// contend for the disk (every journal append is synced).
const TIMEOUT: Duration = Duration::from_secs(180);
/// Random cases for the state machine: 64 on every check; `MCP_CASES`
/// sets fewer for a mutation run, more for a deeper one.
fn cases() -> u32 {
std::env::var("MCP_CASES")
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(64)
}
/// Keys whose values are money. Derived from the `outputSchema`s: every
/// string property described as an amount or "decimal string", plus the
/// payout totals (`gross`, `fees`, `adjustments`, `deposit`), the
/// reconciliation `difference`, and the doc's `debits`/`credits`/
/// `before`/`after`. Arrays and objects under these keys (e.g. `assets`
/// in `list_assets`) are walked, not checked.
const MONEY_KEYS: &[&str] = &[
"accumulated",
"adjustments",
"after",
"amount",
"amount_1099",
"assets",
"balance",
"balance_after",
"balance_before",
"before",
"billed",
"book_balance",
"book_value",
"cash_beginning",
"cash_ending",
"cash_from_financing",
"cash_from_investing",
"cash_from_operations",
"cleared_balance",
"cost",
"credit",
"credits",
"current",
"current_earnings",
"days_30",
"days_60",
"days_90",
"days_90_plus",
"debit",
"debits",
"deposit",
"depreciation",
"difference",
"equity",
"expenses",
"fees",
"final_depreciation",
"gain_loss",
"gross",
"gross_pay",
"invoiced",
"liabilities",
"movement",
"net_change_in_cash",
"net_income",
"net_pay",
"owed",
"paid",
"proceeds",
"received",
"receivable",
"remaining",
"revenue",
"salvage_value",
"statement_balance",
"threshold",
"total",
"total_credits",
"total_debits",
"total_gross",
"total_net",
];
// ---------------------------------------------------------------- harness
/// What a `tools/call` came back as.
#[derive(Debug, Clone)]
enum Out {
/// Success: the `structuredContent`.
Ok(Value),
/// `isError: true`: the first text block.
ToolErr(String),
/// A JSON-RPC error object.
RpcErr(Value),
}
impl Out {
fn ok(self, what: &str) -> Value {
match self {
Out::Ok(v) => v,
other => panic!("{what}: expected success, got {other:?}"),
}
}
fn refused(&self) -> bool {
!matches!(self, Out::Ok(_))
}
fn err_text(&self) -> String {
match self {
Out::Ok(v) => panic!("expected a refusal, got {v}"),
Out::ToolErr(t) => t.clone(),
Out::RpcErr(e) => e.to_string(),
}
}
}
struct Mcp {
stdin: ChildStdin,
rx: Receiver<String>,
child: Child,
next_id: u64,
data: PathBuf,
schemas: HashMap<String, Value>,
validators: HashMap<String, jsonschema::Validator>,
/// Tools that returned a success at least once.
exercised: BTreeSet<String>,
/// readOnlyHint tools that succeeded and were seen to change nothing.
read_only_checked: BTreeSet<String>,
/// Invariant 3 (amounts out) violations: `tool: path = value`.
money_violations: Vec<String>,
}
impl Drop for Mcp {
fn drop(&mut self) {
let _ = self.child.kill();
let _ = self.child.wait();
}
}
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
/// A fresh, empty per-test directory under the system temp dir.
fn test_dir(name: &str) -> PathBuf {
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
let dir =
std::env::temp_dir().join(format!("ledger-mcp-spec-{}-{name}-{n}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// A directory beside the books dir `dir`, outside it (vault keys, files
/// with TINs).
/// The import root beside the books dir `dir`: files the tests hand the
/// server by path (books.import_local_file, books.set_w9 w9_file_path).
fn inbox(dir: &Path) -> PathBuf {
let d = dir.with_extension("inbox");
std::fs::create_dir_all(&d).unwrap();
d
}
fn outside(dir: &Path) -> PathBuf {
let d = PathBuf::from(format!("{}-outside", dir.display()));
std::fs::create_dir_all(&d).unwrap();
d
}
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
/// TOML (appended under `[books]` unless it opens its own table).
fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
let path = dir.join("wanderbooks.toml");
std::fs::write(
&path,
format!(
"[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{extra}"
),
)
.unwrap();
path
}
fn spawn_child(dir: &Path, toml: &Path) -> Child {
let run = dir.join("run");
std::fs::create_dir_all(&run).unwrap();
Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
.arg("--data-dir")
.arg(dir.join("books-data"))
.arg("--books-toml")
.arg(toml)
// Local files come only from here (the default is the downloads
// folder): never the books, never where the server writes.
.arg("--import-root")
.arg(inbox(dir))
// books.show writes a viewer request here; keep it out of the
// user's real runtime dir.
.env("XDG_RUNTIME_DIR", &run)
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
// the user's real one (the happy path names its own anyway).
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.expect("spawn ledger-mcp")
}
impl Mcp {
/// Spawn on `dir` with `toml`, initialize, and load `tools/list`.
fn start(dir: &Path, toml: &Path) -> Self {
let mut child = spawn_child(dir, toml);
let stdin = child.stdin.take().unwrap();
let stdout = BufReader::new(child.stdout.take().unwrap());
let (tx, rx) = channel();
std::thread::spawn(move || {
for line in stdout.lines() {
let Ok(line) = line else { break };
if tx.send(line).is_err() {
break;
}
}
});
let mut mcp = Self {
stdin,
rx,
child,
next_id: 1,
data: dir.join("books-data"),
schemas: HashMap::new(),
validators: HashMap::new(),
exercised: BTreeSet::new(),
read_only_checked: BTreeSet::new(),
money_violations: Vec::new(),
};
let init = mcp.rpc(
"initialize",
json!({
"protocolVersion": "2025-06-18",
"capabilities": {},
"clientInfo": {"name": "spec-test", "version": "0.1.0"},
}),
);
assert!(init.get("result").is_some(), "initialize failed: {init}");
mcp.send_line(
&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(),
);
let list = mcp.rpc("tools/list", json!({}));
for tool in list["result"]["tools"].as_array().expect("tools") {
let name = tool["name"].as_str().unwrap().to_string();
let schema = tool["outputSchema"].clone();
assert!(schema.is_object(), "{name} declares no outputSchema");
let validator = jsonschema::validator_for(&schema)
.unwrap_or_else(|e| panic!("{name}'s outputSchema is not a valid schema: {e}"));
mcp.validators.insert(name.clone(), validator);
mcp.schemas.insert(name, tool.clone());
}
mcp
}
fn journal(&self) -> Vec<u8> {
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
}
/// What a read-only tool must leave as it was: every file under the
/// data dir, the books dir's vault/ and its settings file, by path,
/// each with the SHA-256 of its contents. The database is compared by
/// what it holds (every row of every table, in order: SQLite moves
/// pages between the file and its write-ahead log on its own, and a
/// reader writes its read marks into the shared-memory file), so the
/// -wal and -shm files are left out.
fn data_files(&self) -> BTreeMap<PathBuf, String> {
fn walk(dir: &Path, out: &mut BTreeMap<PathBuf, String>) {
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
let p = e.path();
let name = p.file_name().unwrap().to_string_lossy().into_owned();
if p.is_dir() {
walk(&p, out);
} else if name.ends_with("-wal") || name.ends_with("-shm") {
continue;
} else if name == ledger_store::DB_FILE {
out.insert(p.clone(), db_digest(&p));
} else {
let bytes = std::fs::read(&p).unwrap_or_default();
out.insert(p, ledger_store::journal::sha256_hex(&bytes));
}
}
}
let mut out = BTreeMap::new();
walk(&self.data, &mut out);
let books = self.data.parent().unwrap();
walk(&books.join("vault"), &mut out);
let toml = books.join("wanderbooks.toml");
out.insert(
toml.clone(),
ledger_store::journal::sha256_hex(&std::fs::read(&toml).unwrap_or_default()),
);
out
}
fn read_only(&self, name: &str) -> bool {
self.schemas
.get(name)
.is_some_and(|t| t["annotations"]["readOnlyHint"] == json!(true))
}
fn send_line(&mut self, line: &str) {
writeln!(self.stdin, "{line}").unwrap();
self.stdin.flush().unwrap();
}
/// The next JSON-RPC response (messages without an id, i.e. server
/// notifications, are skipped). `None` on timeout or EOF.
fn recv(&mut self, timeout: Duration) -> Option<Value> {
loop {
match self.rx.recv_timeout(timeout) {
Ok(line) => {
let v: Value = serde_json::from_str(&line)
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
if v.get("id").is_none() && v.get("method").is_some() {
continue;
}
return Some(v);
}
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
}
}
}
/// Send a request and return its response, checking JSON-RPC 2.0 §5:
/// one response, same id, `jsonrpc: "2.0"`, result xor error.
fn rpc(&mut self, method: &str, params: Value) -> Value {
let id = self.next_id;
self.next_id += 1;
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
self.send_line(&msg.to_string());
let resp = self
.recv(TIMEOUT)
.unwrap_or_else(|| panic!("no response to {method} (id {id}): server hung or died"));
check_response_shape(&resp);
assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}");
resp
}
/// Call a tool and check invariants 1, 2, 3 and 7 on the outcome.
fn tool(&mut self, name: &str, args: Value) -> Out {
let before = self.journal();
let files = self.read_only(name).then(|| self.data_files());
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
let out = if let Some(err) = resp.get("error") {
Out::RpcErr(err.clone())
} else {
let result = &resp["result"];
if result.get("isError") == Some(&json!(true)) {
let text = result["content"]
.as_array()
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
.unwrap_or_default()
.to_string();
Out::ToolErr(text)
} else {
Out::Ok(self.check_success(name, result))
}
};
if out.refused() {
assert!(
self.journal() == before,
"invariant 2: refused {name} {args} changed the journal ({out:?})"
);
}
if let Some(files) = files {
assert!(
self.journal() == before,
"readOnlyHint: {name} {args} changed the journal"
);
assert_eq!(
self.data_files(),
files,
"readOnlyHint: {name} {args} changed the files of the books"
);
if !out.refused() {
self.read_only_checked.insert(name.to_string());
}
}
out
}
/// Invariant 1 (schema + text block) and invariant 3 (amounts out).
fn check_success(&mut self, name: &str, result: &Value) -> Value {
let sc = result
.get("structuredContent")
.unwrap_or_else(|| panic!("{name}: success without structuredContent: {result}"))
.clone();
let validator = self
.validators
.get(name)
.unwrap_or_else(|| panic!("{name} is not in tools/list"));
let errors: Vec<String> = validator
.iter_errors(&sc)
.map(|e| format!("{} at {}", e, e.instance_path()))
.collect();
assert!(
errors.is_empty(),
"invariant 1: {name} result violates its outputSchema: {errors:?}\n{sc}"
);
let same_text = result["content"].as_array().is_some_and(|blocks| {
blocks.iter().any(|b| {
b["type"] == "text"
&& b["text"]
.as_str()
.and_then(|t| serde_json::from_str::<Value>(t).ok())
.is_some_and(|v| v == sc)
})
});
assert!(
same_text,
"invariant 1: {name} has no text block holding its structuredContent"
);
let mut bad = Vec::new();
money_walk(&sc, "$", None, &mut bad);
for b in bad {
self.money_violations.push(format!("{name}: {b}"));
}
self.exercised.insert(name.to_string());
sc
}
/// `tool`, expecting success.
fn ok(&mut self, name: &str, args: Value) -> Value {
let a = args.to_string();
self.tool(name, args).ok(&format!("{name} {a}"))
}
/// `tool`, expecting a refusal (isError or JSON-RPC error).
fn refuse(&mut self, name: &str, args: Value) -> String {
let a = args.to_string();
let out = self.tool(name, args);
assert!(out.refused(), "{name} {a} should be refused, got {out:?}");
out.err_text()
}
/// Add accounts `(code, name, type)`; returns their ids.
fn accounts(&mut self, accts: &[(&str, &str, &str)]) -> Vec<u64> {
accts
.iter()
.map(|(code, name, ty)| {
self.ok(
"books.add_account",
json!({"code": code, "name": name, "account_type": ty}),
)["account_id"]
.as_u64()
.unwrap()
})
.collect()
}
/// Draft an entry with `(account, side, amount)` lines; returns its id.
fn draft(&mut self, date: &str, desc: &str, lines: &[(&str, &str, &str)]) -> u64 {
let id = self.ok(
"books.draft_entry",
json!({"date": date, "description": desc}),
)["entry_id"]
.as_u64()
.unwrap();
for (account, side, amount) in lines {
self.ok(
"books.add_line",
json!({"entry_id": id, "account": account, "side": side, "amount": amount}),
);
}
id
}
fn attach(&mut self, entry: Option<u64>, bytes: &[u8]) -> u64 {
let mut args = json!({
"file_name": "receipt.txt",
"mime_type": "text/plain",
"content_base64": b64(bytes),
});
if let Some(e) = entry {
args["entry_id"] = json!(e);
}
self.ok("books.attach_receipt", args)["attachment_id"]
.as_u64()
.unwrap()
}
fn post(&mut self, entry: u64) -> Out {
self.tool(
"books.post_entry",
json!({"entry_id": entry, "confirmed": true}),
)
}
fn entry_status(&mut self, entry: u64) -> String {
self.ok("books.get_entry", json!({"entry_id": entry}))["status"]
.as_str()
.unwrap()
.to_string()
}
}
fn check_response_shape(resp: &Value) {
assert_eq!(
resp["jsonrpc"], "2.0",
"not a JSON-RPC 2.0 response: {resp}"
);
let has_result = resp.get("result").is_some();
let has_error = resp.get("error").is_some();
assert!(
has_result ^ has_error,
"response must have exactly one of result/error: {resp}"
);
assert!(resp.get("id").is_some(), "response without id: {resp}");
if has_error {
assert!(
resp["error"]["code"].is_i64(),
"error code must be an integer: {resp}"
);
}
}
/// Invariant 3, amounts out: under a money key, no JSON number, and every
/// string matches `^-?\d+\.\d{2,}$`.
fn money_walk(v: &Value, path: &str, key: Option<&str>, bad: &mut Vec<String>) {
let money = key.is_some_and(|k| MONEY_KEYS.contains(&k));
match v {
Value::Number(_) if money => bad.push(format!("{path} = {v} (JSON number)")),
Value::String(s) if money && !is_money_out(s) => bad.push(format!("{path} = {s:?}")),
Value::Object(m) => {
for (k, child) in m {
money_walk(child, &format!("{path}.{k}"), Some(k), bad);
}
}
Value::Array(a) => {
for (i, child) in a.iter().enumerate() {
money_walk(child, &format!("{path}[{i}]"), key, bad);
}
}
_ => {}
}
}
/// `^-?\d+\.\d{2,}$`
fn is_money_out(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
let Some((int, frac)) = s.split_once('.') else {
return false;
};
!int.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.len() >= 2
&& frac.bytes().all(|b| b.is_ascii_digit())
}
/// The amount-in grammar, server invariant 3: `-?digits[.digits]`.
fn is_amount_in(s: &str) -> bool {
let s = s.strip_prefix('-').unwrap_or(s);
let (int, frac) = match s.split_once('.') {
Some((i, f)) => (i, Some(f)),
None => (s, None),
};
!int.is_empty()
&& int.bytes().all(|b| b.is_ascii_digit())
&& frac.is_none_or(|f| !f.is_empty() && f.bytes().all(|b| b.is_ascii_digit()))
}
/// Canonical form of a grammatical decimal: no leading zeros, no trailing
/// fractional zeros. Compares two decimal strings by value.
fn canon(s: &str) -> String {
let (neg, s) = match s.strip_prefix('-') {
Some(r) => (true, r),
None => (false, s),
};
let (int, frac) = s.split_once('.').unwrap_or((s, ""));
let int = int.trim_start_matches('0');
let frac = frac.trim_end_matches('0');
let int = if int.is_empty() { "0" } else { int };
let body = if frac.is_empty() {
int.to_string()
} else {
format!("{int}.{frac}")
};
if neg && body != "0" {
format!("-{body}")
} else {
body
}
}
fn b64(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = (u32::from(chunk[0]) << 16)
| (u32::from(*chunk.get(1).unwrap_or(&0)) << 8)
| u32::from(*chunk.get(2).unwrap_or(&0));
for i in 0..4 {
if i <= chunk.len() {
out.push(T[((n >> (18 - 6 * i)) & 63) as usize] as char);
} else {
out.push('=');
}
}
}
out
}
/// A chart used by several tests.
const BASE_CHART: &[(&str, &str, &str)] = &[
("1000", "Bank", "asset"),
("1200", "Accounts Receivable", "asset"),
("2000", "Accounts Payable", "liability"),
("2100", "Due to Shareholders", "liability"),
("3000", "Capital A", "equity"),
("3001", "Capital B", "equity"),
("4000", "Revenue", "revenue"),
("6000", "Rent", "expense"),
("6999", "General Expenses", "expense"),
];
// ------------------------------------------------- invariant 1: schemas
/// Every tool, read-only and mutating, on a scripted happy path; every
/// success validated by `Mcp::tool`. Returns the server for inspection.
fn happy_path(name: &str) -> Mcp {
let dir = test_dir(name);
let toml = books_toml(
&dir,
"1000000.00",
&format!(
"financing_accounts = [\"2100\"]\n\
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
[vault]\nkey_file = \"{}\"\n",
outside(&dir).join("vault.key").display()
),
);
let mut m = Mcp::start(&dir, &toml);
// Empty books: every read-only tool.
m.ok("books.chart", json!({}));
m.ok(
"books.chart_template",
json!({"entity_type": "partnership", "owners": ["A", "B"]}),
);
let cats = m.ok("books.tax_categories", json!({}));
m.ok("books.trial_balance", json!({}));
m.ok("books.balance_sheet", json!({}));
m.ok("books.profit_and_loss", json!({}));
m.ok("books.list_assets", json!({}));
m.ok("books.list_classes", json!({}));
m.ok("books.list_payroll_runs", json!({}));
m.ok("books.list_periods", json!({}));
m.ok("books.list_receipts", json!({}));
m.ok("books.list_recurring", json!({}));
m.ok("books.list_schedules", json!({}));
m.ok("books.list_vendors", json!({}));
m.ok("books.parties", json!({}));
m.ok("books.digest", json!({"since": "2026-01-01"}));
// Chart.
m.accounts(BASE_CHART);
m.accounts(&[
("1300", "Prepaid Expenses", "asset"),
("1500", "Equipment", "asset"),
("1510", "Accumulated Depreciation", "asset"),
("2400", "Deferred Revenue", "liability"),
("3999", "Retained Earnings", "equity"),
("6010", "Platform Fees", "expense"),
("6500", "Depreciation Expense", "expense"),
]);
m.ok(
"books.import_chart_csv",
json!({"csv_text": "code,name,type,parent_code\n7000,Gain or Loss on Disposal,expense,\n"}),
);
m.ok(
"books.renumber_account",
json!({"account": "7000", "code": "7100"}),
);
m.ok("books.settings", json!({}));
m.ok("books.roles", json!({}));
m.ok(
"books.assign_role",
json!({"role": "general_expense", "account": "7100"}),
);
m.ok(
"books.assign_role",
json!({"role": "general_expense", "account": null}),
);
let deduction = cats["categories"]
.as_array()
.unwrap()
.iter()
.find(|c| c["kind"] == "deduction")
.expect("a deduction category")["key"]
.clone();
m.ok(
"books.set_tax_category",
json!({"account": "6000", "category": deduction}),
);
// Classes, parties, 1099.
m.ok("books.add_class", json!({"name": "Rondo"}));
m.ok("books.list_classes", json!({}));
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"]
.as_u64()
.unwrap();
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}),
);
m.ok("books.list_vendors", json!({}));
m.ok(
"books.add_party",
json!({"name": "Pat", "roles": ["employee"]}),
);
m.ok(
"books.update_party",
json!({"party": "Pat", "display_name": "Pat D.", "notes": "first hire"}),
);
m.ok(
"books.set_w4",
json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}),
);
m.ok(
"books.set_tin",
json!({"party": "Pat", "tin": "571-93-2846", "tin_type": "ssn"}),
);
m.ok(
"books.set_party_roles",
json!({"party": "Landlord", "add": ["landlord"]}),
);
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin_last4": "4321", "kept_at": "CPA portal"}),
);
let w9_pdf = inbox(&dir).join("landlord-w9.pdf");
std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 12-3454321").unwrap();
m.ok(
"books.set_w9",
json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)",
"tin": "12-3454321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}),
);
m.ok(
"books.get_w9",
json!({"party": "Landlord",
"save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}),
);
m.ok("books.parties", json!({"role": "landlord", "year": 2026}));
// Owner contribution: draft, lines, receipt, preview, post.
let e1 = m.draft(
"2026-01-02",
"Owner contribution",
&[
("1000", "debit", "10000.00"),
("3000", "credit", "5000.00"),
("3001", "credit", "5000.00"),
],
);
m.attach(Some(e1), b"contribution receipt");
m.ok("books.post_entry", json!({"entry_id": e1}));
m.ok(
"books.post_entry",
json!({"entry_id": e1, "confirmed": true, "expected_total": "10000.00"}),
);
m.ok("books.get_entry", json!({"entry_id": e1}));
// Rent: vendor, class, unlinked receipt then link, clearing.
let e2 = m.ok(
"books.draft_entry",
json!({"date": "2026-01-15", "description": "January rent", "vendor": "Landlord"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": e2, "account": "6000", "side": "debit", "amount": "100.00", "class": "Rondo"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e2, "account": "1000", "side": "credit", "amount": "100.00"}),
);
let r_old = m.attach(None, b"rent receipt v1");
m.ok(
"books.link_receipt",
json!({"attachment_id": r_old, "entry_id": e2}),
);
m.post(e2).ok("post rent");
m.ok(
"books.mark_cleared",
json!({"entry_id": e2, "line_index": 1, "cleared": true}),
);
m.ok(
"books.set_entry_vendor",
json!({"entry_id": e2, "vendor": vendor.to_string()}),
);
m.ok(
"books.set_entry_party",
json!({"entry_id": e2, "party": "Landlord"}),
);
// Replace the receipt: attach new, unlink old, retire it.
m.attach(Some(e2), b"rent receipt v2");
m.ok(
"books.unlink_receipt",
json!({"attachment_id": r_old, "reason": "replaced by v2"}),
);
m.ok(
"books.withdraw_attachment",
json!({"attachment_id": r_old, "reason": "superseded"}),
);
m.ok("books.list_receipts", json!({"include_withdrawn": true}));
m.ok("books.list_receipts", json!({"entry_id": e2}));
m.ok("books.get_receipt", json!({"attachment_id": r_old}));
let doc = m.ok(
"books.file_document",
json!({"category": "legal", "file_name": "agreement.txt", "mime_type": "text/plain",
"content_base64": b64(b"operating agreement"), "description": "OA"}),
)["attachment_id"]
.as_u64()
.unwrap();
let filed = m.ok(
"books.file_document",
json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain",
"content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.link_attachment",
json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.attachments",
json!({"subject": {"kind": "party", "id": "Landlord"}}),
);
m.ok(
"books.unlink_attachment",
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
);
// From outside the books, in the server's import folder.
let local = inbox(&dir).join("local-receipt.txt");
std::fs::write(&local, b"a local receipt").unwrap();
m.ok(
"books.import_local_file",
json!({"path": local.to_str().unwrap(), "category": "receipt"}),
);
// Reversal: preview, then confirmed.
let e3 = m.draft(
"2026-01-20",
"Mistake",
&[("6999", "debit", "50.00"), ("1000", "credit", "50.00")],
);
m.attach(Some(e3), b"mistake receipt");
m.post(e3).ok("post mistake");
let rev = json!({"entry_id": e3, "date": "2026-01-21", "description": "Reverse mistake"});
m.ok("books.reverse_entry", rev.clone());
let mut rev_c = rev;
rev_c["confirmed"] = json!(true);
m.ok("books.reverse_entry", rev_c);
// FX.
m.ok(
"books.set_fx_rate",
json!({"currency": "EUR", "date": "2026-01-01", "rate": "1.10"}),
);
let fx = m.ok(
"books.draft_entry",
json!({"date": "2026-04-22", "description": "EUR draft"}),
)["entry_id"]
.as_u64()
.unwrap();
m.ok(
"books.add_line",
json!({"entry_id": fx, "account": "6999", "side": "debit", "amount": "11.00",
"foreign_currency": "EUR", "foreign_amount": "10.00", "fx_rate": "1.10"}),
);
// A/R and A/P.
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
"lines": [{"description": "Office rent", "amount": "300.00"}]}),
)["id"]
.as_u64()
.unwrap();
m.ok(
"books.pay_bill",
json!({"document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}),
);
m.ok("books.ar_aging", json!({"as_of": "2026-06-30"}));
m.ok("books.ap_aging", json!({"as_of": "2026-06-30"}));
// Fixed assets.
let registered = m.ok(
"books.register_asset",
json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00",
"salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500",
"accum_depr_account": "1510", "expense_account": "6500"}),
);
assert_eq!(registered["tag"], "S-0001", "\"Spec Co\" without \"Co\": S");
let asset = registered["asset_id"].as_u64().unwrap();
m.ok("books.asset_schedule", json!({"asset_id": asset}));
m.ok("books.asset_schedule", json!({"tag": "s-0001"}));
m.ok(
"books.label_asset",
json!({"asset": "S-0001", "tag": "LAPTOP-1", "serial": "SN 123"}),
);
m.ok(
"books.set_asset_tax_basis",
json!({"asset": "LAPTOP-1", "rule": "personal_use_conversion", "contributor_basis": "3200.00"}),
);
m.ok(
"books.correct_asset",
json!({"asset": "LAPTOP-1", "useful_life_months": 48, "reason": "registered with the wrong life"}),
);
m.ok("books.asset_labels", json!({}));
m.ok(
"books.asset_labels",
json!({"assets": ["LAPTOP-1"], "skip": 3}),
);
m.ok(
"books.export",
json!({"report": "fixed_asset_register", "format": "json", "as_of": "2026-03-31"}),
);
m.ok("books.list_assets", json!({}));
m.ok("books.run_depreciation", json!({"period": "2026-01"}));
let dispose = json!({"asset_id": asset, "date": "2026-06-15", "proceeds": "1000.00",
"proceeds_account": "1000", "gain_loss_account": "7100"});
m.ok("books.dispose_asset", dispose.clone());
let mut dispose_c = dispose;
dispose_c["confirmed"] = json!(true);
dispose_c["receipt_attachment_ids"] = json!([doc]);
m.ok("books.dispose_asset", dispose_c);
// Schedules and recurring.
m.ok(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "Insurance", "total": "1200.00",
"start": "2026-01-01", "months": 12, "balance_account": "1300", "pl_account": "6999"}),
);
m.ok("books.list_schedules", json!({}));
m.ok("books.run_schedules", json!({"through": "2026-03-31"}));
m.ok(
"books.run_schedules",
json!({"through": "2026-03-31", "confirmed": true}),
);
m.ok(
"books.create_recurring",
json!({"description": "Hosting", "frequency": "monthly", "start": "2026-04-30",
"end": "2026-06-30", "lines": [
{"account": "6999", "side": "debit", "amount": "10.00"},
{"account": "1000", "side": "credit", "amount": "10.00"}]}),
);
m.ok("books.list_recurring", json!({}));
m.ok("books.run_recurring", json!({"through": "2026-06-30"}));
// Payouts.
m.ok(
"books.record_payout",
json!({"date": "2026-02-28", "description": "App Store payout", "deposit_account": "1000",
"deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00"}],
"fees": [{"account": "6010", "amount": "15.00"}]}),
);
// Payroll.
m.ok(
"books.run_payroll",
json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{
"employee": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00",
"state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50",
"ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}),
);
m.ok("books.list_payroll_runs", json!({}));
m.ok(
"books.remit_payroll_taxes",
json!({"date": "2026-02-20", "cash_account": "1000"}),
);
// Bank import and reconciliation.
let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n";
let source = m.ok(
"books.file_document",
json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv",
"content_base64": b64(csv.as_bytes())}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.import_bank_csv",
json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999",
"source_attachment_id": source}),
);
m.ok(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}),
);
// January's statement: the contribution and the rent cleared.
m.ok(
"books.mark_cleared",
json!({"entry_id": e1, "line_index": 0, "cleared": true}),
);
let stmt = m.ok(
"books.file_document",
json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf",
"content_base64": b64(b"%PDF statement")}),
)["attachment_id"]
.as_u64()
.unwrap();
m.ok(
"books.record_statement",
json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01",
"period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}),
);
m.ok("books.statements", json!({}));
m.ok("books.reconcile", json!({"statement_attachment": stmt}));
let rec = m.ok(
"books.reconcile",
json!({"statement_attachment": stmt, "confirmed": true}),
)["reconciliation"]["reconciliation_id"]
.as_u64()
.unwrap();
m.ok("books.reconciliations", json!({"account": "1000"}));
m.ok("books.show", json!({"reconciliation": rec}));
m.ok(
"books.void_reconciliation",
json!({"reconciliation_id": rec, "reason": "redo after the March import"}),
);
// Voided, its statement can be voided too, to record it again.
m.ok(
"books.void_statement",
json!({"attachment_id": stmt, "reason": "closing balance mistyped"}),
);
// Periods.
let p = m.ok(
"books.create_period",
json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}),
)["period_id"]
.as_u64()
.unwrap();
m.ok("books.list_periods", json!({}));
m.ok("books.close_period", json!({"period_id": p}));
m.ok(
"books.close_period",
json!({"period_id": p, "confirmed": true}),
);
// The handbook: its index, and a section.
m.ok("books.handbook", json!({}));
m.ok("books.handbook", json!({"section": "contract"}));
// Reports over the populated books.
m.ok("books.chart", json!({}));
m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
m.ok("books.balance_sheet", json!({"as_of": "2026-12-31"}));
m.ok(
"books.profit_and_loss",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok("books.profit_and_loss", json!({"class": "Rondo"}));
m.ok(
"books.profit_and_loss_by_class",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok(
"books.cash_flow",
json!({"start": "2026-01-01", "end": "2026-12-31"}),
);
m.ok("books.digest", json!({"since": "2026-01-01"}));
m.ok("books.search_entries", json!({"text": "rent", "limit": 10}));
m.ok("books.vendor_1099", json!({"year": 2026}));
m.ok(
"books.vendor_1099",
json!({"year": 2026, "include_tins": true,
"save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge"}),
);
m.ok(
"books.purge_tin",
json!({"party": "Landlord", "reason": "test purge", "confirmed": true}),
);
m.ok("books.party", json!({"party": "Landlord", "year": 2026}));
m.ok("books.party", json!({"party": "Pat", "year": 2026}));
m.ok("books.party", json!({"party": "Client"}));
m.ok("books.verify", json!({}));
m.ok("books.show", json!({"party": "Landlord"}));
m.ok("books.show", json!({"entry_id": e1}));
m.ok(
"books.show",
json!({"report": "balance_sheet", "end": "2026-12-31"}),
);
for (format, report) in [
("csv", "trial_balance"),
("json", "profit_and_loss"),
("xlsx", "balance_sheet"),
("pdf", "profit_and_loss"),
("csv", "cash_flow"),
("iif", "journal"),
("csv", "general_ledger"),
("json", "members_equity"),
("csv", "tax_summary"),
] {
m.ok(
"books.export",
json!({"format": format, "report": report, "start": "2026-01-01", "end": "2026-12-31"}),
);
}
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false, "include_tins": false}),
);
m.ok(
"books.export_cpa_package",
json!({"as_of": "2026-12-31", "include_receipts": false,
"save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}),
);
m
}
/// Invariant 1 over every tool. Fails listing any tool never exercised
/// successfully.
#[test]
fn inv1_every_tool_result_matches_its_output_schema() {
let m = happy_path("inv1");
let missing: Vec<&String> = m
.schemas
.keys()
.filter(|t| !m.exercised.contains(*t))
.collect();
assert!(missing.is_empty(), "tools never exercised: {missing:?}");
}
/// The read-only hint is true in behaviour: every tool annotated
/// readOnlyHint succeeded on the fixture company and left the journal,
/// every file under the data dir (its contents, the database by every row
/// it holds), the vault and the settings file exactly as they were
/// (checked in [`Mcp::tool`] on every call).
#[test]
fn read_only_tools_change_nothing() {
let m = happy_path("read-only");
let read_only: Vec<&String> = m.schemas.keys().filter(|t| m.read_only(t)).collect();
assert!(read_only.len() > 30, "only {read_only:?}");
let unchecked: Vec<&&String> = read_only
.iter()
.filter(|t| !m.read_only_checked.contains(**t))
.collect();
assert!(
unchecked.is_empty(),
"read-only tools never checked: {unchecked:?}"
);
}
/// Invariant 3, amounts out, weak form: no money key ever holds a JSON
/// number, over every tool's results.
#[test]
fn inv3_amounts_out_are_never_json_numbers() {
let m = happy_path("inv3-out-numbers");
let numbers: Vec<&String> = m
.money_violations
.iter()
.filter(|v| v.contains("JSON number"))
.collect();
assert!(numbers.is_empty(), "{numbers:#?}");
}
/// Invariant 3, amounts out, full form: every amount is a decimal string
/// with at least two places.
#[test]
fn inv3_amounts_out_have_at_least_two_places() {
let m = happy_path("inv3-out-places");
assert!(
m.money_violations.is_empty(),
"invariant 3 (amounts out): {:#?}",
m.money_violations
);
}
// ------------------------------------- invariants 2 and 7: state machine
/// An id argument: usually one the books handed out (by index into what
/// this run has created), sometimes a raw number that may not exist.
#[derive(Debug, Clone, Copy)]
enum Pick {
Known(usize),
Raw(u64),
}
impl Pick {
fn resolve(self, known: &[u64]) -> u64 {
match self {
Pick::Known(i) if !known.is_empty() => known[i % known.len()],
Pick::Known(i) => i as u64,
Pick::Raw(n) => n,
}
}
}
fn pick() -> impl Strategy<Value = Pick> {
prop_oneof![3 => (0usize..16).prop_map(Pick::Known), 1 => (0u64..12).prop_map(Pick::Raw)]
}
/// Ids handed out so far in a run.
#[derive(Default)]
struct Known {
entries: Vec<u64>,
attachments: Vec<u64>,
periods: Vec<u64>,
vendors: Vec<u64>,
bills: Vec<u64>,
}
/// One step of the random walk.
#[derive(Debug, Clone)]
enum Op {
Draft {
date: String,
desc: String,
},
/// Draft plus two lines (debit `account`, credit 1000), optionally
/// with a receipt: several calls, each checked.
Balanced {
date: String,
desc: String,
account: String,
amount: String,
receipt: bool,
},
AddLine {
entry: Pick,
account: String,
side: String,
amount: String,
},
Post {
entry: Pick,
confirmed: Option<bool>,
},
Reverse {
entry: Pick,
date: String,
confirmed: Option<bool>,
},
Attach {
entry: Option<Pick>,
content: String,
},
Link {
att: Pick,
entry: Pick,
},
Unlink {
att: Pick,
},
Withdraw {
att: Pick,
},
CreatePeriod {
start: String,
end: String,
},
ClosePeriod {
id: Pick,
confirmed: bool,
},
AddAccount {
code: String,
ty: String,
},
AddVendor {
name: String,
},
CreateBill {
party: Pick,
amount: String,
},
PayBill {
doc: Pick,
amount: String,
},
Payout {
deposit: String,
},
Renumber {
account: String,
code: String,
},
Read(u8),
UnknownTool,
MissingArgs,
WrongTypes,
}
fn date_strategy() -> impl Strategy<Value = String> {
prop_oneof![
8 => (1u32..=6, 1u32..=28).prop_map(|(m, d)| format!("2026-{m:02}-{d:02}")),
1 => prop::sample::select(vec![
"2026-02-30", "2026-13-01", "26-1-1", "", "2026/01/05", "yesterday", "2026-1-5",
])
.prop_map(String::from),
]
}
fn amount_strategy() -> impl Strategy<Value = String> {
prop_oneof![
6 => (1u64..=6000).prop_map(|c| format!("{}.{:02}", c / 100, c % 100)),
2 => prop::sample::select(vec!["25.00", "25.01", "20.00", "5"]).prop_map(String::from),
2 => prop::sample::select(vec![
"1e3", "+5", ".5", "5.", "1,000", " 5", "abc", "", "-", "-5.00", "0", "0.001", "NaN",
])
.prop_map(String::from),
]
}
fn account_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec![
"1000", "1000", "2000", "4000", "6000", "6000", "6999", "1", "3", "9999", "", "abc",
])
.prop_map(String::from)
}
fn desc_strategy() -> impl Strategy<Value = String> {
prop::sample::select(vec!["Coffee", "coffee ", "Rent", "Supplies"]).prop_map(String::from)
}
fn op_strategy() -> impl Strategy<Value = Op> {
let confirmed = || prop::option::of(any::<bool>());
prop_oneof![
2 => (date_strategy(), desc_strategy()).prop_map(|(date, desc)| Op::Draft { date, desc }),
5 => (date_strategy(), desc_strategy(),
prop::sample::select(vec!["6000", "6999", "2000", "9999"]),
amount_strategy(), any::<bool>())
.prop_map(|(date, desc, a, amount, receipt)| Op::Balanced {
date, desc, account: a.to_string(), amount, receipt,
}),
5 => (pick(), account_strategy(),
prop::sample::select(vec!["debit", "credit", "debit", "credit", "Debit", "sideways"]),
amount_strategy())
.prop_map(|(entry, account, s, amount)| Op::AddLine { entry, account, side: s.to_string(), amount }),
6 => (pick(), confirmed()).prop_map(|(entry, confirmed)| Op::Post { entry, confirmed }),
2 => (pick(), date_strategy(), confirmed())
.prop_map(|(entry, date, confirmed)| Op::Reverse { entry, date, confirmed }),
3 => (prop::option::of(pick()), prop_oneof![
4 => prop::collection::vec(any::<u8>(), 1..24).prop_map(|b| b64(&b)),
1 => Just("!!not base64!!".to_string()),
])
.prop_map(|(entry, content)| Op::Attach { entry, content }),
1 => (pick(), pick()).prop_map(|(att, entry)| Op::Link { att, entry }),
1 => pick().prop_map(|att| Op::Unlink { att }),
1 => pick().prop_map(|att| Op::Withdraw { att }),
1 => (date_strategy(), date_strategy()).prop_map(|(start, end)| Op::CreatePeriod { start, end }),
1 => (pick(), any::<bool>()).prop_map(|(id, confirmed)| Op::ClosePeriod { id, confirmed }),
1 => (prop::sample::select(vec!["1000", "5000", "5001", "", "x"]),
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
.prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }),
1 => prop::sample::select(vec!["Acme", "Acme", "", "1"]).prop_map(|n| Op::AddVendor { name: n.to_string() }),
1 => (pick(), amount_strategy()).prop_map(|(party, amount)| Op::CreateBill { party, amount }),
1 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }),
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""]))
.prop_map(|(account, c)| Op::Renumber { account, code: c.to_string() }),
3 => (0u8..6).prop_map(Op::Read),
1 => Just(Op::UnknownTool),
1 => Just(Op::MissingArgs),
1 => Just(Op::WrongTypes),
]
}
/// The id a success handed out under `key`, if any.
fn new_id(out: &Out, key: &str) -> Option<u64> {
match out {
Out::Ok(v) => v[key].as_u64(),
_ => None,
}
}
fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
let opt = |v: Option<bool>| v.map_or(Value::Null, Value::Bool);
let (tool, args) = match op {
Op::Draft { date, desc } => (
"books.draft_entry",
json!({"date": date, "description": desc}),
),
Op::Balanced {
date,
desc,
account,
amount,
receipt,
} => {
let out = m.tool(
"books.draft_entry",
json!({"date": date, "description": desc}),
);
let Some(e) = new_id(&out, "entry_id") else {
return;
};
k.entries.push(e);
for (acct, side) in [(account.as_str(), "debit"), ("1000", "credit")] {
m.tool(
"books.add_line",
json!({"entry_id": e, "account": acct, "side": side, "amount": amount}),
);
}
if *receipt {
let bytes = format!("receipt for entry {e}");
let out = m.tool(
"books.attach_receipt",
json!({"entry_id": e, "file_name": "r.txt", "mime_type": "text/plain",
"content_base64": b64(bytes.as_bytes())}),
);
k.attachments.extend(new_id(&out, "attachment_id"));
}
return;
}
Op::AddLine {
entry,
account,
side,
amount,
} => (
"books.add_line",
json!({"entry_id": entry.resolve(&k.entries), "account": account, "side": side,
"amount": amount}),
),
Op::Post { entry, confirmed } => (
"books.post_entry",
json!({"entry_id": entry.resolve(&k.entries), "confirmed": opt(*confirmed)}),
),
Op::Reverse {
entry,
date,
confirmed,
} => (
"books.reverse_entry",
json!({"entry_id": entry.resolve(&k.entries), "date": date, "description": "undo",
"confirmed": opt(*confirmed)}),
),
Op::Attach { entry, content } => (
"books.attach_receipt",
json!({"entry_id": entry.map(|p| p.resolve(&k.entries)), "file_name": "r.bin",
"mime_type": "application/octet-stream", "content_base64": content}),
),
Op::Link { att, entry } => (
"books.link_receipt",
json!({"attachment_id": att.resolve(&k.attachments),
"entry_id": entry.resolve(&k.entries)}),
),
Op::Unlink { att } => (
"books.unlink_receipt",
json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}),
),
Op::Withdraw { att } => (
"books.withdraw_attachment",
json!({"attachment_id": att.resolve(&k.attachments), "reason": "test"}),
),
Op::CreatePeriod { start, end } => (
"books.create_period",
json!({"name": "P", "start": start, "end": end}),
),
Op::ClosePeriod { id, confirmed } => (
"books.close_period",
json!({"period_id": id.resolve(&k.periods), "confirmed": confirmed}),
),
Op::AddAccount { code, ty } => (
"books.add_account",
json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}),
),
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
Op::CreateBill { party, amount } => (
"books.create_bill",
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
"due_date": "2026-03-31", "lines": [{"description": "thing", "amount": amount}]}),
),
Op::PayBill { doc, amount } => (
"books.pay_bill",
json!({"document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount,
"cash_account": "1000"}),
),
Op::Payout { deposit } => (
"books.record_payout",
json!({"date": "2026-03-31", "description": "payout", "deposit_account": "1000",
"deposit": deposit, "revenue": [{"account": "4000", "amount": "100.00"}],
"fees": [{"account": "6999", "amount": "15.00"}]}),
),
Op::Renumber { account, code } => (
"books.renumber_account",
json!({"account": account, "code": code}),
),
Op::Read(i) => match i {
0 => ("books.chart", json!({})),
1 => ("books.trial_balance", json!({"as_of": "2026-12-31"})),
2 => (
"books.get_entry",
json!({"entry_id": Pick::Known(0).resolve(&k.entries)}),
),
3 => ("books.list_receipts", json!({})),
4 => ("books.list_periods", json!({})),
_ => ("books.digest", json!({"since": "2026-01-01"})),
},
Op::UnknownTool => ("books.no_such_tool", json!({})),
Op::MissingArgs => ("books.add_line", json!({"entry_id": 1})),
Op::WrongTypes => (
"books.post_entry",
json!({"entry_id": "one", "confirmed": "yes"}),
),
};
// Mcp::tool checks invariants 1, 2, 3 (schema, text) and 7.
let out = m.tool(tool, args);
match tool {
"books.draft_entry" | "books.record_payout" => k.entries.extend(new_id(&out, "entry_id")),
"books.reverse_entry" => k.entries.extend(new_id(&out, "reversal_id")),
"books.attach_receipt" => k.attachments.extend(new_id(&out, "attachment_id")),
"books.create_period" => k.periods.extend(new_id(&out, "period_id")),
"books.add_vendor" => k.vendors.extend(new_id(&out, "id")),
"books.create_bill" => {
k.bills.extend(new_id(&out, "id"));
k.entries.extend(new_id(&out, "entry_id"));
}
_ => {}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: cases(), failure_persistence: None, ..ProptestConfig::default() })]
/// Invariants 2 and 7 under random call sequences: every call gets
/// exactly one response with its id, result xor error; any refusal
/// leaves the journal byte-identical; successes match their schemas;
/// and the server keeps answering afterwards.
#[test]
fn inv2_inv7_random_calls_keep_books_and_server_intact(
ops in prop::collection::vec(op_strategy(), 30..60)
) {
let dir = test_dir("sm");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let mut known = Known::default();
for op in &ops {
run_op(&mut m, &mut known, op);
}
// Still alive and consistent at the end.
m.ok("books.chart", json!({}));
let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
prop_assert_eq!(&tb["balanced"], &json!(true));
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
}
// ---------------------------------------------- invariant 3: amounts in
const REFUSED_AMOUNTS: &[&str] = &[
"1e3", "1E3", "+5", ".5", "5.", "1_000", "1,000", " 5", "5 ", "", "-", "abc", "١٢٣", "5.0.0",
"0x10", "5..0", "--5", "5-", "Infinity", "NaN", "5",
];
/// Grammatical amounts with at most two places and a positive value: what
/// the core is expected to accept on a line.
const ACCEPTED_AMOUNTS: &[&str] = &["5", "5.00", "123.45", "5.5", "007.50", "1000000.00"];
fn amounts_server(name: &str) -> (PathBuf, Mcp, u64) {
let dir = test_dir(name);
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft("2026-03-01", "amounts", &[]);
(dir, m, e)
}
#[test]
fn inv3_add_line_amount_grammar_table() {
let (_dir, mut m, e) = amounts_server("inv3-table");
for a in REFUSED_AMOUNTS {
m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
}
for a in ACCEPTED_AMOUNTS {
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
// Amounts out: the stored line keeps the value, never rounded.
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
let last = entry["lines"].as_array().unwrap().last().unwrap().clone();
let out = last["amount"].as_str().expect("amount is a string");
assert_eq!(canon(out), canon(a), "line amount {out} for input {a}");
}
assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations);
}
/// The amount grammar applies to every amount-taking tool, not just
/// add_line.
#[test]
fn inv3_other_tools_refuse_ungrammatical_amounts() {
let (_dir, mut m, e) = amounts_server("inv3-other");
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00"}),
);
let vendor = m.ok("books.add_vendor", json!({"name": "V"}))["id"].clone();
for bad in ["1e1", "+10.00", "10,00", " 10.00", "10.00 "] {
m.refuse(
"books.post_entry",
json!({"entry_id": e, "confirmed": true, "expected_total": bad}),
);
m.refuse(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "x", "amount": bad}]}),
);
m.refuse(
"books.reconcile",
json!({"account": "1000", "statement_date": "2026-03-31", "statement_balance": bad}),
);
m.refuse(
"books.record_payout",
json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000",
"deposit": bad, "revenue": [{"account": "4000", "amount": "10.00"}]}),
);
m.refuse(
"books.create_schedule",
json!({"kind": "prepaid_expense", "description": "s", "total": bad,
"start": "2026-01-01", "months": 12, "balance_account": "1200",
"pl_account": "6000"}),
);
}
}
fn amount_char() -> impl Strategy<Value = char> {
prop::sample::select(vec![
'0', '1', '5', '9', '0', '1', '5', '9', '.', '.', '-', '+', 'e', 'E', '_', ',', ' ', '١',
'x',
])
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, failure_persistence: None, ..ProptestConfig::default() })]
/// Random strings: ungrammatical ones are refused; grammatical ones
/// with at most two places and a positive value are accepted and come
/// back with the same value. Others (zero, negative, sub-cent) are the
/// core's business and aren't asserted.
#[test]
fn inv3_add_line_amount_grammar_random(
amounts in prop::collection::vec(
prop::collection::vec(amount_char(), 0..9).prop_map(|cs| cs.into_iter().collect::<String>()),
20..40,
)
) {
let (dir, mut m, e) = amounts_server("inv3-prop");
for a in &amounts {
let out = m.tool(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": a}),
);
if !is_amount_in(a) {
prop_assert!(out.refused(), "ungrammatical {:?} accepted", a);
continue;
}
let places = a.split_once('.').map_or(0, |(_, f)| f.len());
let positive = !a.starts_with('-') && canon(a) != "0";
if places <= 2 && positive && a.len() <= 7 {
prop_assert!(!out.refused(), "grammatical {:?} refused: {:?}", a, out);
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
let last = entry["lines"].as_array().unwrap().last().unwrap().clone();
prop_assert_eq!(canon(last["amount"].as_str().unwrap()), canon(a));
}
}
prop_assert!(m.money_violations.is_empty(), "{:#?}", m.money_violations);
drop(m);
let _ = std::fs::remove_dir_all(&dir);
}
}
// ----------------------------------------- invariant 4: preview changes nothing
#[test]
fn inv4_post_and_reverse_preview_without_confirmed() {
let dir = test_dir("inv4");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-04-01",
"Rent",
&[("6000", "debit", "40.00"), ("1000", "credit", "40.00")],
);
m.attach(Some(e), b"rent");
for args in [
json!({"entry_id": e}),
json!({"entry_id": e, "confirmed": false}),
json!({"entry_id": e, "confirmed": null}),
] {
let before = m.journal();
let p = m.ok("books.post_entry", args.clone());
assert_eq!(p["status"], "preview", "{args}: {p}");
assert!(m.journal() == before, "post_entry preview {args} wrote");
assert_eq!(m.entry_status(e), "draft");
}
let before = m.journal();
let posted = m.post(e).ok("confirmed post");
assert_eq!(posted["status"], "posted");
assert!(m.journal() != before, "confirmed post wrote nothing");
assert_eq!(m.entry_status(e), "posted");
let rev = json!({"entry_id": e, "date": "2026-04-02", "description": "undo"});
for extra in [json!({}), json!({"confirmed": false})] {
let mut args = rev.clone();
args.as_object_mut()
.unwrap()
.extend(extra.as_object().unwrap().clone());
let before = m.journal();
let p = m.ok("books.reverse_entry", args.clone());
assert_eq!(p["status"], "preview", "{args}: {p}");
assert!(m.journal() == before, "reverse_entry preview {args} wrote");
}
let mut args = rev;
args["confirmed"] = json!(true);
let before = m.journal();
let r = m.ok("books.reverse_entry", args);
assert_eq!(r["status"], "posted", "{r}");
assert!(m.journal() != before);
let tb = m.ok("books.trial_balance", json!({"as_of": "2026-12-31"}));
assert_eq!(tb["balanced"], true);
}
// ------------------------------------------ invariant 5: receipt policy
#[test]
fn inv5_threshold_is_strictly_greater_than() {
let dir = test_dir("inv5-25");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let at = m.draft(
"2026-05-01",
"At threshold",
&[("6000", "debit", "25.00"), ("1000", "credit", "25.00")],
);
m.post(at).ok("25.00 posts without a receipt");
let over = m.draft(
"2026-05-02",
"Over threshold",
&[("6000", "debit", "25.01"), ("1000", "credit", "25.01")],
);
let preview = m.ok("books.post_entry", json!({"entry_id": over}));
assert_eq!(preview["receipt_missing"], true, "{preview}");
let err = m.post(over).err_text();
assert!(err.contains("receipt_required"), "{err}");
assert_eq!(m.entry_status(over), "draft");
m.attach(Some(over), b"receipt for 25.01");
m.post(over).ok("25.01 posts with a receipt");
}
#[test]
fn inv5_zero_threshold_needs_a_receipt_on_every_entry() {
let dir = test_dir("inv5-0");
let toml = books_toml(&dir, "0.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-05-01",
"A penny",
&[("6000", "debit", "0.01"), ("1000", "credit", "0.01")],
);
let err = m.post(e).err_text();
assert!(err.contains("receipt_required"), "{err}");
m.attach(Some(e), b"penny receipt");
m.post(e).ok("with a receipt");
// Keep posted: the last receipt can't be unlinked from a covered entry.
let receipts = m.ok("books.list_receipts", json!({"entry_id": e}));
let att = receipts["receipts"][0]["attachment_id"].clone();
let err = m.refuse(
"books.unlink_receipt",
json!({"attachment_id": att, "reason": "try"}),
);
assert!(err.contains("receipt_required"), "{err}");
}
#[test]
fn inv5_splitting_does_not_dodge_the_threshold() {
let dir = test_dir("inv5-split");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let a = m.draft(
"2026-05-03",
"Office supplies",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(a).ok("first half posts");
// Same day, same description (case and surrounding whitespace aside),
// same debit account: grouped, 40.00 > 25.00.
let b = m.draft(
"2026-05-03",
" OFFICE supplies ",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
let err = m.post(b).err_text();
assert!(err.contains("receipt_required"), "{err}");
// Not the signature: another day, or another debit account.
let c = m.draft(
"2026-05-04",
"Office supplies",
&[("6999", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(c).ok("another day");
let d = m.draft(
"2026-05-03",
"Office supplies",
&[("6000", "debit", "20.00"), ("1000", "credit", "20.00")],
);
m.post(d).ok("another debit account");
// A receipt clears it.
m.attach(Some(b), b"supplies receipt");
m.post(b).ok("split with a receipt");
}
#[test]
fn inv5_reversals_are_exempt() {
let dir = test_dir("inv5-rev");
let toml = books_toml(&dir, "0.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let e = m.draft(
"2026-05-01",
"Big",
&[("6000", "debit", "500.00"), ("1000", "credit", "500.00")],
);
m.attach(Some(e), b"big receipt");
m.post(e).ok("post");
m.ok(
"books.reverse_entry",
json!({"entry_id": e, "date": "2026-05-02", "description": "undo", "confirmed": true}),
);
}
/// Policy invariant 4: a threshold that isn't a decimal, or is below
/// zero, is refused at startup.
#[test]
fn inv5_bad_threshold_refused_at_startup() {
for bad in ["-1", "abc", "-0.01", "", "1,000", "ten"] {
let dir = test_dir("inv5-startup");
let toml = books_toml(&dir, bad, "");
let mut child = spawn_child(&dir, &toml);
let mut stdin = child.stdin.take().unwrap();
let stdout = child.stdout.take().unwrap();
let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
"protocolVersion": "2025-06-18", "capabilities": {},
"clientInfo": {"name": "spec-test", "version": "0.1.0"}}});
let _ = writeln!(stdin, "{init}");
let _ = stdin.flush();
let (tx, rx) = channel();
std::thread::spawn(move || {
let mut line = String::new();
let _ = BufReader::new(stdout).read_line(&mut line);
let _ = tx.send(line);
});
let line = rx.recv_timeout(TIMEOUT).unwrap_or_default();
let answered = serde_json::from_str::<Value>(&line)
.ok()
.is_some_and(|v| v.get("result").is_some());
drop(stdin);
let status = wait_with_timeout(&mut child, TIMEOUT);
assert!(
!answered,
"threshold {bad:?}: server completed initialize: {line}"
);
assert!(
status.is_some_and(|s| !s.success()),
"threshold {bad:?}: server did not exit nonzero ({status:?})"
);
}
}
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Option<std::process::ExitStatus> {
let start = std::time::Instant::now();
loop {
if let Ok(Some(s)) = child.try_wait() {
return Some(s);
}
if start.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(Duration::from_millis(20));
}
}
// --------------------------------------- invariant 6: ambiguous references
#[test]
fn inv6_ambiguous_account_reference_refused() {
let dir = test_dir("inv6-acct");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
// Account "2" first, then others; find one whose id is 2 and whose
// code is not "2".
let ids = m.accounts(&[
("2", "Two by code", "expense"),
("1000", "Bank", "asset"),
("6000", "Rent", "expense"),
]);
assert!(
ids.contains(&2) && ids[0] != 2,
"need an account with id 2 that is not code \"2\": ids {ids:?}"
);
let e = m.draft("2026-06-01", "ambiguity", &[]);
let err = m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "2", "side": "debit", "amount": "10.00"}),
);
assert!(!err.is_empty());
m.refuse(
"books.reconcile",
json!({"account": "2", "statement_date": "2026-06-30", "statement_balance": "0.00"}),
);
m.refuse(
"books.set_tax_category",
json!({"account": "2", "category": null}),
);
m.refuse(
"books.renumber_account",
json!({"account": "2", "code": "6100"}),
);
// Unique references work: a code nobody's id, an id nobody's code.
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00"}),
);
let unique_id = ids
.iter()
.copied()
.find(|&i| i != 2 && !["2", "1000", "6000"].contains(&i.to_string().as_str()))
.expect("an id that is no account's code");
m.ok(
"books.add_line",
json!({"entry_id": e, "account": unique_id.to_string(), "side": "credit", "amount": "10.00"}),
);
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
assert_eq!(entry["lines"].as_array().unwrap().len(), 2, "{entry}");
}
#[test]
fn inv6_ambiguous_vendor_reference_refused() {
let dir = test_dir("inv6-vendor");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let acme = m.ok("books.add_vendor", json!({"name": "Acme"}))["id"]
.as_u64()
.unwrap();
// A vendor literally named after Acme's id.
let named = m.ok("books.add_vendor", json!({"name": acme.to_string()}))["id"]
.as_u64()
.unwrap();
assert_ne!(acme, named);
let amb = acme.to_string();
m.refuse(
"books.set_vendor_1099",
json!({"vendor": amb, "form_1099": "nec", "tin_on_file": false}),
);
m.refuse(
"books.draft_entry",
json!({"date": "2026-06-01", "description": "x", "vendor": amb}),
);
let e = m.draft("2026-06-01", "tag me", &[]);
m.refuse(
"books.set_entry_vendor",
json!({"entry_id": e, "vendor": amb}),
);
// Unique: the name "Acme", and the second vendor's id (no vendor has
// that name).
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Acme", "form_1099": "nec", "tin_on_file": false}),
);
let out = m.ok(
"books.set_entry_vendor",
json!({"entry_id": e, "vendor": named.to_string()}),
);
assert!(out.get("vendor").is_some(), "{out}");
}
#[test]
fn inv6_ambiguous_class_reference_refused() {
let dir = test_dir("inv6-class");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let alpha = m.ok("books.add_class", json!({"name": "Alpha"}))["id"]
.as_u64()
.unwrap();
let named = m.ok("books.add_class", json!({"name": alpha.to_string()}))["id"]
.as_u64()
.unwrap();
assert_ne!(alpha, named);
let e = m.draft("2026-06-01", "classy", &[]);
// add_line with an ambiguous class is refused too, but writes the
// line first: see add_line_bad_class_leaves_journal_unchanged.
m.refuse("books.profit_and_loss", json!({"class": alpha.to_string()}));
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00", "class": "Alpha"}),
);
m.ok(
"books.add_line",
json!({"entry_id": e, "account": "1000", "side": "credit", "amount": "10.00",
"class": named.to_string()}),
);
m.ok("books.profit_and_loss", json!({"class": "Alpha"}));
}
/// Invariant 2: a refused add_line leaves the journal unchanged, here when
/// the line's class doesn't resolve (unknown, or ambiguous per invariant
/// 6).
#[test]
fn add_line_bad_class_leaves_journal_unchanged() {
let dir = test_dir("bug-class");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.ok("books.add_class", json!({"name": "Alpha"}));
m.ok("books.add_class", json!({"name": "1"}));
let e = m.draft("2026-06-01", "classy", &[]);
for class in ["Nope", "99", "1"] {
// Mcp::tool asserts the journal is unchanged on refusal.
m.refuse(
"books.add_line",
json!({"entry_id": e, "account": "6000", "side": "debit", "amount": "10.00",
"class": class}),
);
}
let entry = m.ok("books.get_entry", json!({"entry_id": e}));
assert_eq!(entry["lines"], json!([]), "{entry}");
}
/// Invariant 6 and "validate all tool inputs": profit_and_loss_by_class
/// declares a `class` filter; an unknown or ambiguous class must be
/// refused, not ignored.
#[test]
fn profit_and_loss_by_class_validates_class() {
let dir = test_dir("bug-pnl-class");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.ok("books.add_class", json!({"name": "Alpha"}));
m.ok("books.add_class", json!({"name": "1"}));
m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"}));
m.refuse("books.profit_and_loss_by_class", json!({"class": "1"}));
}
/// Invariant 2 for other class-taking tools: a payout with an unknown
/// class is refused and creates no draft.
#[test]
fn payout_bad_class_leaves_journal_unchanged() {
let dir = test_dir("payout-class");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.refuse(
"books.record_payout",
json!({"date": "2026-03-31", "description": "p", "deposit_account": "1000",
"deposit": "85.00", "revenue": [{"account": "4000", "amount": "100.00", "class": "Nope"}],
"fees": [{"account": "6999", "amount": "15.00", "class": "Nope"}]}),
);
}
// --------------------------------------------- JSON-RPC 2.0 conformance
/// mcp-tools.md: "Unknown tools" are protocol errors (JSON-RPC errors),
/// not isError results.
#[test]
fn jsonrpc_unknown_tool_is_a_protocol_error() {
let dir = test_dir("rpc-tool");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let out = m.tool("books.no_such_tool", json!({}));
assert!(
matches!(out, Out::RpcErr(_)),
"unknown tool should be a JSON-RPC error, got {out:?}"
);
m.ok("books.chart", json!({}));
}
/// jsonrpc-2.0.md §5.1: unknown method → -32601.
#[test]
fn jsonrpc_unknown_method_is_32601() {
let dir = test_dir("rpc-method");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let resp = m.rpc("books/does_not_exist", json!({}));
assert_eq!(resp["error"]["code"], -32601, "{resp}");
m.ok("books.chart", json!({}));
}
/// jsonrpc-2.0.md §5 and §5.1: invalid JSON → a response with code
/// -32700 and id null; the server keeps serving.
#[test]
fn jsonrpc_parse_error_is_32700_with_null_id() {
let dir = test_dir("rpc-parse");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.send_line("{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ");
let resp = m.recv(Duration::from_secs(5));
let resp = resp.expect("no response to a malformed JSON line");
check_response_shape(&resp);
assert_eq!(resp["error"]["code"], -32700, "{resp}");
assert_eq!(resp["id"], Value::Null, "{resp}");
}
/// jsonrpc-2.0.md §5: `id` is REQUIRED in every response; when it can't
/// be detected (Invalid Request) it MUST be null, else the request's.
#[test]
fn jsonrpc_invalid_request_response_has_id() {
let dir = test_dir("rpc-invalid");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
for (junk, id) in [
("[]", Value::Null),
("{\"jsonrpc\": \"2.0\", \"id\": 98}", json!(98)),
] {
m.send_line(junk);
let resp = m.recv(Duration::from_secs(5)).expect("no response");
assert_eq!(resp["error"]["code"], -32600, "{resp}");
assert!(
resp.get("id").is_some(),
"{junk}: response has no id: {resp}"
);
// A detectable id should be echoed; Null when it isn't.
assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}");
}
}
/// Whatever the server does with a malformed line, it keeps serving
/// (invariant 7).
#[test]
fn jsonrpc_server_survives_malformed_input() {
let dir = test_dir("rpc-survive");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
for junk in [
"{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ",
"not json at all",
"[]",
"{\"jsonrpc\": \"2.0\", \"id\": 98}",
] {
m.send_line(junk);
// Drain anything it answered with, without requiring an answer
// (the missing id is jsonrpc_invalid_request_response_has_id).
while let Some(v) = m.recv(Duration::from_millis(300)) {
assert_eq!(v["jsonrpc"], "2.0", "{v}");
assert!(v.get("result").is_some() ^ v.get("error").is_some(), "{v}");
}
m.ok("books.chart", json!({}));
}
}
/// Missing or mistyped arguments: mcp-tools.md lists "invalid arguments"
/// as protocol errors and "invalid input data" as tool errors, so either
/// is accepted; the books must not change and the server must survive.
#[test]
fn jsonrpc_invalid_arguments_refused_cleanly() {
let dir = test_dir("rpc-args");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
m.refuse("books.add_line", json!({"entry_id": 1}));
m.refuse("books.post_entry", json!({"entry_id": "one"}));
m.refuse(
"books.draft_entry",
json!({"date": 20260101, "description": "x"}),
);
m.refuse("books.add_account", json!({}));
m.ok("books.chart", json!({}));
}
#[test]
fn helpers_self_check() {
for ok in ["0.00", "-12.34", "1.234"] {
assert!(is_money_out(ok), "{ok}");
}
for bad in ["0", "1.2", "1e3", "", "-", ".50", "1,000.00"] {
assert!(!is_money_out(bad), "{bad}");
}
for ok in ["0", "5", "-5", "5.00", "007.5"] {
assert!(is_amount_in(ok), "{ok}");
}
for bad in REFUSED_AMOUNTS {
assert!(!is_amount_in(bad), "{bad}");
}
assert_eq!(canon("007.50"), "7.5");
assert_eq!(canon("5.00"), "5");
assert_eq!(canon("-0.00"), "0");
assert_eq!(b64(b"abcd"), "YWJjZA==");
let mut bad = Vec::new();
money_walk(
&json!({"total": 5, "lines": [{"amount": "1.5"}], "id": 3}),
"$",
None,
&mut bad,
);
assert_eq!(bad.len(), 2, "{bad:?}");
}
// ------------------------------------- settings reload (settings.rs spec)
/// INVARIANT 1: a call after an edit sees it, with no restart, and two
/// same-size edits in a row are both seen (compared by content).
#[test]
fn settings_reload_on_the_next_call() {
let dir = test_dir("reload");
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], "Spec Co");
assert_eq!(s["loads"], 1);
for name in ["Aaaa Co", "Bbbb Co"] {
let text = std::fs::read_to_string(&toml)
.unwrap()
.replace("Spec Co", name)
.replace("Aaaa Co", name);
std::fs::write(&toml, text).unwrap();
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], name);
assert_eq!(s["reload_error"], Value::Null);
}
assert_eq!(m.ok("books.settings", json!({}))["loads"], 3);
}
/// INVARIANT 1, enforced: a lowered receipt threshold refuses the next
/// post without a receipt.
#[test]
fn a_reloaded_threshold_is_enforced() {
let dir = test_dir("reload-threshold");
let toml = books_toml(&dir, "1000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let first = m.draft(
"2026-03-01",
"small",
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
);
m.post(first).ok("under the threshold");
let text = std::fs::read_to_string(&toml)
.unwrap()
.replace("\"1000.00\"", "\"0.00\"");
std::fs::write(&toml, text).unwrap();
let second = m.draft(
"2026-03-02",
"small",
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
);
let out = m.post(second);
assert!(out.err_text().contains("receipt_required"), "{out:?}");
assert_eq!(m.entry_status(second), "draft");
}
/// INVARIANTS 2 and 3: a broken edit, and a named file gone missing, keep
/// the last good settings and say why; fixing it loads again.
#[test]
fn a_broken_edit_keeps_the_last_good_settings() {
let dir = test_dir("reload-broken");
let toml = books_toml(&dir, "25.00", "");
let good = std::fs::read_to_string(&toml).unwrap();
let mut m = Mcp::start(&dir, &toml);
std::fs::write(
&toml,
good.replace("Spec Co", "Next Co")
.replace("[books]", "[books"),
)
.unwrap();
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], "Spec Co");
assert!(s["reload_error"].is_string(), "{s}");
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
assert!(
digest["settings_problems"][0]
.as_str()
.unwrap()
.contains("didn't load"),
"{digest}"
);
std::fs::remove_file(&toml).unwrap();
let s = m.ok("books.settings", json!({}));
assert!(
s["reload_error"].is_string(),
"a named file gone missing: {s}"
);
assert_eq!(s["books_toml_exists"], false);
assert_eq!(s["company"], "Spec Co");
std::fs::write(&toml, good.replace("Spec Co", "Fixed Co")).unwrap();
let s = m.ok("books.settings", json!({}));
assert_eq!(s["company"], "Fixed Co");
assert_eq!(s["reload_error"], Value::Null);
assert_eq!(s["loads"], 2);
}
/// books.settings and the digest list settings that disagree with the
/// chart, each naming the setting.
#[test]
fn settings_that_disagree_with_the_chart_are_problems() {
let dir = test_dir("problems");
let toml = books_toml(
&dir,
"25.00",
"financing_accounts = [\"6000\"]\nclose_into = \"3000\"\n",
);
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let s = m.ok("books.settings", json!({}));
let problems: Vec<String> = s["problems"]
.as_array()
.unwrap()
.iter()
.map(|p| p.as_str().unwrap().to_string())
.collect();
assert_eq!(problems.len(), 1, "{problems:?}");
assert!(problems[0].contains("[books] financing_accounts names 6000"));
let text = std::fs::read_to_string(&toml)
.unwrap()
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
.replace("close_into = \"3000\"", "close_into = \"4000\"");
std::fs::write(&toml, text).unwrap();
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
let problems = digest["settings_problems"].to_string();
assert!(
problems.contains("[books] cash_accounts names 1010, which isn't in the chart"),
"{problems}"
);
assert!(
problems.contains("[books] close_into names 4000"),
"{problems}"
);
assert!(
problems.contains("[books] financing_accounts names 6000"),
"{problems}"
);
}
/// books.assign_role steers what an invoice posts, and books.roles shows
/// it; a refusal names the reason.
#[test]
fn assigned_roles_steer_invoices() {
let dir = test_dir("roles");
let toml = books_toml(&dir, "1000000.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(&[
("1000", "Bank", "asset"),
("1110", "Trade Receivables", "asset"),
("4100", "Consulting Income", "revenue"),
("6000", "Rent", "expense"),
]);
let roles = m.ok("books.roles", json!({}));
let receivables = roles["roles"]
.as_array()
.unwrap()
.iter()
.find(|r| r["role"] == "receivables")
.unwrap()
.clone();
assert_eq!(receivables["default_code"], "1200");
assert_eq!(receivables["assigned"], false);
assert_eq!(receivables["account_code"], Value::Null);
let err = m.refuse(
"books.assign_role",
json!({"role": "receivables", "account": "6000"}),
);
assert!(err.contains("accounts receivable"), "{err}");
let err = m.refuse(
"books.assign_role",
json!({"role": "receivable", "account": "1110"}),
);
assert!(err.contains("no role"), "{err}");
m.ok(
"books.assign_role",
json!({"role": "receivables", "account": "1110"}),
);
let roles = m.ok(
"books.assign_role",
json!({"role": "revenue", "account": "4100"}),
);
let revenue = roles["roles"]
.as_array()
.unwrap()
.iter()
.find(|r| r["role"] == "revenue")
.unwrap()
.clone();
assert_eq!(revenue["assigned"], true);
assert_eq!(revenue["account_code"], "4100");
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"]
.as_u64()
.unwrap();
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
);
let tb = m.ok("books.trial_balance", json!({})).to_string();
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
assert!(!tb.contains("\"1200\"") && !tb.contains("\"4000\""), "{tb}");
}
/// The database at `path` by what it holds: every row of every table, in
/// rowid order, as text, hashed. Read through a read-only connection
/// beside the server's (WAL mode lets a reader in while it writes).
fn db_digest(path: &Path) -> String {
let conn =
rusqlite::Connection::open_with_flags(path, rusqlite::OpenFlags::SQLITE_OPEN_READ_ONLY)
.unwrap();
let mut tables: Vec<String> = conn
.prepare("SELECT name FROM sqlite_master WHERE type = 'table' ORDER BY name")
.unwrap()
.query_map([], |r| r.get(0))
.unwrap()
.map(Result::unwrap)
.collect();
tables.sort();
let mut dump = String::new();
for t in &tables {
dump.push_str(&format!("table {t}\n"));
let mut stmt = conn
.prepare(&format!("SELECT * FROM \"{t}\" ORDER BY rowid"))
.unwrap();
let cols = stmt.column_count();
let mut rows = stmt.query([]).unwrap();
while let Some(row) = rows.next().unwrap() {
for i in 0..cols {
dump.push_str(&format!("{:?}\t", row.get_ref(i).unwrap()));
}
dump.push('\n');
}
}
ledger_store::journal::sha256_hex(dump.as_bytes())
}