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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-18 12:27:48 -06:00
1 parent 18120727eb
commit cab6e8a334
12 files changed
+832 -378

No files matched your search

+10
View File
@@ -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" }
+118 -18
View File
@@ -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<String>) -> Vec<String> {
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<String>) -> i32 {
match ledger_gui::run("wanderbooks gui", gui_args(args)) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_gui::Exit>() {
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<String>) -> 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<String>) -> i32 {
match ledger_mcp::run("wanderbooks dev-stdio", args) {
Ok(()) => 0,
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
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<PathBuf> = 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<String> {
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);
}
}
}
+220
View File
@@ -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<std::process::ChildStdout>,
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}"
);
}