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:
12 files changed
+832
-378
No files matched your search
@@ -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}"
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user