refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s

The owner (2026-10-23): "We should probably just delete the stdio path
altogether. THere's no point in that. And it sitting there unused will
just lead to bitrot. Nuke it?"

Books are served only by the books server (`wanderbooks server`),
reached by agents through the bridge (`wanderbooks mcp --book ID`),
which stays.

Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and
`ledger_mcp::run` (stdio and `--http` over a bare data dir), with all
that only that mode had:
- files the books make coming back inline: `HandedFile.content_base64`
  is gone, and `download` and `expires_at` are always there;
- books with no access log or backups: `books.access_log`,
  `books.backup` and `books.back_up_now` are always offered, by scope;
- the settings file read and reloaded (`legacy_files`), the vault key
  named by a file or `--vault-key-file`, and the books dir's
  key-inside-the-books and git checks (with `paths.rs`);
- `wire.rs` (the bridge frames an agent's lines itself);
- `books.settings`' file fields and `books.start`'s `settings_source`.

`BooksServer` is built one way, from `Served` (a book as the books
server serves it), in production and in the unit tests. An unknown tool
is now a protocol error (mcp-tools.md). Inline file input is unchanged.

Then the owner's three decisions (2026-10-23, the question tool):
(a) `books.import_settings` is removed entirely (118 tools): old books
come in whole by `wanderbooks books adopt`, which reads their settings
file once; ledger-config keeps its settings-file parsing only as far as
adoption uses it (`load` and the path-reading `effective_receipt_policy`
are gone), and `books.chart_template` gives its settings as
`books.set_setting` changes. (b) The viewer's loose-books-folder mode is
removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings
file and config.json followed): it opens only books of the data root,
reading settings from the journal and the vault from the book's own
`vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an
unknown command like any other word. Also: an upload's expiry past the
calendar is an error, never an empty string.

Tests: every suite that drove the stdio server moved to
crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a
data root of its own (tests/common), handed files by their download;
mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and
listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS,
testing, rules, mcp-interface, the handbook, README; justfile recipes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-23 19:45:18 -06:00
1 parent 0df7f52ac3
commit 7741e96eb3
87 files changed
+3477 -6476

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
//! The viewer's command line: an unknown argument stops the viewer with
//! status 2, and it opens only books of the data root (D-44): no flag
//! names a loose books folder, its settings file or a vault key.
use std::process::Command;
/// The viewer, run in a home of the test's own with an empty environment
/// but PATH: if an argument it should refuse were ever taken, it would
/// find no books, no display and no preferences of this account's.
fn viewer(home: &std::path::Path) -> Command {
let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui"));
c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home);
c
}
#[test]
fn an_unknown_argument_stops_the_viewer() {
let home = tempfile::Builder::new()
.prefix("wb-gui-args-")
.tempdir()
.unwrap();
let out = viewer(home.path()).arg("--no-such-flag").output().unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let help = viewer(home.path()).arg("--help").output().unwrap();
assert!(help.status.success());
let help = String::from_utf8_lossy(&help.stdout);
assert!(help.contains("--book ID"), "{help}");
for gone in ["--data-dir", "--books-toml", "--vault-key-file"] {
assert!(!help.contains(gone), "{help}");
}
// A gone flag is an unknown argument like any other.
for gone in ["--data-dir", "--books-toml", "--vault-key-file"] {
let out = viewer(home.path()).args([gone, "x"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{gone}");
}
}
+26 -9
View File
@@ -219,9 +219,6 @@ fn a_book_in_the_root_reads_its_own_vault() {
);
let e = b.w9_file(f.member).unwrap_err();
assert!(e.contains("no W-9 file"), "{e}");
// Its key is the book's own: one named on the command line is never
// used for it (none is named here, and none is needed).
assert!(ledger_gui::vault_key_file().is_none());
}
#[test]
@@ -465,12 +462,13 @@ fn the_menus_1099_worksheet_carries_a_payees_full_tin() {
}
/// The menu reads settings as the rest of the viewer and the server do:
/// an older books dir that keeps none in its journal, from its file.
/// the books' own, from their journal; a settings file beside them is
/// never read (D-44).
#[test]
fn the_menu_reads_an_older_books_dirs_settings_file() {
fn the_menu_reads_the_books_own_settings() {
downloads();
let dir = tempfile::Builder::new()
.prefix("wb-gui-older-")
.prefix("wb-gui-kept-")
.tempdir()
.unwrap();
let data = dir.path().join("books-data");
@@ -482,14 +480,33 @@ fn the_menu_reads_an_older_books_dirs_settings_file() {
] {
s.add_account(code, name, ty, None, false).unwrap();
}
s.change_settings(
&[
ledger_core::settings::Change {
key: "company.name".into(),
value: Some(serde_json::json!("Journal Kept Co")),
},
ledger_core::settings::Change {
key: "books.cash_accounts".into(),
value: Some(serde_json::json!(["1000"])),
},
],
&ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: today(),
citation: None,
note: None,
},
)
.unwrap();
}
std::fs::write(
dir.path().join("wanderbooks.toml"),
"[company]\nname = \"File Kept Co\"\n[books]\ncash_accounts = [\"1000\"]\n",
"[company]\nname = \"File Beside Co\"\n",
)
.unwrap();
let b = load(&data);
assert_eq!(b.settings.company.name, "File Kept Co");
assert_eq!(b.settings.company.name, "Journal Kept Co");
let (path, _) = Job::Package.run(&b).unwrap();
let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
let mut readme = String::new();
@@ -497,7 +514,7 @@ fn the_menu_reads_an_older_books_dirs_settings_file() {
.unwrap()
.read_to_string(&mut readme)
.unwrap();
assert!(readme.starts_with("File Kept Co"), "{readme}");
assert!(readme.starts_with("Journal Kept Co"), "{readme}");
}
/// The worksheet says when a reportable payee's TIN isn't in it (the
+6 -6
View File
@@ -3,8 +3,8 @@
//! and the go-ahead is asked as the viewer opens them, with the owner
//! passphrase, handed to the owner the program gives the viewer (the
//! server checks it). "Not now" changes nothing; a refusal is said in the
//! dialog, which stays; books this version made, and older books dirs,
//! are never asked; the viewer writes nothing of the books.
//! dialog, which stays; books this version made, and books outside a
//! data root (the demo's), are never asked; the viewer writes nothing of the books.
use chrono::NaiveDate;
use egui_kittest::Harness;
@@ -95,7 +95,7 @@ fn asked(f: &Fake) -> Vec<String> {
f.asked.lock().unwrap().clone()
}
/// Books `acme`: in a data root (`root`) or an older books dir, in journal
/// Books `acme`: in a data root (`root`) or outside one, in journal
/// format 1 (as an older version wrote them) or this version's (`old`).
fn books(root: bool, old: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
@@ -320,11 +320,11 @@ fn an_upgrade_that_ends_without_an_answer_never_holds_the_window() {
assert!(h.query_by_label(ASKS).is_none(), "and closes");
}
/// Books this version made wait for nothing, and older books dirs (no
/// owner's server for them) are never asked: no notice, no dialog, no
/// Books this version made wait for nothing, and books outside a data
/// root (no owner's server for them) are never asked: no notice, no dialog, no
/// menu item.
#[test]
fn books_in_this_versions_format_and_older_books_dirs_are_never_asked() {
fn books_in_this_versions_format_and_books_outside_a_root_are_never_asked() {
let _alone = alone();
let _ = fake();
for (root, old) in [(true, false), (false, true), (false, false)] {
+30 -17
View File
@@ -1,4 +1,4 @@
//! Live updates: a change written to the books (by the MCP server, in
//! Live updates: a change written to the books (by the books server, in
//! practice) reaches the GUI's view model on its own, without a restart,
//! and the changed entry is marked fresh; a show request reaches it, and
//! while it runs it holds the books' viewer presence.
@@ -121,16 +121,21 @@ fn a_change_in_another_process_shows_up_live() {
);
}
/// An older books dir that keeps no settings in its journal and has no
/// settings file: one written while the viewer runs is followed, as its
/// server follows it.
/// A setting the server journals while the viewer runs is followed (the
/// books' own settings, the only ones the viewer reads: D-44); a settings
/// file beside the books is never read.
#[test]
fn a_settings_file_made_later_is_followed() {
let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id()));
fn a_setting_changed_while_it_runs_is_followed() {
let dir = std::env::temp_dir().join(format!("ledger-gui-live-set-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let _cleanup = TestDir(dir.clone());
let data = dir.join("books-data");
drop(Store::create(&data, Currency::USD).unwrap());
std::fs::write(
dir.join("wanderbooks.toml"),
"[company]\nname = \"Beside Co\"\n",
)
.unwrap();
let loader = Loader::spawn(
Source {
data_dir: data.clone(),
@@ -139,23 +144,31 @@ fn a_settings_file_made_later_is_followed() {
egui::Context::default(),
);
match loader.wait(Duration::from_secs(10)) {
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"),
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Beside Co"),
_ => panic!("no initial load"),
}
std::fs::write(
dir.join("wanderbooks.toml"),
"[company]\nname = \"Made Later Co\"\n",
)
.unwrap();
let mut store = Store::open(&data).unwrap();
store
.change_settings(
&[ledger_core::settings::Change {
key: "company.name".into(),
value: Some(serde_json::json!("Made Later Co")),
}],
&ledger_core::settings::Source {
decided_by: "Tobin Harlow".into(),
decided_on: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(),
citation: None,
note: None,
},
)
.unwrap();
drop(store);
let deadline = Instant::now() + Duration::from_secs(10);
loop {
assert!(
Instant::now() < deadline,
"the new settings file was never read"
);
assert!(Instant::now() < deadline, "the new setting was never read");
match loader.wait(Duration::from_millis(200)) {
Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break,
Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"),
Some(Update::Error(e)) => panic!("the books didn't load: {e}"),
_ => {}
}
}
+22 -52
View File
@@ -480,62 +480,32 @@ fn books_that_keep_their_settings_show_them() {
}
}
/// Books that keep no settings in their journal are read as their server
/// reads them (plan 3.6): an older books dir from its settings file; a
/// book of the data root never from a file, so with the defaults.
/// Books that keep no settings in their journal show the defaults, as
/// their server reads them (plan 3.6, D-44): no settings file or
/// `config.json` beside them is read, in a data root or not.
#[test]
fn books_that_keep_no_settings_read_their_file_only_outside_a_root() {
fn books_that_keep_no_settings_read_no_file_beside_them() {
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books")
.join("shares_big");
// An older books dir: its server reads wanderbooks.toml, so the viewer
// does too.
let t = temp("said-file");
copy_books(&from, &t.0.join("books-data"));
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
let b = load(&t);
assert_eq!(b.settings.company.name, "Old Books Co");
assert!(!b.settings.company.members.is_empty());
// The same books in a data root: no file is read, whatever lies
// beside them; they show the defaults until their settings are in
// their journal.
let t = temp("said-defaults");
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
copy_books(&from, &t.0.join("books-data"));
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
let b = load(&t);
assert_eq!(
b.settings,
ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD")
);
}
/// An older books dir with no settings file but a `config.json`: its
/// receipt rule is the server's, from that file (the regression round 2
/// of slice 6's review found).
#[test]
fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../ledger-store/tests/fixtures/old-books")
.join("shares_big");
let t = temp("said-config");
copy_books(&from, &t.0.join("books-data"));
std::fs::write(
t.0.join("books-data/config.json"),
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
)
.unwrap();
assert_eq!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
// In a data root, no file: the books' own rule (the default).
let t = temp("said-config-root");
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
copy_books(&from, &t.0.join("books-data"));
std::fs::write(
t.0.join("books-data/config.json"),
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
)
.unwrap();
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
let defaults =
ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD");
for (tag, in_root) in [("said-file", false), ("said-root", true)] {
let t = temp(tag);
if in_root {
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
}
copy_books(&from, &t.0.join("books-data"));
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
std::fs::write(
t.0.join("books-data/config.json"),
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
)
.unwrap();
let b = load(&t);
assert_eq!(b.settings, defaults, "{tag}");
assert_ne!(b.receipt_threshold, rust_decimal::Decimal::ZERO, "{tag}");
}
}
/// rules.md B-37: the classes an earlier version wrote read as projects
+3 -3
View File
@@ -1,8 +1,8 @@
//! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each
//! item hands its work to the owner the program gives the viewer, off the
//! UI thread, and says what came of it; a restore is previewed first; the
//! viewer itself writes nothing of the books; without an owner, or for an
//! older books dir, the menu isn't there.
//! viewer itself writes nothing of the books; without an owner, or for
//! books outside a data root (the demo's), the menu isn't there.
use chrono::NaiveDate;
use egui_kittest::Harness;
@@ -102,7 +102,7 @@ fn today() -> NaiveDate {
}
/// Books in a data root (`root`: a folder holding the root's marker), or
/// an older books dir's.
/// outside one (as the demo's are).
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
let dir = tempfile::Builder::new()
.prefix("wb-gui-owner-")
+20 -3
View File
@@ -1,7 +1,8 @@
//! P3's seams in the viewer: on the seam review's books (a year of a
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), the
//! viewer's figures, read through its own [`Books`] from the books' folder
//! and settings file as it opens them, are the engine's (the server's
//! as it opens them (their settings journaled, as the books keep them:
//! D-44), are the engine's (the server's
//! tools make theirs with the same calls and settings): each member's
//! capital statement, party money, the cash flow statement, the fixed
//! asset register and the 1099 worksheet, on several dates.
@@ -9,7 +10,7 @@
#[path = "../../ledger-package/tests/seams/books.rs"]
mod books;
use books::{books, d};
use books::{SETTINGS_TOML, books, d};
use chrono::NaiveDate;
use ledger_gui::books::Books as Viewer;
use ledger_gui::{Source, data};
@@ -27,7 +28,23 @@ fn viewer(dir: &std::path::Path, today: NaiveDate) -> Viewer {
#[test]
fn the_viewers_figures_are_the_engines_on_every_date() {
let dir = tempfile::tempdir().unwrap();
let b = books(dir.path());
let mut b = books(dir.path());
// The settings in the books, where the viewer reads them (it reads no
// file beside them).
let import =
ledger_config::codec::import(SETTINGS_TOML, None, b.store.ledger().settings(), "USD")
.unwrap();
b.store
.change_settings(
&import.changes,
&ledger_core::settings::Source {
decided_by: "the test".into(),
decided_on: d(START),
citation: None,
note: None,
},
)
.unwrap();
let l = b.store.ledger();
let cash: Vec<_> = b
.cash
+21 -31
View File
@@ -21,7 +21,7 @@ use ledger_gui::nav::Route;
use ledger_gui::{App, Source, data};
use ledger_store::Store;
use ledger_vault::{Tin, Vault};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
const SSN: &str = "123-45-6789";
@@ -38,17 +38,19 @@ impl Drop for Fixture {
}
}
/// Books with one member whose SSN is in the vault; the key lives outside
/// the books dir, as the server requires.
/// Books with one member whose SSN is in the vault, the vault and its key
/// where a book of the data root keeps them (its own `vault/` and
/// `keys/`).
fn fixture(tag: &str) -> Fixture {
let root = std::env::temp_dir().join(format!("ledger-gui-tin-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let books = root.join("books");
let key = root.join("keys/vault.key");
std::fs::create_dir_all(&books).unwrap();
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
let key = books
.join(ledger_store::root::KEYS_DIR)
.join(ledger_store::root::LEGACY_KEY_FILE);
let mut s = Store::create(&books, ledger_core::Currency::USD).unwrap();
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
let v = Vault::open(&books.join(ledger_store::root::VAULT_DIR), &key, true).unwrap();
let hash = v
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
.unwrap();
@@ -70,14 +72,12 @@ fn fixture(tag: &str) -> Fixture {
}
}
fn load(f: &Fixture, key: Option<&Path>) -> Books {
let mut b = data::load_once(&Source {
data_dir: f.books.join("books-data"),
fn load(f: &Fixture) -> Books {
data::load_once(&Source {
data_dir: f.books.clone(),
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()),
})
.unwrap();
b.settings.vault_key_file = key.map(Path::to_path_buf);
b
.unwrap()
}
fn copied(h: &Harness<'_, App>) -> Vec<String> {
@@ -102,7 +102,7 @@ fn no_digits(s: &str) {
#[test]
fn reads_the_vault_with_the_key_in_force() {
let f = fixture("read");
let b = load(&f, Some(&f.key));
let b = load(&f);
assert_eq!(b.tin_from_vault(f.party).unwrap(), SSN);
let e = b.tin_from_vault(f.party + 1).unwrap_err();
assert!(e.contains("Not in the vault"), "{e}");
@@ -112,22 +112,12 @@ fn reads_the_vault_with_the_key_in_force() {
#[test]
fn a_missing_key_is_locked_and_never_created() {
let f = fixture("locked");
let gone = f.root.join("elsewhere/vault.key");
let b = load(&f, Some(&gone));
std::fs::remove_file(&f.key).unwrap();
let b = load(&f);
let e = b.tin_from_vault(f.party).unwrap_err();
assert!(e.contains("locked"), "{e}");
no_digits(&e);
assert!(!gone.exists(), "reading must never create a key");
}
/// INVARIANT 3: a key file inside the books dir is refused.
#[test]
fn a_key_inside_the_books_is_refused() {
let f = fixture("inside");
let inside = f.books.join("vault.key");
std::fs::copy(&f.key, &inside).unwrap();
let e = load(&f, Some(&inside)).tin_from_vault(f.party).unwrap_err();
assert!(e.contains("inside the books folder"), "{e}");
assert!(!f.key.exists(), "reading must never create a key");
}
/// INVARIANT 4: a tampered record is unreadable, and says nothing of it.
@@ -139,7 +129,7 @@ fn a_bad_record_is_an_error_without_digits() {
let last = raw.len() - 1;
raw[last] ^= 1;
std::fs::write(&rec, raw).unwrap();
let e = load(&f, Some(&f.key)).tin_from_vault(f.party).unwrap_err();
let e = load(&f).tin_from_vault(f.party).unwrap_err();
no_digits(&e);
}
@@ -156,7 +146,7 @@ fn party_page(b: Books, party: PartyId) -> Harness<'static, App> {
fn clicking_the_masked_tin_copies_the_full_number() {
let f = fixture("click");
let shown = "TIN on file ••••6789 (SSN)";
let mut h = party_page(load(&f, Some(&f.key)), f.party);
let mut h = party_page(load(&f), f.party);
assert!(
h.query_by_label_contains("12345").is_none(),
"the full number is never drawn"
@@ -168,8 +158,8 @@ fn clicking_the_masked_tin_copies_the_full_number() {
assert!(h.query_by_label_contains("12345").is_none());
// Locked: nothing copied; the reason shows beside it.
let gone = f.root.join("elsewhere/vault.key");
let mut h = party_page(load(&f, Some(&gone)), f.party);
std::fs::remove_file(&f.key).unwrap();
let mut h = party_page(load(&f), f.party);
h.get_by_label(shown).click();
h.step();
assert!(copied(&h).is_empty());
-84
View File
@@ -1,84 +0,0 @@
//! The viewer's command line: `--vault-key-file` names the vault key and
//! wins over a settings file's `[vault] key_file`, as it does for the
//! server; an unknown argument stops the viewer with status 2. (Its own
//! test binary: the key file is named once per process.)
use ledger_core::{Role, TinFacts, TinType};
use ledger_gui::{Source, data};
use ledger_store::Store;
use ledger_vault::{Tin, Vault};
const SSN: &str = "123-45-6789";
/// A test's dir, removed when the test ends, pass or panic.
struct TestDir(std::path::PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[test]
fn the_key_named_on_the_command_line_wins() {
let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let _cleanup = TestDir(root.clone());
let books = root.join("books");
let key = root.join("keys/vault.key");
std::fs::create_dir_all(&books).unwrap();
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
let hash = v
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
.unwrap();
s.set_party_tin(
party,
Some(TinFacts {
tin_type: TinType::Ssn,
last4: "6789".into(),
vault_record: Some(hash),
purged: None,
}),
)
.unwrap();
drop(s);
// Named first, so the default key location is never consulted.
assert_eq!(ledger_gui::vault_key_file(), None);
ledger_gui::set_vault_key_file(key.clone());
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
// Named once: a second naming changes nothing.
ledger_gui::set_vault_key_file(root.join("elsewhere.key"));
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
let mut b = data::load_once(&Source {
data_dir: books.join("books-data"),
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()),
})
.unwrap();
// The settings name a key that isn't there; the command line wins.
b.settings.vault_key_file = Some(root.join("missing/vault.key"));
assert_eq!(b.tin_from_vault(party).unwrap(), SSN);
}
#[test]
fn an_unknown_argument_stops_the_viewer() {
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
.arg("--no-such-flag")
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2));
assert!(
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let help = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
.arg("--help")
.output()
.unwrap();
assert!(help.status.success());
assert!(String::from_utf8_lossy(&help.stdout).contains("--vault-key-file PATH"));
}