From 920b15ba82cfee28d25025589a9429a21e9e55cf Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Sun, 18 Oct 2026 11:13:43 -0600 Subject: [PATCH] P2 slice 1: one executable (D-22) The server and the viewer are libraries with run(prog, args), returning Exit(code) instead of ending the process; ledger-mcp and ledger-gui are one-line shims (the live books keep using them until the cutover). wanderbooks dispatches dev-stdio and gui to them in-process; a mode must come first and owns its flags. The gui feature (default) leaves the viewer out of the server-only build, which just server-only builds, clippies, tests, and checks for window libraries. The viewer's mutation recipe is retired (owner, 2026-10-18); its two musts are tested directly. Two review rounds; mutation on the diff (docs/testing.md). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 2 + crates/ledger-cli/Cargo.toml | 10 + crates/ledger-cli/src/main.rs | 136 ++++++++-- crates/ledger-cli/tests/one_executable.rs | 220 +++++++++++++++ crates/ledger-gui/src/lib.rs | 111 ++++++++ crates/ledger-gui/src/main.rs | 85 +----- crates/ledger-mcp/Cargo.toml | 3 + crates/ledger-mcp/src/lib.rs | 314 ++++++++++++++++++++++ crates/ledger-mcp/src/main.rs | 279 +------------------ docs/testing.md | 16 +- justfile | 31 ++- plan/served.md | 3 +- 12 files changed, 832 insertions(+), 378 deletions(-) create mode 100644 crates/ledger-cli/tests/one_executable.rs create mode 100644 crates/ledger-mcp/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index 5b7aa79..b2f5fb3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3655,6 +3655,8 @@ dependencies = [ "chrono", "ledger-config", "ledger-core", + "ledger-gui", + "ledger-mcp", "serde_json", "tempfile", "toml 1.1.8+spec-1.1.0", diff --git a/crates/ledger-cli/Cargo.toml b/crates/ledger-cli/Cargo.toml index 158921b..421d204 100644 --- a/crates/ledger-cli/Cargo.toml +++ b/crates/ledger-cli/Cargo.toml @@ -10,6 +10,16 @@ description = "wanderbooks CLI: one command from nothing to a working books dire name = "wanderbooks" path = "src/main.rs" +[features] +default = ["gui"] +# The viewer. Off in the server-only build for another machine (plan +# D-22, P9), so a headless machine carries no window libraries. +gui = ["dep:ledger-gui"] + +[dependencies] +ledger-mcp = { path = "../ledger-mcp" } +ledger-gui = { path = "../ledger-gui", optional = true } + [dev-dependencies] chrono = "0.4" ledger-config = { path = "../ledger-config" } diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 066b9aa..185a615 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -3,9 +3,15 @@ //! //! ```text //! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init) -//! wanderbooks gui [DIR] +//! wanderbooks gui [DIR | viewer flags] +//! wanderbooks dev-stdio [server flags] //! ``` //! +//! `wanderbooks` is the one executable (plan D-22): `gui` is the viewer and +//! `dev-stdio` the books server over stdio, run in this process. A build +//! without the `gui` feature (the server-only build for another machine) +//! has no viewer. +//! //! `new` makes a complete books directory and starts the agent in it: //! //! 1. Finds `ledger-mcp` and `ledger-gui` next to this binary or on the @@ -79,7 +85,7 @@ gui-fg: # Is the viewer open for these books? Prints open/closed; exits 0 only if open gui-status: - @pgrep -f -- "ledger-gui --data-dir {{justfile_directory()}}/books-data( |$)" >/dev/null && echo open || { echo closed; exit 1; } + @pgrep -f -- "(ledger-gui|wanderbooks gui) --data-dir {{justfile_directory()}}/books-data( |$)|wanderbooks gui {{justfile_directory()}}/?$" >/dev/null && echo open || { echo closed; exit 1; } # The agents' session-start hook: the viewer's state, handled per [viewer] open_at_start session-start: @@ -384,10 +390,12 @@ const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up wi fn print_usage() { eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]"); - eprintln!(" wanderbooks gui [DIR]"); + eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]"); + eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]"); eprintln!(); eprintln!(" new make a books directory and start the agent in it (alias: init)"); eprintln!(" gui open the live, read-only view of a books directory"); + eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)"); eprintln!(" DIR the books directory (default: current directory)"); eprintln!(" --force rewrite the scaffold in an existing books directory"); eprintln!(" --no-start don't start the agent afterwards"); @@ -743,24 +751,72 @@ fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String> Ok(()) } -fn gui(dir: &Path) -> Result<(), String> { - let gui = find_binary("ledger-gui") - .ok_or("ledger-gui isn't installed; run `wanderbooks new` or `just update`")?; - let status = Command::new(gui) - .arg("--data-dir") - .arg(dir.join("books-data")) - .arg("--books-toml") - .arg(dir.join("wanderbooks.toml")) - .status() - .map_err(|e| e.to_string())?; - if status.success() { - Ok(()) - } else { - Err("ledger-gui exited with an error".into()) +/// The viewer's arguments for `wanderbooks gui ARGS`: a lone books +/// directory (none means this one) becomes its `books-data/` and +/// `wanderbooks.toml`, named explicitly as before the viewer moved into +/// this executable, so a settings file made later is still followed; +/// anything else is the viewer's own flags, passed as they are. +#[cfg(feature = "gui")] +fn gui_args(args: Vec) -> Vec { + match args.as_slice() { + [] => gui_args(vec![".".into()]), + [dir] if !dir.starts_with('-') => { + let dir = Path::new(dir); + vec![ + "--data-dir".into(), + dir.join("books-data").display().to_string(), + "--books-toml".into(), + dir.join("wanderbooks.toml").display().to_string(), + ] + } + _ => args, + } +} + +/// `wanderbooks gui`: the viewer, in this process. +#[cfg(feature = "gui")] +fn run_gui(args: Vec) -> i32 { + match ledger_gui::run("wanderbooks gui", gui_args(args)) { + Ok(()) => 0, + Err(e) => match e.downcast_ref::() { + Some(exit) => exit.0, + None => { + eprintln!("error: {e}"); + 1 + } + }, + } +} + +/// `wanderbooks gui` in a build without the viewer. +#[cfg(not(feature = "gui"))] +fn run_gui(_args: Vec) -> i32 { + eprintln!("error: this wanderbooks is the server-only build: it has no viewer"); + 2 +} + +/// `wanderbooks dev-stdio`: the books server over stdio, in this process. +fn run_dev_stdio(args: Vec) -> i32 { + match ledger_mcp::run("wanderbooks dev-stdio", args) { + Ok(()) => 0, + Err(e) => match e.downcast_ref::() { + Some(exit) => exit.0, + None => { + eprintln!("error: {e}"); + 1 + } + }, } } fn main() { + // The modes run in this process and take their own flags. + let mut all = env::args().skip(1); + match all.next().as_deref() { + Some("gui") => std::process::exit(run_gui(all.collect())), + Some("dev-stdio") => std::process::exit(run_dev_stdio(all.collect())), + _ => {} + } let mut dir: Option = None; let mut force = false; let mut start = true; @@ -789,10 +845,13 @@ fn main() { } } } + if let Some(mode @ ("gui" | "dev-stdio")) = cmd.as_deref() { + eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]"); + std::process::exit(2); + } let dir = dir.unwrap_or_else(|| PathBuf::from(".")); let result = match cmd.as_deref() { Some("new") | Some("init") => new(&dir, force, start, agent), - Some("gui") => gui(&dir), _ => { print_usage(); std::process::exit(2); @@ -803,3 +862,44 @@ fn main() { std::process::exit(1); } } + +#[cfg(all(test, feature = "gui"))] +mod gui_args_tests { + use super::gui_args; + + fn v(args: &[&str]) -> Vec { + args.iter().map(|a| a.to_string()).collect() + } + + #[test] + fn a_lone_books_dir_names_its_data_and_settings() { + let dir = std::path::Path::new("some/books"); + assert_eq!( + gui_args(v(&["some/books"])), + v(&[ + "--data-dir", + &dir.join("books-data").display().to_string(), + "--books-toml", + &dir.join("wanderbooks.toml").display().to_string(), + ]) + ); + } + + #[test] + fn no_dir_means_this_one_named_explicitly() { + assert_eq!(gui_args(v(&[])), gui_args(v(&["."]))); + assert_eq!(gui_args(v(&[]))[0], "--data-dir"); + } + + #[test] + fn flags_and_anything_else_pass_as_they_are() { + for args in [ + v(&["--demo"]), + v(&["--data-dir", "x"]), + v(&["a", "b"]), + v(&["--help"]), + ] { + assert_eq!(gui_args(args.clone()), args); + } + } +} diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs new file mode 100644 index 0000000..beaf8b1 --- /dev/null +++ b/crates/ledger-cli/tests/one_executable.rs @@ -0,0 +1,220 @@ +//! One executable (plan D-22): `wanderbooks` runs the books server +//! (`dev-stdio`) and the viewer (`gui`) as modes of itself, each with its +//! own flags, and the server-only build has no viewer. + +use serde_json::{Value, json}; +use std::io::{BufRead, BufReader, Write}; +use std::process::{Command, Stdio}; + +fn wanderbooks() -> Command { + Command::new(env!("CARGO_BIN_EXE_wanderbooks")) +} + +/// One JSON-RPC request over the child's stdio, its response parsed. +fn ask( + stdin: &mut std::process::ChildStdin, + stdout: &mut BufReader, + id: u64, + method: &str, + params: Value, +) -> Value { + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + writeln!(stdin, "{msg}").unwrap(); + stdin.flush().unwrap(); + let mut line = String::new(); + stdout.read_line(&mut line).expect("read a response"); + let resp: Value = serde_json::from_str(&line).expect("parse the response"); + assert_eq!(resp["id"], id, "{resp}"); + resp +} + +#[test] +fn dev_stdio_is_the_books_server() { + let dir = tempfile::Builder::new() + .prefix("wb-one-exe-") + .tempdir() + .unwrap(); + let data = dir.path().join("books").join("books-data"); + // An import root may not be inside the books dir (the data dir's + // parent), so it is a sibling of it. + let inbox = dir.path().join("inbox"); + std::fs::create_dir(&inbox).unwrap(); + let mut child = wanderbooks() + .arg("dev-stdio") + .arg("--data-dir") + .arg(&data) + .arg("--import-root") + .arg(&inbox) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn wanderbooks dev-stdio"); + let mut stdin = child.stdin.take().unwrap(); + let mut stdout = BufReader::new(child.stdout.take().unwrap()); + let init = ask( + &mut stdin, + &mut stdout, + 1, + "initialize", + json!({"protocolVersion": "2025-06-18", "capabilities": {}, + "clientInfo": {"name": "one-executable", "version": "0"}}), + ); + assert!(init["result"]["serverInfo"].is_object(), "{init}"); + writeln!( + stdin, + "{}", + json!({"jsonrpc": "2.0", "method": "notifications/initialized"}) + ) + .unwrap(); + let tools = ask(&mut stdin, &mut stdout, 2, "tools/list", json!({})); + let names: Vec<&str> = tools["result"]["tools"] + .as_array() + .expect("a tool list") + .iter() + .filter_map(|t| t["name"].as_str()) + .collect(); + assert!(names.contains(&"books.start"), "{names:?}"); + // The books it serves are real books: a fresh store was created. + let tb = ask( + &mut stdin, + &mut stdout, + 3, + "tools/call", + json!({"name": "books.trial_balance", "arguments": {}}), + ); + assert!(tb.get("error").is_none(), "{tb}"); + assert!(data.join("journal.jsonl").is_file()); + drop(stdin); + let status = child.wait().unwrap(); + assert!(status.success(), "{status}"); +} + +#[test] +fn each_mode_names_itself_in_its_usage() { + let out = wanderbooks() + .args(["dev-stdio", "--help"]) + .output() + .unwrap(); + assert!(out.status.success()); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("usage: wanderbooks dev-stdio [--data-dir PATH]"), + "{err}" + ); + + let out = wanderbooks().arg("--help").output().unwrap(); + let err = String::from_utf8_lossy(&out.stderr); + for line in [ + "wanderbooks gui [DIR", + "wanderbooks dev-stdio", + "dev-stdio run the books server", + ] { + assert!(err.contains(line), "{line:?} missing from {err}"); + } +} + +#[cfg(feature = "gui")] +#[test] +fn the_viewer_names_itself_in_its_usage() { + let out = wanderbooks().args(["gui", "--help"]).output().unwrap(); + assert!(out.status.success()); + let text = String::from_utf8_lossy(&out.stdout); + assert!( + text.contains("usage: wanderbooks gui [--data-dir PATH]"), + "{text}" + ); +} + +#[test] +fn a_mode_refuses_what_it_does_not_know() { + let modes: &[&str] = if cfg!(feature = "gui") { + &["dev-stdio", "gui"] + } else { + &["dev-stdio"] + }; + for &mode in modes { + let out = wanderbooks() + .args([mode, "--no-such-flag"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); + } + // A mode's flags are its own: the CLI's `--force` means nothing there. + let out = wanderbooks() + .args(["dev-stdio", "--force"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(2), "{out:?}"); +} + +#[test] +fn a_mode_comes_first() { + for mode in ["gui", "dev-stdio"] { + let out = wanderbooks().args(["--codex", mode]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains(&format!("`{mode}` must come first")), "{err}"); + } +} + +#[test] +fn a_server_that_cannot_start_says_why_and_exits_1() { + // A flag missing its value is an error, not a usage slip (exit 2). + let out = wanderbooks() + .args(["dev-stdio", "--data-dir"]) + .output() + .unwrap(); + assert_eq!(out.status.code(), Some(1), "{out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!(err.contains("error: --data-dir needs a path"), "{err}"); +} + +/// With no display to open a window on, the viewer fails and says so +/// (Linux only: elsewhere there is always a display, and a window would +/// open). +#[cfg(all(feature = "gui", target_os = "linux"))] +#[test] +fn a_viewer_that_cannot_open_a_window_exits_1() { + let dir = tempfile::Builder::new() + .prefix("wb-one-exe-") + .tempdir() + .unwrap(); + let mut child = wanderbooks() + .args(["gui", "--data-dir"]) + .arg(dir.path().join("books-data")) + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") + .env_remove("WAYLAND_SOCKET") + .stdout(Stdio::null()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if std::time::Instant::now() > deadline { + let _ = child.kill(); + panic!("the viewer neither opened nor failed within a minute"); + } + std::thread::sleep(std::time::Duration::from_millis(50)); + }; + assert_eq!(status.code(), Some(1), "{status}"); + let mut err = String::new(); + std::io::Read::read_to_string(&mut child.stderr.take().unwrap(), &mut err).unwrap(); + assert!(err.contains("error: "), "{err}"); +} + +#[cfg(not(feature = "gui"))] +#[test] +fn the_server_only_build_has_no_viewer() { + let out = wanderbooks().args(["gui", "--help"]).output().unwrap(); + assert_eq!(out.status.code(), Some(2), "{out:?}"); + let err = String::from_utf8_lossy(&out.stderr); + assert!( + err.contains("the server-only build: it has no viewer"), + "{err}" + ); +} diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index d7547db..b3638e0 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -23,6 +23,8 @@ mod widgets; pub use app::App; pub use data::Source; +use std::path::PathBuf; + /// The vault key file named on the command line (`--vault-key-file`), /// which wins over a settings file's `[vault] key_file`, as it does for /// the server: books that keep their own settings never name it. @@ -37,3 +39,112 @@ pub fn set_vault_key_file(path: std::path::PathBuf) { pub fn vault_key_file() -> Option { VAULT_KEY_FILE.get().cloned() } + +/// How [`run`] stops when it can't go on: the reason is already printed +/// on stderr, and the number is the process's exit code (2: a bad argument; 1: the demo books couldn't be built). A +/// library never ends the process itself, so the books it opened are +/// closed properly on the way out; the binaries turn this into the exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Exit(pub i32); + +impl std::fmt::Display for Exit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "exit code {}", self.0) + } +} + +impl std::error::Error for Exit {} + +/// Run the viewer with `args` (the command line after the program name); +/// `prog` names the program in the usage text (`ledger-gui`, or +/// `wanderbooks gui`). It opens the books read-only and returns when the +/// window closes. An unknown argument returns [`Exit`] (code 2). +/// +/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`: +/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the +/// current directory; `--demo` builds a demo company in a temp directory +/// and opens that. +pub fn run( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut data_dir: Option = None; + let mut books_toml: Option = None; + let mut today = None; + let mut demo = false; + let mut args = args.into_iter(); + while let Some(a) = args.next() { + match a.as_str() { + "--data-dir" => data_dir = args.next().map(PathBuf::from), + "--books-toml" => books_toml = args.next().map(PathBuf::from), + "--vault-key-file" => { + if let Some(k) = args.next() { + crate::set_vault_key_file(PathBuf::from(k)); + } + } + "--today" => today = args.next().and_then(|d| d.parse().ok()), + "--demo" => demo = true, + "-h" | "--help" => { + println!( + "usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]" + ); + return Ok(()); + } + other => { + eprintln!("unknown argument {other}"); + return Err(Box::new(Exit(2))); + } + } + } + if demo { + let d = today.unwrap_or_else(|| chrono::Local::now().date_naive()); + let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}")); + let _ = std::fs::remove_dir_all(&dir); + if let Err(e) = crate::demo::build(&dir, d) { + eprintln!("building the demo books failed: {e}"); + return Err(Box::new(Exit(1))); + } + eprintln!("demo books: {}", dir.display()); + data_dir = Some(dir.join("books-data")); + books_toml = Some(dir.join("wanderbooks.toml")); + } + let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data")); + let books_toml = books_toml.or_else(|| { + let guess = data_dir + .parent() + .unwrap_or(&data_dir) + .join("wanderbooks.toml"); + guess.exists().then_some(guess) + }); + let source = Source { + data_dir, + books_toml, + today, + }; + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_title("wanderbooks") + .with_app_id("wanderbooks") + .with_inner_size([1440.0, 920.0]) + // Small on purpose: tiling window managers hand out small slots + // regardless, and every screen lays itself out to fit. + .with_min_inner_size([360.0, 400.0]), + ..Default::default() + }; + Ok(eframe::run_native( + "wanderbooks", + options, + Box::new(move |cc| Ok(Box::new(App::new(cc, source)))), + )?) +} + +#[cfg(test)] +mod exit_tests { + use super::Exit; + + #[test] + fn an_exit_says_its_code() { + assert_eq!(Exit(2).to_string(), "exit code 2"); + assert_eq!(Exit(1).to_string(), "exit code 1"); + } +} diff --git a/crates/ledger-gui/src/main.rs b/crates/ledger-gui/src/main.rs index f8d1594..32016c2 100644 --- a/crates/ledger-gui/src/main.rs +++ b/crates/ledger-gui/src/main.rs @@ -1,79 +1,12 @@ -//! `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]` -//! -//! Opens the books read-only and follows them live. With no flags it looks -//! for `books-data/` and `wanderbooks.toml` in the current directory. -//! `--demo` builds a demo company in a temp directory and opens that. +//! `ledger-gui`: the read-only, live viewer. The same viewer is +//! `wanderbooks gui`; both call [`ledger_gui::run`]. -use ledger_gui::{App, Source}; -use std::path::PathBuf; - -fn main() -> eframe::Result<()> { - let mut data_dir: Option = None; - let mut books_toml: Option = None; - let mut today = None; - let mut demo = false; - let mut args = std::env::args().skip(1); - while let Some(a) = args.next() { - match a.as_str() { - "--data-dir" => data_dir = args.next().map(PathBuf::from), - "--books-toml" => books_toml = args.next().map(PathBuf::from), - "--vault-key-file" => { - if let Some(k) = args.next() { - ledger_gui::set_vault_key_file(PathBuf::from(k)); - } - } - "--today" => today = args.next().and_then(|d| d.parse().ok()), - "--demo" => demo = true, - "-h" | "--help" => { - println!( - "usage: ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]" - ); - return Ok(()); - } - other => { - eprintln!("unknown argument {other}"); - std::process::exit(2); - } - } +fn main() -> Result<(), Box> { + match ledger_gui::run("ledger-gui", std::env::args().skip(1)) { + Err(e) => match e.downcast_ref::() { + Some(exit) => std::process::exit(exit.0), + None => Err(e), + }, + done => done, } - if demo { - let d = today.unwrap_or_else(|| chrono::Local::now().date_naive()); - let dir = std::env::temp_dir().join(format!("wanderbooks-demo-{d}")); - let _ = std::fs::remove_dir_all(&dir); - if let Err(e) = ledger_gui::demo::build(&dir, d) { - eprintln!("building the demo books failed: {e}"); - std::process::exit(1); - } - eprintln!("demo books: {}", dir.display()); - data_dir = Some(dir.join("books-data")); - books_toml = Some(dir.join("wanderbooks.toml")); - } - let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data")); - let books_toml = books_toml.or_else(|| { - let guess = data_dir - .parent() - .unwrap_or(&data_dir) - .join("wanderbooks.toml"); - guess.exists().then_some(guess) - }); - let source = Source { - data_dir, - books_toml, - today, - }; - let options = eframe::NativeOptions { - viewport: egui::ViewportBuilder::default() - .with_title("wanderbooks") - .with_app_id("wanderbooks") - .with_inner_size([1440.0, 920.0]) - // Small on purpose: tiling window managers hand out small slots - // regardless, and every screen lays itself out to fit. - .with_min_inner_size([360.0, 400.0]), - ..Default::default() - }; - eframe::run_native( - "wanderbooks", - options, - Box::new(move |cc| Ok(Box::new(App::new(cc, source)))), - ) } diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 35a2e4c..ac6808b 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -6,6 +6,9 @@ license.workspace = true repository.workspace = true description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store." +[lib] +path = "src/lib.rs" + [[bin]] name = "ledger-mcp" path = "src/main.rs" diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs new file mode 100644 index 0000000..3fd3b5d --- /dev/null +++ b/crates/ledger-mcp/src/lib.rs @@ -0,0 +1,314 @@ +//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio. +//! +//! [`run`] is the whole server; the `ledger-mcp` binary and `wanderbooks +//! dev-stdio` (one executable, plan D-22) both call it. +//! +//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] +//! [--import-root DIR]...` +//! +//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db` +//! (the disposable SQLite projection), `blobs/` (content-addressed receipts), +//! and optionally `config.json`. If no store exists, one is created with the +//! configured base currency. Company settings (name, fiscal year start, +//! receipt threshold) come from the books dir's `wanderbooks.toml`, by +//! default the one next to the data dir, and reload whenever it changes +//! (settings.rs). `books.import_local_file` and `books.set_w9`'s +//! `w9_file_path` read only from the import roots: each `--import-root`, +//! or by default the downloads folder (local_files.rs); a root that is +//! `/`, the home folder, or holds the books, the vault or a key is refused +//! at startup. + +mod handbook; +mod local_files; +mod policy; +mod server; +mod settings; +mod tin_files; +mod types; +mod wire; + +use ledger_core::Currency; +use ledger_store::Store; +use local_files::ImportRoots; +use policy::ReceiptPolicy; +use rmcp::ServiceExt; +use serde::Deserialize; +use server::BooksServer; +use std::path::PathBuf; + +/// Server configuration, JSON. All fields optional. +#[derive(Debug, Default, Deserialize)] +struct Config { + /// ISO 4217 base currency for a new store. Default "USD". + #[serde(default)] + base_currency: Option, + // `receipt_policy` is read by ledger_config::effective_receipt_policy. +} + +fn default_data_dir() -> PathBuf { + let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); + PathBuf::from(home).join(".local/share/wanderbooks") +} + +fn print_usage(prog: &str) { + eprintln!( + "usage: {prog} [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..." + ); + eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)"); + eprintln!(" --config JSON config file (default /config.json, optional)"); + eprintln!( + " --books-toml company settings, for books that don't keep their own yet (default /../wanderbooks.toml, optional)" + ); + eprintln!( + " --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)" + ); + eprintln!( + " --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio" + ); + eprintln!( + " --import-root a folder books.import_local_file may read from (repeatable; default: the downloads folder)" + ); +} + +/// How [`run`] stops when it can't go on: the reason is already printed +/// on stderr, and the number is the process's exit code (2: a bad argument or a refused import folder; 1: books from a newer wanderbooks). A +/// library never ends the process itself, so the books it opened are +/// closed properly on the way out; the binaries turn this into the exit. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Exit(pub i32); + +impl std::fmt::Display for Exit { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "exit code {}", self.0) + } +} + +impl std::error::Error for Exit {} + +/// Run the books server with `args` (the command line after the program +/// name). `prog` names the program in the usage text: `ledger-mcp`, or +/// `wanderbooks dev-stdio`. It returns when the client disconnects; a bad +/// argument, books from a newer wanderbooks, or a refused import root +/// return [`Exit`] after a plain message. +pub fn run( + prog: &str, + args: impl IntoIterator, +) -> Result<(), Box> { + let mut data_dir = default_data_dir(); + let mut config_path: Option = None; + let mut books_toml: Option = None; + let mut vault_key_file: Option = None; + let mut http_addr: Option = None; + let mut import_roots: Vec = Vec::new(); + let mut args = args.into_iter(); + while let Some(arg) = args.next() { + match arg.as_str() { + "--data-dir" => { + data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?); + } + "--config" => { + config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?)); + } + "--books-toml" => { + books_toml = Some(PathBuf::from( + args.next().ok_or("--books-toml needs a path")?, + )); + } + "--vault-key-file" => { + vault_key_file = Some(PathBuf::from( + args.next().ok_or("--vault-key-file needs a path")?, + )); + } + "--http" => { + http_addr = Some(args.next().ok_or("--http needs an address")?); + } + "--import-root" => { + import_roots.push(PathBuf::from( + args.next().ok_or("--import-root needs a folder")?, + )); + } + "--help" | "-h" => { + print_usage(prog); + return Ok(()); + } + other => { + eprintln!("unknown argument: {other}"); + print_usage(prog); + return Err(Box::new(Exit(2))); + } + } + } + let import_roots = if import_roots.is_empty() { + ImportRoots::defaults() + } else { + ImportRoots::new(import_roots) + }; + let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json")); + // config.json's base currency matters only to books being created: it + // is read then, and never otherwise (books that keep their own + // settings read no file). + let read_config = || -> Result> { + if config_path.exists() { + let raw = std::fs::read_to_string(&config_path)?; + Ok(serde_json::from_str(&raw) + .map_err(|e| format!("bad config {}: {e}", config_path.display()))?) + } else { + Ok(Config::default()) + } + }; + // Logs go to stderr: stdout is the MCP channel. + eprintln!("ledger-mcp: data dir {}", data_dir.display()); + // An explicit --books-toml must exist; the default (beside the data + // dir) is optional. Either is reloaded whenever it changes. + let source = settings::Source { + required: books_toml.is_some(), + books_toml: books_toml.unwrap_or_else(|| { + data_dir + .parent() + .map(|p| p.join("wanderbooks.toml")) + .unwrap_or_else(|| PathBuf::from("wanderbooks.toml")) + }), + config_json: config_path.clone(), + }; + + let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() { + match open_waiting_for_lock(&data_dir) { + Ok(store) => store, + // Books from a newer wanderbooks are fine; this server is old. + // Say so in plain words (the error's text), and leave them be. + Err(e @ ledger_store::StoreError::NewerJournal { .. }) => { + eprintln!("ledger-mcp: {e}"); + return Err(Box::new(Exit(1))); + } + Err(e) => return Err(e.into()), + } + } else { + let config = read_config()?; + let code = config.base_currency.as_deref().unwrap_or("USD"); + let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?; + eprintln!("ledger-mcp: creating new store (base currency {code})"); + Store::create(&data_dir, currency)? + }; + // The journal is the source of truth: verify the chain, the replay, and + // the whole projection before serving a single tool call, rebuilding + // the projection if it drifted. + let report = store + .verify_and_heal() + .map_err(|e| format!("store verify failed: {e}"))?; + if let Some(why) = &report.healed { + eprintln!("ledger-mcp: rebuilt the database from the journal ({why})"); + } + // Books that keep their own settings never read a settings file, not + // even to start (settings INVARIANT 1); others read it as before. + let mut live = if store.ledger().settings().is_journaled() { + settings::Live::journaled(source) + } else { + settings::Live::watching(source)? + } + .with_vault_key_file(vault_key_file); + live.sync(store.ledger().settings(), store.base_currency().code()); + eprintln!( + "ledger-mcp: company {:?} (settings from {})", + live.current().company.name, + if live.is_journaled() { + "the books" + } else { + "the settings file" + } + ); + let live = std::sync::Arc::new(std::sync::Mutex::new(live)); + // No import root may be `/`, the home folder, or hold the books, the + // vault or a key (local_files.rs INVARIANT 0): a named one stops the + // server here, a default one is dropped. + let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current(); + let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) { + Ok(roots) => roots, + Err(why) => { + eprintln!("ledger-mcp: {why}"); + return Err(Box::new(Exit(2))); + } + }; + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build()?; + if let Some(addr) = http_addr { + eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); + rt.block_on(serve_http(addr, store, live, import_roots))?; + return Ok(()); + } + + eprintln!("ledger-mcp: store verified, serving over stdio"); + let server = BooksServer::new(store, ReceiptPolicy::default()) + .with_live_settings(live) + .with_import_roots(import_roots); + rt.block_on(async { + // The wire answers what JSON-RPC requires that rmcp drops (wire.rs). + let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); + let running = server.serve((input, output)).await?; + let _ = running.waiting().await; + let _ = writer.await; + Ok::<(), Box>(()) + }) +} + +/// Serve MCP over Streamable HTTP (for network clients). +async fn serve_http( + addr: String, + store: Store, + live: std::sync::Arc>, + import_roots: ImportRoots, +) -> Result<(), Box> { + use rmcp::transport::streamable_http_server::{ + StreamableHttpService, session::local::LocalSessionManager, + }; + use std::sync::Arc; + + // One store for every connection: the async mutex keeps it + // single-writer, and tool bodies run on blocking threads (server.rs). + let store = server::SharedStore::new(store); + let service = StreamableHttpService::new( + move || { + let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) + .with_live_settings(live.clone()) + .with_import_roots(import_roots.clone()); + Ok(server) + }, + Arc::new(LocalSessionManager::default()), + Default::default(), + ); + + let router = axum::Router::new().nest_service("/mcp", service); + let listener = tokio::net::TcpListener::bind(&addr).await?; + eprintln!("ledger-mcp: listening on http://{addr}/mcp"); + axum::serve(listener, router).await?; + Ok(()) +} + +/// Open the books, waiting up to ten seconds for another writer to let go. +/// A restarted server (a client reconnecting) often starts while the old +/// process is still exiting with the lock; waiting turns that race into a +/// short delay. A writer that keeps the books longer is a real conflict, +/// and the refusal says so. +fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result { + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); + loop { + match Store::open(data_dir) { + Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => { + std::thread::sleep(std::time::Duration::from_millis(100)); + } + other => return other, + } + } +} + +#[cfg(test)] +mod exit_tests { + use super::Exit; + + #[test] + fn an_exit_says_its_code() { + assert_eq!(Exit(2).to_string(), "exit code 2"); + assert_eq!(Exit(1).to_string(), "exit code 1"); + } +} diff --git a/crates/ledger-mcp/src/main.rs b/crates/ledger-mcp/src/main.rs index 4b87a8f..8e0a346 100644 --- a/crates/ledger-mcp/src/main.rs +++ b/crates/ledger-mcp/src/main.rs @@ -1,277 +1,12 @@ -//! `ledger-mcp`: the AI-first bookkeeping interface, over stdio. -//! -//! Usage: `ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] -//! [--import-root DIR]...` -//! -//! The data dir holds `journal.jsonl` (the source of truth), `wanderbooks.db` -//! (the disposable SQLite projection), `blobs/` (content-addressed receipts), -//! and optionally `config.json`. If no store exists, one is created with the -//! configured base currency. Company settings (name, fiscal year start, -//! receipt threshold) come from the books dir's `wanderbooks.toml`, by -//! default the one next to the data dir, and reload whenever it changes -//! (settings.rs). `books.import_local_file` and `books.set_w9`'s -//! `w9_file_path` read only from the import roots: each `--import-root`, -//! or by default the downloads folder (local_files.rs); a root that is -//! `/`, the home folder, or holds the books, the vault or a key is refused -//! at startup. - -mod handbook; -mod local_files; -mod policy; -mod server; -mod settings; -mod tin_files; -mod types; -mod wire; - -use ledger_core::Currency; -use ledger_store::Store; -use local_files::ImportRoots; -use policy::ReceiptPolicy; -use rmcp::ServiceExt; -use serde::Deserialize; -use server::BooksServer; -use std::path::PathBuf; - -/// Server configuration, JSON. All fields optional. -#[derive(Debug, Default, Deserialize)] -struct Config { - /// ISO 4217 base currency for a new store. Default "USD". - #[serde(default)] - base_currency: Option, - // `receipt_policy` is read by ledger_config::effective_receipt_policy. -} - -fn default_data_dir() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string()); - PathBuf::from(home).join(".local/share/wanderbooks") -} - -fn print_usage() { - eprintln!( - "usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..." - ); - eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)"); - eprintln!(" --config JSON config file (default /config.json, optional)"); - eprintln!( - " --books-toml company settings, for books that don't keep their own yet (default /../wanderbooks.toml, optional)" - ); - eprintln!( - " --vault-key-file the TIN vault's key file (default ~/.config/wanderbooks/vault.key; wins over a settings file's [vault] key_file)" - ); - eprintln!( - " --http serve over Streamable HTTP at ADDR (e.g. 127.0.0.1:8080) instead of stdio" - ); - eprintln!( - " --import-root a folder books.import_local_file may read from (repeatable; default: the downloads folder)" - ); -} +//! `ledger-mcp`: the books server over stdio (or `--http ADDR`). The same +//! server is `wanderbooks dev-stdio`; both call [`ledger_mcp::run`]. fn main() -> Result<(), Box> { - let mut data_dir = default_data_dir(); - let mut config_path: Option = None; - let mut books_toml: Option = None; - let mut vault_key_file: Option = None; - let mut http_addr: Option = None; - let mut import_roots: Vec = Vec::new(); - let mut args = std::env::args().skip(1); - while let Some(arg) = args.next() { - match arg.as_str() { - "--data-dir" => { - data_dir = PathBuf::from(args.next().ok_or("--data-dir needs a path")?); - } - "--config" => { - config_path = Some(PathBuf::from(args.next().ok_or("--config needs a path")?)); - } - "--books-toml" => { - books_toml = Some(PathBuf::from( - args.next().ok_or("--books-toml needs a path")?, - )); - } - "--vault-key-file" => { - vault_key_file = Some(PathBuf::from( - args.next().ok_or("--vault-key-file needs a path")?, - )); - } - "--http" => { - http_addr = Some(args.next().ok_or("--http needs an address")?); - } - "--import-root" => { - import_roots.push(PathBuf::from( - args.next().ok_or("--import-root needs a folder")?, - )); - } - "--help" | "-h" => { - print_usage(); - return Ok(()); - } - other => { - eprintln!("unknown argument: {other}"); - print_usage(); - std::process::exit(2); - } - } - } - let import_roots = if import_roots.is_empty() { - ImportRoots::defaults() - } else { - ImportRoots::new(import_roots) - }; - let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json")); - // config.json's base currency matters only to books being created: it - // is read then, and never otherwise (books that keep their own - // settings read no file). - let read_config = || -> Result> { - if config_path.exists() { - let raw = std::fs::read_to_string(&config_path)?; - Ok(serde_json::from_str(&raw) - .map_err(|e| format!("bad config {}: {e}", config_path.display()))?) - } else { - Ok(Config::default()) - } - }; - // Logs go to stderr: stdout is the MCP channel. - eprintln!("ledger-mcp: data dir {}", data_dir.display()); - // An explicit --books-toml must exist; the default (beside the data - // dir) is optional. Either is reloaded whenever it changes. - let source = settings::Source { - required: books_toml.is_some(), - books_toml: books_toml.unwrap_or_else(|| { - data_dir - .parent() - .map(|p| p.join("wanderbooks.toml")) - .unwrap_or_else(|| PathBuf::from("wanderbooks.toml")) - }), - config_json: config_path.clone(), - }; - - let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() { - match open_waiting_for_lock(&data_dir) { - Ok(store) => store, - // Books from a newer wanderbooks are fine; this server is old. - // Say so in plain words (the error's text), and leave them be. - Err(e @ ledger_store::StoreError::NewerJournal { .. }) => { - eprintln!("ledger-mcp: {e}"); - std::process::exit(1); - } - Err(e) => return Err(e.into()), - } - } else { - let config = read_config()?; - let code = config.base_currency.as_deref().unwrap_or("USD"); - let currency = Currency::new(code).map_err(|_| format!("bad base currency {code:?}"))?; - eprintln!("ledger-mcp: creating new store (base currency {code})"); - Store::create(&data_dir, currency)? - }; - // The journal is the source of truth: verify the chain, the replay, and - // the whole projection before serving a single tool call, rebuilding - // the projection if it drifted. - let report = store - .verify_and_heal() - .map_err(|e| format!("store verify failed: {e}"))?; - if let Some(why) = &report.healed { - eprintln!("ledger-mcp: rebuilt the database from the journal ({why})"); - } - // Books that keep their own settings never read a settings file, not - // even to start (settings INVARIANT 1); others read it as before. - let mut live = if store.ledger().settings().is_journaled() { - settings::Live::journaled(source) - } else { - settings::Live::watching(source)? - } - .with_vault_key_file(vault_key_file); - live.sync(store.ledger().settings(), store.base_currency().code()); - eprintln!( - "ledger-mcp: company {:?} (settings from {})", - live.current().company.name, - if live.is_journaled() { - "the books" - } else { - "the settings file" - } - ); - let live = std::sync::Arc::new(std::sync::Mutex::new(live)); - // No import root may be `/`, the home folder, or hold the books, the - // vault or a key (local_files.rs INVARIANT 0): a named one stops the - // server here, a default one is dropped. - let settings_now = live.lock().unwrap_or_else(|e| e.into_inner()).current(); - let import_roots = match server::guard_import_roots(&store, &settings_now, import_roots) { - Ok(roots) => roots, - Err(why) => { - eprintln!("ledger-mcp: {why}"); - std::process::exit(2); - } - }; - - let rt = tokio::runtime::Builder::new_multi_thread() - .enable_all() - .build()?; - if let Some(addr) = http_addr { - eprintln!("ledger-mcp: store verified, serving Streamable HTTP on {addr}"); - rt.block_on(serve_http(addr, store, live, import_roots))?; - return Ok(()); - } - - eprintln!("ledger-mcp: store verified, serving over stdio"); - let server = BooksServer::new(store, ReceiptPolicy::default()) - .with_live_settings(live) - .with_import_roots(import_roots); - rt.block_on(async { - // The wire answers what JSON-RPC requires that rmcp drops (wire.rs). - let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout()); - let running = server.serve((input, output)).await?; - let _ = running.waiting().await; - let _ = writer.await; - Ok::<(), Box>(()) - }) -} - -/// Serve MCP over Streamable HTTP (for network clients). -async fn serve_http( - addr: String, - store: Store, - live: std::sync::Arc>, - import_roots: ImportRoots, -) -> Result<(), Box> { - use rmcp::transport::streamable_http_server::{ - StreamableHttpService, session::local::LocalSessionManager, - }; - use std::sync::Arc; - - // One store for every connection: the async mutex keeps it - // single-writer, and tool bodies run on blocking threads (server.rs). - let store = server::SharedStore::new(store); - let service = StreamableHttpService::new( - move || { - let server = server::BooksServer::new_shared(store.clone(), ReceiptPolicy::default()) - .with_live_settings(live.clone()) - .with_import_roots(import_roots.clone()); - Ok(server) + match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) { + Err(e) => match e.downcast_ref::() { + Some(exit) => std::process::exit(exit.0), + None => Err(e), }, - Arc::new(LocalSessionManager::default()), - Default::default(), - ); - - let router = axum::Router::new().nest_service("/mcp", service); - let listener = tokio::net::TcpListener::bind(&addr).await?; - eprintln!("ledger-mcp: listening on http://{addr}/mcp"); - axum::serve(listener, router).await?; - Ok(()) -} - -/// Open the books, waiting up to ten seconds for another writer to let go. -/// A restarted server (a client reconnecting) often starts while the old -/// process is still exiting with the lock; waiting turns that race into a -/// short delay. A writer that keeps the books longer is a real conflict, -/// and the refusal says so. -fn open_waiting_for_lock(data_dir: &std::path::Path) -> Result { - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); - loop { - match Store::open(data_dir) { - Err(ledger_store::StoreError::Locked(_)) if std::time::Instant::now() < deadline => { - std::thread::sleep(std::time::Duration::from_millis(100)); - } - other => return other, - } + done => done, } } diff --git a/docs/testing.md b/docs/testing.md index 41f1503..60faf3b 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -34,7 +34,7 @@ promise. | Mutation (roles, reload) | can the tests fail? | control roles and settings reload (2026-10-15, targeted: the role paths in `ledger-core` 30 caught of 30 viable; `ledger-store`'s journaling and replay of `role.assigned` 20 of 20; `ledger-mcp` settings, reload, roles, digest 28 of 28) | `cargo mutants -F` on those functions (see the commit) | ~15 min | with the crate's weekly run | | Mutation (config) | can the tests fail? | `ledger-config` (2026-10-14: 55 caught of 55 viable) | `just mutants-config` | minutes | weekly | | Mutation (MCP) | can the tests fail? | `ledger-mcp` (2026-10-14: from 197 caught + 5 timeouts of 328 viable (62%) to 314 caught + 2 timeouts of 331; of its 15 survivors, five then killed by targeted tests (the settings file beside the books, year earnings with an open period and with last year's partial close, year 1, a failed flush), one dead guard removed (`profit_and_loss_by_class` skipping placeholders, which never take postings), nine equivalent: `money` rescaling an amount already at two places, the lock wait's deadline instant, `year_earnings` starting the day the year starts either way, the bank import's rehearsal sides (a line's side can't make a draft refuse) and its zero-amount side (zero rows are dropped by the parser), a disposal's zero gain adding zero, and the CPA package index summing debits instead of credits (entries balance)) | `just mutants-mcp` | ~1 h | weekly | -| Mutation (GUI) | can the tests fail? | `ledger-gui` view model and formatting (2026-10-14: 140 caught of 176 viable on the full run, then 92 of 99 on a targeted rerun of the money paths after new tests; of its 7 survivors, one then killed by a test (burn over exactly one month, confirmed by a rerun of `compute_kpis`'s 48 mutants), two dead arms removed (`parties`' bill arms: the ledger tags bill entries with their vendor, which wins), four equivalent: an entry's debits vs its credits (they balance), the three-month burn boundary (both paths give 3), burn over zero covered months (only when the first entry is after the window, which then holds no loss), and `compact`'s sign test at zero (zero prints "$0" unsigned either way)) | `just mutants-gui` | ~2 h | weekly | +| Mutation (GUI) | can the tests fail? | `ledger-gui` view model and formatting (2026-10-14: 140 caught of 176 viable on the full run, then 92 of 99 on a targeted rerun of the money paths after new tests; of its 7 survivors, one then killed by a test (burn over exactly one month, confirmed by a rerun of `compute_kpis`'s 48 mutants), two dead arms removed (`parties`' bill arms: the ledger tags bill entries with their vendor, which wins), four equivalent: an entry's debits vs its credits (they balance), the three-month burn boundary (both paths give 3), burn over zero covered months (only when the first entry is after the window, which then holds no loss), and `compact`'s sign test at zero (zero prints "$0" unsigned either way)) | retired 2026-10-18 (owner: it crushed the machine; for the viewer, test its two musts directly: it never writes the books, `tests/read_only.rs`, and every figure it shows is the engine's, `tests/view_model.rs`) | | never | CI: `.github/workflows/check.yml` runs `just check` on every push; `scheduled.yml` runs proofs, the oracle, fuzzing, and mutation weekly. @@ -365,6 +365,20 @@ package test), and the filing scan passes the company's own EIN printed Equivalent: `share_text`'s `scale() < 2` made `<= 2` (padding a two-place share to two places changes nothing). +## P2 slice 1: one executable (2026-10-18) + +`wanderbooks dev-stdio` and `wanderbooks gui` run the server and the +viewer from the one executable (plan D-22); `tests/one_executable.rs` +drives both, and `just server-only` builds, clippies and tests the build +without the viewer and fails if it links a window library. Mutation on +the diff, two jobs, the viewer left out (owner, 2026-10-18): the server's +`run` against the server's tests, 14 mutants, 12 caught or unviable, the +exit text's then caught by a new test, and one equivalent (`<` vs `<=` +on the ten-second lock deadline); the CLI, 24 mutants against its tests +in the default build and the `run_gui` arms again in the server-only +build (`--no-default-features`), every one caught in the build where its +code exists. + ## Benchmarks (baseline 2026-10-13) No budget, by decision (Stephen, 2026-10-13: the books do little work diff --git a/justfile b/justfile index b9447b2..15a82ee 100644 --- a/justfile +++ b/justfile @@ -15,8 +15,23 @@ check: sweep-test-tmp cargo clippy --workspace --all-targets -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet cargo test --workspace + @just server-only @just sweep-test-tmp +# The server-only build (plan D-22, for another machine) builds, passes +# clippy and its tests, and links no window libraries +server-only: + #!/usr/bin/env bash + set -euo pipefail + cargo clippy -p ledger-cli --no-default-features --all-targets -- -D warnings + cargo test -p ledger-cli --no-default-features + tree=$(cargo tree -p ledger-cli --no-default-features -e normal) + if grep -qiE '\b(eframe|egui|winit|wgpu|glow|ledger-gui)\b' <<<"$tree"; then + echo "the server-only build links a window library" >&2 + exit 1 + fi + echo "server-only build: no window libraries" + # Test books go in the system temp dir (often RAM). Every test's dir is # removed when the test ends, pass or panic (a guard dropped on unwind; # until 2026-10-17 many were removed only on pass, or never: 117,000 piled @@ -109,16 +124,12 @@ mutants-mcp: sweep-test-tmp MCP_CASES=16 PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-mcp --test-package ledger-mcp -j 4 --timeout 600 -o mutants-mcp @just sweep-test-tmp -# Mutation testing of the viewer where it matters: the numbers it shows -# (the view model) and how it writes them (formatting), against the tests -# that check them (the unit tests, spec_gui, view_model, copy). Its read-only promise has -# its own test (tests/read_only.rs, which renders every page: too slow to -# rerun per mutant); search, history, ticks, and the attention list are -# spec-tested, not swept. -mutants-gui: sweep-test-tmp - mkdir -p {{mutants_tmp}} - PROPTEST_MAX_SHRINK_ITERS=0 TMPDIR={{mutants_tmp}} cargo mutants -p ledger-gui --test-package ledger-gui -j 4 --timeout 300 -o mutants-gui -f crates/ledger-gui/src/books.rs -f crates/ledger-gui/src/fmt.rs -- --lib --test spec_gui --test view_model --test copy - @just sweep-test-tmp +# No mutation runs on the viewer (owner, 2026-10-18: they crushed the +# machine, and for the GUI it's "not as critical"). Its two musts are +# tested directly instead: it never writes the books (tests/read_only.rs) +# and every figure it shows is the engine's (tests/view_model.rs, against +# ledger-core on random books). Mutation recipes for other crates leave +# crates/ledger-gui out. # Benchmarks of the core's hot paths (no budget gates them yet) bench: diff --git a/plan/served.md b/plan/served.md index 640dc3e..7f19c3c 100644 --- a/plan/served.md +++ b/plan/served.md @@ -140,7 +140,7 @@ The data root is `$XDG_DATA_HOME/wanderbooks` on Linux (default `~/.local/share/ | Viewer | `wanderbooks gui `, the viewer mode (the `ledger-gui` crate; local, direct, the default; what opening the app shows); `wanderbooks gui --books ` (remote replica, P9) | the human | local: yes, read-only, never writes. Remote: none | | CLI | `wanderbooks` (ledger-cli) | the human | **by design never**. Talks to `run/admin.sock` (peer-credential check: same user ID as the server). The admin socket cannot read books data; it can create or adopt books and mint agent tokens, admin-scope ones included. The peer-credential check keeps other local accounts out; any process running as the human, agents included, can use it (advisory, like the rest of the desktop deployment). | | Agent bridge | `wanderbooks mcp --book ` (stdio to the agent; `--url` for a remote book, P9) | the agent's user | never; it has no data-dir flag. It may start the host (D-23), whose server then reads the disk as itself | -| Test/demo | `ledger-mcp dev-stdio --data-dir` | any | refuses inside the data root; `wanderbooks connect` never configures it (D-18) | +| Test/demo | `wanderbooks dev-stdio --data-dir` (also the `ledger-mcp` binary until the scaffold goes) | any | refuses inside the data root; `wanderbooks connect` never configures it (D-18) | ### 3.2 Transport (one code path, local and remote) @@ -813,6 +813,7 @@ Wanderland keeps working between phases. - `setup_needs_no_root` (setup, serve, adopt, connect and gui as an unprivileged user in a clean container; nothing is left installed to run in the background). - `books_close_cleanly` (the last lease ending, "close the books", SIGTERM and SIGHUP of the host, all during a burst of posts: every request either completes or is refused, the head verifies at the next open, the lock is released); `openers_share_one_server` (the app, `serve`, a CLI command and two bridges started together: one host, one writer); `books_close_with_last_holder` (each kind of holder ending, `kill -9` included: closed 10 s after the last, not before); `bridge_opens_closed_books` and `bridge_survives_server_restart` (the agent's session carries on, no reconnect); `close_now_ends_every_lease`; `host_starts_clean` (started from a confined viewer or bridge with a hostile environment, `XDG_DATA_HOME` and `LD_PRELOAD` included, the host is unconfined, finds the real root, and holds no inherited descriptor); `host_respawns` (kill the host: a holder starts a new one); `host_survives_app_scope` (under a systemd app scope, as uwsm sets up, closing the app that started the host leaves it running for the other holders); `no_local_agent_tcp` (the bridge never connects to TCP; TCP is off by default; with it on, a squatter on 7411 makes `doctor` report a failed bind and sees no bridge traffic); `lease_socket_accepts_only_owner_intents` (exactly the five; consent and replace without the passphrase refused); `restart_is_not_close` (bridges carry on across a restart; openers wait on `update.lock`); `children_match_host_version` (replace the executable mid-run: no child of the new version joins the old host); `format_upgrade_waits_for_consent` (read-only until the owner's consent, a backup with the keys made first, no MCP tool can consent, declining leaves the book openable after `wanderbooks rollback`); `no_rollback_after_consent` ("Go back" refuses for an upgraded book; the pre-upgrade restore arrives beside it, states the entries it lacks, and needs the passphrase); `restore_reads_only_staged_backups` (a `restore` intent naming a path is refused); `bridge_initializes_during_cold_start`; `archive_catches_up_at_start` (a push that failed before a close is made at the next open). - `one_executable` (every mode from one binary; the viewer mode under read-only Landlock opens and follows a book); `server_build_has_no_gui` (the self-host build links no window libraries). +- `viewer_shows_the_engines_numbers` (the viewer's two musts, owner 2026-10-18: it never writes the books, and every figure it shows is correct): on random books, every report page, account register, entry and Needs-attention count the viewer renders equals the engine's report for the same books and dates, extending `tests/view_model.rs` from the overview to every page. No mutation runs on the viewer. - `viewer_without_server` (with the server stopped or failing to start, the viewer still opens and follows the books from disk; "Restore…" and "New books…" say why they can't run). - `isolation_label_is_advisory`. - `no_path_params` schema lint; `no_result_names_a_path`.