Files
wanderbooks/crates/ledger-cli/tests/one_executable.rs
T
swaitsandClaude Opus 5.5 d5c9522ad1 P2: tests mutation found, three bugs fixed; plan P12 and P13
Mutation testing pauses until P12 (D-33, owner 2026-10-20), which reruns
the whole max-testing pass over everything since P1. Before the pause,
pass 1 on slices 3-6 and its triage gave tests in ledger-store,
ledger-package and ledger-mcp, and three real bugs, fixed:
- the command line took an admin answer longer than four MiB, or cut off
  before its newline, whenever the bytes that came still parsed;
- The CPA package put a twin's number on the last dot of the whole path,
  so in a folder with a dot the second file landed in another folder;
- a document or category named of dots alone ("..") could climb out of
  its folder when the CPA package is unpacked.
The plan gains P13, bookspec (D-34): a formal, sourced accounting
specification first (rules with symbolic forms and worked examples), then
its own formal validation and a conformance harness any engine can be
tested against, seeded by P3's regression tests. docs/testing.md records
slices 3-8 and what P12 must cover; plan/STATUS.md says where P2 stands.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-20 11:14:22 -06:00

439 lines
14 KiB
Rust

//! 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");
let mut child = wanderbooks()
.arg("dev-stdio")
.arg("--data-dir")
.arg(&data)
.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().args(["server", "--help"]).output().unwrap();
assert!(out.status.success());
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains("usage: wanderbooks server [--root DIR]"),
"{err}"
);
let out = wanderbooks().arg("--help").output().unwrap();
let err = String::from_utf8_lossy(&out.stderr);
for line in [
"wanderbooks gui [BOOK",
"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 [--root PATH] [--book ID]"),
"{text}"
);
}
#[test]
fn a_mode_refuses_what_it_does_not_know() {
let modes: &[&str] = if cfg!(feature = "gui") {
&["dev-stdio", "gui", "server"]
} else {
&["dev-stdio", "server"]
};
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 the_books_folder_scaffold_is_gone_and_says_so() {
for gone in ["new", "init"] {
let out = wanderbooks().args([gone, "acme"]).output().unwrap();
assert_eq!(out.status.code(), Some(2), "{gone}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(
err.contains(&format!("`wanderbooks {gone}` is gone")),
"{err}"
);
assert!(err.contains("wanderbooks books create ID"), "{err}");
}
}
#[test]
fn a_mode_comes_first() {
for mode in ["gui", "dev-stdio", "server"] {
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}"
);
}
/// Books in the data root are the books server's alone (plan 3.1): the
/// test server refuses them, and the books server needs a root.
#[cfg(unix)]
#[test]
fn dev_stdio_refuses_books_in_the_private_folder() {
let home = tempfile::Builder::new()
.prefix("wb-one-root-")
.tempdir()
.unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let root = home.path().join(".local/share/wanderbooks");
for inside in [
root.join("books/acme"),
root.clone(),
root.join("books/a/b"),
] {
let out = wanderbooks()
.args(["dev-stdio", "--data-dir"])
.arg(&inside)
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{inside:?}: {out:?}");
let err = String::from_utf8_lossy(&out.stderr);
assert!(err.contains("only the books server opens"), "{err}");
assert!(!inside.join("journal.jsonl").exists(), "{inside:?}");
}
assert!(!root.join("books/acme").exists());
// A relative path, from inside the root.
let out = wanderbooks()
.args(["dev-stdio", "--data-dir", "acme"])
.current_dir(root.join("books"))
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(!root.join("books/acme").exists());
// No root yet: the books server says to run setup.
let empty = tempfile::Builder::new()
.prefix("wb-one-noroot-")
.tempdir()
.unwrap();
let out = wanderbooks()
.args(["server", "--root"])
.arg(empty.path().join("none"))
.stdin(std::process::Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("run `wanderbooks setup` first"),
"{out:?}"
);
}
/// `upload` and `download` say how they ended in their exit code, as
/// every mode does: 0 for their usage on request, 2 for a slip in how
/// they were called, 1 for what went wrong doing it. A file's path may be
/// a name that isn't text: it reaches the command as given.
#[cfg(unix)]
#[test]
fn the_file_commands_end_with_the_code_that_says_how() {
use std::os::unix::ffi::OsStrExt;
let home = tempfile::Builder::new()
.prefix("wb-one-files-")
.tempdir()
.unwrap();
let root = home.path().join("wanderbooks");
let run = |args: &[&std::ffi::OsStr]| {
wanderbooks()
.args(args)
.env_remove("XDG_DATA_HOME")
.stdin(Stdio::null())
.output()
.unwrap()
};
let os = |s: &'static str| std::ffi::OsStr::new(s);
for (mode, usage) in [
("upload", "usage: wanderbooks upload --book ID"),
("download", "usage: wanderbooks download --book ID"),
] {
// Its usage on request.
let out = run(&[os(mode), os("--help")]);
assert_eq!(out.status.code(), Some(0), "{mode}: {out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains(usage),
"{out:?}"
);
// Called wrong: no file, or no books named.
let out = run(&[
os(mode),
os("--root"),
root.as_os_str(),
os("--book"),
os("acme"),
]);
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains(usage),
"{out:?}"
);
let out = run(&[os(mode), os("--root"), root.as_os_str(), os("1")]);
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("which books? give --book ID"),
"{out:?}"
);
}
// A file that isn't there, named by a path that isn't text: what went
// wrong, said, and nothing sent.
let missing = home
.path()
.join(std::ffi::OsStr::from_bytes(b"receipt-\xff.pdf"));
let out = run(&[
os("upload"),
os("--root"),
root.as_os_str(),
os("--book"),
os("acme"),
os("up_abcdefghijklmnopabcdefghijklmnop"),
missing.as_os_str(),
]);
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("error: can't read"),
"{out:?}"
);
// A folder to save in that isn't there: the same.
let out = run(&[
os("download"),
os("--root"),
root.as_os_str(),
os("--book"),
os("acme"),
os("7"),
os("--to"),
home.path().join("no-such-folder").as_os_str(),
]);
assert_eq!(out.status.code(), Some(1), "{out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("isn't a folder"),
"{out:?}"
);
assert!(!root.exists(), "the books were reached for");
}
/// Where local sockets aren't built yet (P11, Windows): the modes that
/// hold the books say so and end with 2, never a success; setup makes
/// the folder and ends with 0, asking for no passphrase (nothing there
/// asks for one yet).
#[cfg(not(unix))]
#[test]
fn without_local_sockets_the_modes_that_hold_the_books_say_so() {
for mode in [
"server",
"host",
"mcp",
"serve",
"close",
"books",
"doctor",
"token",
"connect",
"passphrase",
"upload",
"download",
] {
let out = wanderbooks()
.arg(mode)
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(2), "{mode}: {out:?}");
assert!(
String::from_utf8_lossy(&out.stderr).contains("to this system with P11"),
"{mode}: {out:?}"
);
}
let home = tempfile::Builder::new()
.prefix("wb-one-nosock-")
.tempdir()
.unwrap();
let out = wanderbooks()
.args(["setup", "--home"])
.arg(home.path())
.env_remove("XDG_DATA_HOME")
.stdin(Stdio::null())
.output()
.unwrap();
assert_eq!(out.status.code(), Some(0), "{out:?}");
let text = String::from_utf8_lossy(&out.stdout);
assert!(text.contains("The books' private folder: made"), "{text}");
assert!(!text.contains("passphrase"), "{text}");
}