Files
wanderbooks/crates/ledger-cli/tests/init.rs
T
swaitsandClaude Opus 5.5 b292a505b1
check / check (push) Canceled after 0s
P1: the books keep how they are kept
Settings, conventions (posting templates, approval rules, policies,
close steps, calendar, mappings, notes), decisions, open items and
entry approvals are journal events (journal format 2). Books that keep
their settings never read wanderbooks.toml or config.json; the server
and the viewer take the vault key from --vault-key-file instead.
books.import_settings moves a settings file in once, exactly as the
server reads it. Approval rules, when enforced, gate every posting
path. Replay refuses what it can't read as newer, never as corrupt.

Lean: a record changes no figure. Mutation on the whole diff: every
survivor killed by a test but two equivalent (docs/testing.md). The
Company page says a late open item is overdue in words, not colour
alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-17 19:27:48 -06:00

459 lines
17 KiB
Rust

//! Integration tests for `wanderbooks new`: one command lands a complete
//! books dir with the MCP server registered for it, the TOML and JSON
//! parse, and re-running is refused without `--force` (which never
//! touches the company profile).
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
fn bin() -> PathBuf {
PathBuf::from(env!("CARGO_BIN_EXE_wanderbooks"))
}
/// Stand-ins for the installed binaries, so tests never run cargo install.
fn fake_bins(tmp: &Path) -> PathBuf {
let dir = tmp.join("bin");
fs::create_dir_all(&dir).unwrap();
for b in ["ledger-mcp", "ledger-gui"] {
fs::write(dir.join(b), "#!/bin/sh\n").unwrap();
}
dir
}
fn run(tmp: &Path, args: &[&str]) -> (bool, String) {
let path = format!(
"{}:{}",
fake_bins(tmp).display(),
std::env::var("PATH").unwrap_or_default()
);
let out = Command::new(bin())
.args(args)
.arg("--no-start")
.env("PATH", path)
.output()
.expect("run wanderbooks");
let mut s = String::from_utf8_lossy(&out.stdout).into_owned();
s.push_str(&String::from_utf8_lossy(&out.stderr));
(out.status.success(), s)
}
fn new(tmp: &Path, books: &Path, extra: &[&str]) -> (bool, String) {
let mut args = vec!["new", books.to_str().unwrap()];
args.extend(extra);
run(tmp, &args)
}
#[test]
fn scaffold_lands_complete() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("acme-books");
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(ok, "new should succeed: {out}");
for f in [
"justfile",
"AGENTS.md",
"CLAUDE.md",
"wanderbooks.toml",
".gitignore",
".mcp.json",
".claude/settings.json",
".codex/hooks.json",
] {
let p = books.join(f);
assert!(p.exists(), "{f} exists");
assert!(!fs::read_to_string(&p).unwrap().is_empty(), "{f} non-empty");
}
assert!(books.join("books-data").is_dir(), "books-data dir exists");
assert!(books.join(".git").is_dir(), "fresh dir gets git init");
let log = Command::new("git")
.args(["log", "--oneline"])
.current_dir(&books)
.output()
.unwrap();
assert!(
String::from_utf8_lossy(&log.stdout).contains("init"),
"scaffold committed"
);
}
#[test]
fn the_server_is_registered_for_this_dir() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let books = books.canonicalize().unwrap();
let mcp: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".mcp.json")).unwrap()).unwrap();
let server = &mcp["mcpServers"]["wanderbooks"];
assert!(server["command"].as_str().unwrap().ends_with("ledger-mcp"));
assert_eq!(
server["args"],
serde_json::json!(["--data-dir", books.join("books-data").to_string_lossy()])
);
let settings: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".claude/settings.json")).unwrap())
.unwrap();
assert_eq!(
settings["enabledMcpjsonServers"],
serde_json::json!(["wanderbooks"])
);
// Every session starts by checking the viewer against open_at_start.
let hook = settings["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
let codex: serde_json::Value =
serde_json::from_str(&fs::read_to_string(books.join(".codex/hooks.json")).unwrap())
.unwrap();
let hook = codex["hooks"]["SessionStart"][0]["hooks"][0]["command"]
.as_str()
.expect("Codex SessionStart hook command");
assert!(hook.ends_with("just session-start"), "{hook}");
// The justfile opens this dir's books in the GUI.
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("ledger-gui\" --data-dir \"{{justfile_directory()}}/books-data\""));
for recipe in [
"agent:",
"gui:",
"gui-fg:",
"gui-status:",
"session-start:",
"backup:",
"update:",
] {
assert!(just.contains(recipe), "justfile has {recipe}");
}
assert_eq!(
fs::read_to_string(books.join("CLAUDE.md")).unwrap().trim(),
"@AGENTS.md"
);
let toml: toml::Value =
toml::from_str(&fs::read_to_string(books.join("wanderbooks.toml")).unwrap()).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
}
#[test]
fn agents_md_carries_the_contract() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"preview",
"Posted is forever",
"reversing entries",
"receipt",
"onboard",
"chart_template",
"to the cent",
"just gui",
"just gui-status",
"open_at_start",
] {
assert!(md.contains(phrase), "AGENTS.md mentions {phrase:?}");
}
}
/// The agent reaches the books only through the MCP tools. The scaffolded
/// AGENTS.md once said "the journal is the evidence — read it", and an
/// agent `cat`ed the journal file instead of asking the books.
#[test]
fn agents_md_reaches_the_books_only_through_tools() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let md = fs::read_to_string(books.join("AGENTS.md")).unwrap();
for phrase in [
"only through the",
"wanderbooks MCP tools",
"Never open, list or parse",
"books.get_entry",
"books.search_entries",
"general_ledger",
"can't answer, say so.",
] {
assert!(md.contains(phrase), "AGENTS.md says {phrase:?}");
}
// Nothing points the agent at the books' files.
for path in ["books-data", "journal.jsonl", "wanderbooks.db", "read it"] {
assert!(!md.contains(path), "AGENTS.md never mentions {path:?}");
}
}
#[test]
fn rerun_refused_without_force_and_never_clobbers_the_profile() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
assert!(new(tmp.path(), &books, &[]).0);
let (ok, out) = new(tmp.path(), &books, &[]);
assert!(!ok, "second new without --force fails");
assert!(out.contains("--force"), "error points at --force: {out}");
fs::write(
books.join("wanderbooks.toml"),
"[company]\nname = \"Acme LLC\"\n",
)
.unwrap();
let (ok, _) = new(tmp.path(), &books, &["--force"]);
assert!(ok, "second new with --force succeeds");
assert!(
fs::read_to_string(books.join("wanderbooks.toml"))
.unwrap()
.contains("Acme LLC"),
"--force rewrites the scaffold, never the company profile"
);
}
#[test]
fn init_is_an_alias_and_respects_an_existing_repo() {
let tmp = tempfile::tempdir().unwrap();
let books = tmp.path().join("b");
fs::create_dir_all(&books).unwrap();
assert!(
Command::new("git")
.args(["init", "-q"])
.current_dir(&books)
.status()
.unwrap()
.success()
);
let (ok, out) = run(tmp.path(), &["init", books.to_str().unwrap()]);
assert!(ok, "{out}");
assert!(books.join("wanderbooks.toml").exists());
}
#[test]
fn codex_gets_its_own_named_server() {
let tmp = tempfile::tempdir().unwrap();
let bin = fake_bins(tmp.path());
let log = tmp.path().join("codex-args");
// A stand-in codex that records how it was called.
fs::write(
bin.join("codex"),
format!("#!/bin/sh\necho \"$@\" > {}\n", log.display()),
)
.unwrap();
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(bin.join("codex"), fs::Permissions::from_mode(0o755)).unwrap();
let books = tmp.path().join("Acme Books");
let (ok, out) = new(tmp.path(), &books, &["--codex"]);
assert!(ok, "{out}");
let args = fs::read_to_string(&log).unwrap();
let books = books.canonicalize().unwrap();
assert!(
args.starts_with("mcp add wanderbooks-acme-books -- "),
"{args}"
);
assert!(
args.trim_end().ends_with(&format!(
"--data-dir {}",
books.join("books-data").display()
)),
"{args}"
);
let just = fs::read_to_string(books.join("justfile")).unwrap();
assert!(just.contains("codex mcp add \"wanderbooks-acme-books\""));
assert!(out.contains("just codex"), "{out}");
}
/// The `wanderbooks.toml` a fresh `new` writes.
fn scaffolded_toml(tmp: &Path) -> (PathBuf, String) {
let books = tmp.join("b");
assert!(new(tmp, &books, &[]).0);
let path = books.join("wanderbooks.toml");
let text = fs::read_to_string(&path).unwrap();
(path, text)
}
/// The example a commented line holds, if it is one: `# key = value`, or
/// `# [section]` / `# [[section]]`. Prose comments are not examples.
fn example(line: &str) -> Option<&str> {
let rest = line.strip_prefix("# ")?;
let is_key = |k: &str| !k.is_empty() && k.bytes().all(|b| b.is_ascii_lowercase() || b == b'_');
let header = rest
.trim_start_matches('[')
.trim_end_matches(']')
.split('.')
.all(is_key);
let assignment = rest
.split_once(" = ")
.is_some_and(|(k, v)| is_key(k) && !v.trim().is_empty());
((rest.starts_with('[') && rest.ends_with(']') && header) || assignment).then_some(rest)
}
/// The template with the examples `pick` chooses uncommented.
fn uncomment(text: &str, pick: impl Fn(&str) -> bool) -> String {
text.lines()
.map(|l| match example(l) {
Some(ex) if pick(ex) => ex,
_ => l,
})
.collect::<Vec<_>>()
.join("\n")
}
#[test]
fn the_scaffolded_toml_loads_with_the_defaults() {
let tmp = tempfile::tempdir().unwrap();
let (path, text) = scaffolded_toml(tmp.path());
let s = ledger_config::load(&path).expect("scaffolded wanderbooks.toml loads");
let c = &s.company;
assert_eq!(c.name, "Unnamed company", "name is left for onboarding");
assert_eq!(c.fiscal_year_start, (1, 1));
assert_eq!(c.basis, "Accrual");
assert!(c.members.is_empty());
assert_eq!(c.tax_form, None);
assert_eq!(c.close_into, None);
assert!(c.close_sweep.is_empty());
assert_eq!(
c.profile,
[
("base_currency".to_string(), "USD".to_string()),
("fiscal_year_start".to_string(), "01-01".to_string()),
]
);
assert_eq!(s.receipt_threshold.as_deref(), Some("25.00"));
assert!(s.cash_accounts.is_empty());
assert!(s.financing_accounts.is_empty());
assert!(s.investing_accounts.is_empty());
let toml: toml::Value = toml::from_str(&text).unwrap();
assert_eq!(toml["books"]["data_dir"].as_str(), Some("books-data"));
// The justfile's session-start reads this line with sed, from column 0.
assert!(
text.lines().any(|l| l == "open_at_start = \"ask\""),
"{text}"
);
assert!(text.contains("SSN"), "the template warns off storing SSNs");
}
#[test]
fn every_example_in_the_toml_is_valid() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let examples = text.lines().filter_map(example).count();
assert!(examples >= 20, "only {examples} examples found");
// close_into is for a single owner and refused with members, so it
// gets a pass of its own; together the two passes cover every example.
let single_owner = |ex: &str| ex.starts_with("close_into ");
let all = ledger_config::parse(&uncomment(&text, |ex| !single_owner(ex)))
.unwrap_or_else(|e| panic!("examples (without close_into) parse: {e}"));
let c = &all.company;
assert_eq!(c.name, "Acme Widgets LLC");
assert_eq!(c.tax_form, Some(ledger_core::TaxForm::Form1065));
assert_eq!(c.members.len(), 2);
assert_eq!(c.members[1].distribution_account.as_deref(), Some("3101"));
assert_eq!(c.close_sweep, ["3100"]);
assert!(c.profile.iter().any(|(k, _)| k == "ein"));
assert_eq!(all.cash_accounts, ["1000", "1010"]);
assert_eq!(all.financing_accounts, ["2100"]);
assert_eq!(all.investing_accounts, ["1400"]);
let single = ledger_config::parse(&uncomment(&text, single_owner))
.unwrap_or_else(|e| panic!("close_into example parses: {e}"));
assert_eq!(single.company.close_into.as_deref(), Some("3000"));
assert!(
ledger_config::parse(&uncomment(&text, |_| true)).is_err(),
"close_into with members is refused, as the template says"
);
}
/// The serde field names of `struct name` in ledger-config's source.
fn fields(src: &str, name: &str) -> Vec<String> {
let start = src
.find(&format!("struct {name} {{"))
.unwrap_or_else(|| panic!("ledger-config has struct {name}"));
let body = &src[start..];
let body = &body[..body.find("\n}").expect("struct ends")];
let found: Vec<String> = body
.lines()
.skip(1)
.map(str::trim)
.filter(|l| !l.starts_with("//") && !l.starts_with("#["))
.filter_map(|l| l.split_once(':'))
.map(|(k, _)| k.trim_start_matches("pub ").to_string())
.collect();
assert!(!found.is_empty(), "struct {name} has fields");
found
}
#[test]
fn the_toml_shows_every_setting_the_config_reads() {
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
// (section, key) for every assignment, examples included.
let mut section = String::new();
let mut present = Vec::new();
for line in uncomment(&text, |_| true).lines() {
if line.starts_with('[') {
section = line.trim_matches(['[', ']']).to_string();
} else if let Some((k, _)) = line.split_once(" = ") {
present.push((section.clone(), k.to_string()));
}
}
let has = |s: &str, k: &str| present.iter().any(|(ps, pk)| ps == s && pk == k);
// Read from the config crate's serde structs, so a new setting there
// fails here until the template shows it.
let src = include_str!("../../ledger-config/src/lib.rs");
assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]);
for (strukt, sect) in [
("CompanyToml", "company"),
("MemberToml", "company.members"),
("BooksSection", "books"),
("VaultSection", "vault"),
] {
for key in fields(src, strukt) {
if strukt == "CompanyToml" && key == "members" {
assert!(text.contains("# [[company.members]]"));
continue;
}
assert!(has(sect, &key), "[{sect}] {key} is in the template");
}
}
// Read elsewhere: the justfile's session-start (main.rs), and the two
// the scaffold has always written live.
for (sect, key) in [
("viewer", "open_at_start"),
("company", "base_currency"),
("books", "data_dir"),
] {
assert!(has(sect, key), "[{sect}] {key} is in the template");
}
}
/// Moving the scaffolded settings file, and every example in it, into
/// books: each imports, reads as the file does (the import refuses
/// otherwise), and exporting it back and importing the export changes
/// nothing (plan/served.md P1: toml_round_trip_zero_events).
#[test]
fn the_toml_and_its_examples_import_and_round_trip() {
use ledger_core::settings::{Settings, Source};
let tmp = tempfile::tempdir().unwrap();
let (_, text) = scaffolded_toml(tmp.path());
let single_owner = |ex: &str| ex.starts_with("close_into ");
let source = Source {
decided_by: "onboarding".into(),
decided_on: chrono_date(),
citation: None,
note: None,
};
for file in [
text.clone(),
uncomment(&text, |ex| !single_owner(ex)),
uncomment(&text, single_owner),
] {
let import = ledger_config::codec::import(&file, None, &Settings::default(), "USD")
.unwrap_or_else(|e| panic!("imports: {e}\n{file}"));
assert!(import.unknown.is_empty(), "{:?}", import.unknown);
let mut books = Settings::default();
let changes = books.check_changes(&import.changes, &source).unwrap();
books.apply(&changes, &source, 1).unwrap();
let export = ledger_config::codec::export(&books, "USD");
let again = ledger_config::codec::import(&export, None, &books, "USD")
.unwrap_or_else(|e| panic!("the export imports: {e}\n{export}"));
assert!(again.changes.is_empty(), "{:?}\n{export}", again.changes);
}
}
fn chrono_date() -> chrono::NaiveDate {
chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()
}