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

+5 -5
View File
@@ -44,8 +44,9 @@ pub struct Import {
pub notes: Vec<String>,
/// Entries the books don't know: not imported (INVARIANT 3).
pub unknown: Vec<String>,
/// `[vault] key_file`, if the file sets one: the server's to use
/// (`--vault-key-file`), not a books setting.
/// `[vault] key_file`, if the file sets one: not a books setting (a
/// book in the data root keeps its own key: `books adopt --vault-key`
/// brings it in).
pub vault_key_file: Option<String>,
/// The file's comments, in order: a settings file's notes (where a
/// value came from, what it waits on) are not settings, so they are
@@ -151,7 +152,7 @@ pub fn import(
("vault", "key_file") => {
out.vault_key_file = v.as_str().map(str::to_string);
out.notes.push(
"[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(),
"[vault] key_file: not a books setting (a book in the data root keeps its own key: adopt with --vault-key naming that file); dropped".into(),
);
}
("viewer", n) => out.notes.push(format!(
@@ -332,7 +333,6 @@ pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
investing_accounts: s.investing_accounts(),
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
parties: s.parties(),
vault_key_file: None,
timezone: s.timezone().map(str::to_string),
}
}
@@ -395,7 +395,7 @@ pub fn export(s: &Settings, base_currency: &str) -> String {
.unwrap_or_default()
);
out.push_str(
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n",
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n",
);
for table in ["company", "books"] {
let _ = write!(out, "\n[{table}]\n");
+24 -57
View File
@@ -1,14 +1,17 @@
//! Company settings from the books dir's `wanderbooks.toml`.
//! Company settings as an older books dir's `wanderbooks.toml` holds them.
//!
//! An older books dir keeps that file next to `books-data/` until
//! `books.import_settings` moves it into the journal (books of the data
//! root never read one). The server reads the company name (report headers),
//! the fiscal year start (fiscal-year-to-date reports), the members of a
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
//! for the statement of members' equity), `[books] accounting_basis`
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
//! the next start, so the two can't disagree.
//! Nothing that serves or shows books reads that file (D-44): the books
//! keep their own settings in their journal. The file is read once, when
//! `wanderbooks books adopt` brings an older books dir in whole (`codec`
//! moves its settings, and its `config.json`'s receipt policy, into the
//! journal), and `books.export_settings` writes the books' settings out as
//! one. It holds the company name (report headers), the fiscal year start
//! (fiscal-year-to-date reports), the members of a multi-member LLC
//! (`[[company.members]]`: name, share, capital account — for the
//! statement of members' equity), `[books] accounting_basis` (report
//! footers), and `[books] receipt_threshold` (the receipt policy, unless
//! the books dir's `config.json` sets one). The fiscal year *end* is never
//! stored: it is the day before the next start, so the two can't disagree.
pub mod codec;
pub mod receipt_policy;
@@ -17,9 +20,9 @@ pub use receipt_policy::ReceiptPolicy;
use rust_decimal::Decimal;
use serde::Deserialize;
use std::path::Path;
/// What the server takes from `wanderbooks.toml`.
/// What a `wanderbooks.toml` says, read (`parse`); and the books' own
/// settings in the same shape (`codec::books_settings`).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BooksSettings {
/// `[company]`.
@@ -52,9 +55,6 @@ pub struct BooksSettings {
/// `[books] parties`: how hard the agent asks for each entry's
/// counterparty. Unset: [`PartyPolicy::Suggest`].
pub parties: PartyPolicy,
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
/// default (`~/.config/wanderbooks/vault.key`).
pub vault_key_file: Option<std::path::PathBuf>,
/// `[company] timezone`: the company's time zone (an IANA name), what
/// "today" means for the books. Unset: the computer's.
pub timezone: Option<String>,
@@ -210,32 +210,10 @@ fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
.collect()
}
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
/// file sets one, else `[books] receipt_threshold`, else the default
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
/// the viewer both call this, so they enforce and show one rule.
pub fn effective_receipt_policy(
config_json: &Path,
settings: &BooksSettings,
) -> Result<ReceiptPolicy, String> {
let raw = if config_json.exists() {
Some(
std::fs::read_to_string(config_json)
.map_err(|e| format!("{}: {e}", config_json.display()))?,
)
} else {
None
};
effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| {
e.replace(
"bad config",
&format!("bad config {}", config_json.display()),
)
})
}
/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no
/// such file).
/// The receipt policy in force: `config_json`'s `receipt_policy` (the
/// text of an older books dir's `config.json`; `None`: no such file) when
/// it sets one, else `[books] receipt_threshold`, else the default
/// ($25.00); validated (crate::receipt_policy INVARIANT 4).
pub fn effective_receipt_policy_text(
config_json: Option<&str>,
settings: &BooksSettings,
@@ -448,6 +426,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
})
.collect();
}
// The key file is no books setting (`codec` notes it), but an empty
// one is still a broken file.
if raw.vault.key_file.is_some_and(|p| p.trim().is_empty()) {
return Err("[vault] key_file is empty".into());
}
let timezone = match raw.company.timezone {
Some(z) => {
let key = ledger_core::settings::key("company.timezone").expect("a known key");
@@ -471,16 +454,6 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
vault_key_file: match raw.vault.key_file {
Some(p) if p.trim().is_empty() => {
return Err("[vault] key_file is empty".into());
}
Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") {
Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()),
None => p,
})),
None => None,
},
asset_tag_prefix: match raw.books.asset_tag_prefix {
Some(p)
if !p.is_empty()
@@ -513,12 +486,6 @@ fn toml_json(s: &str) -> serde_json::Value {
serde_json::Value::String(s.to_string())
}
/// Load `wanderbooks.toml` from a path.
pub fn load(path: &Path) -> Result<BooksSettings, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;