refactor!: one way to serve books: the stdio server is gone (D-44)
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
The owner (2026-10-23): "We should probably just delete the stdio path altogether. THere's no point in that. And it sitting there unused will just lead to bitrot. Nuke it?" Books are served only by the books server (`wanderbooks server`), reached by agents through the bridge (`wanderbooks mcp --book ID`), which stays. Removed: `wanderbooks dev-stdio`, the `ledger-mcp` binary, and `ledger_mcp::run` (stdio and `--http` over a bare data dir), with all that only that mode had: - files the books make coming back inline: `HandedFile.content_base64` is gone, and `download` and `expires_at` are always there; - books with no access log or backups: `books.access_log`, `books.backup` and `books.back_up_now` are always offered, by scope; - the settings file read and reloaded (`legacy_files`), the vault key named by a file or `--vault-key-file`, and the books dir's key-inside-the-books and git checks (with `paths.rs`); - `wire.rs` (the bridge frames an agent's lines itself); - `books.settings`' file fields and `books.start`'s `settings_source`. `BooksServer` is built one way, from `Served` (a book as the books server serves it), in production and in the unit tests. An unknown tool is now a protocol error (mcp-tools.md). Inline file input is unchanged. Then the owner's three decisions (2026-10-23, the question tool): (a) `books.import_settings` is removed entirely (118 tools): old books come in whole by `wanderbooks books adopt`, which reads their settings file once; ledger-config keeps its settings-file parsing only as far as adoption uses it (`load` and the path-reading `effective_receipt_policy` are gone), and `books.chart_template` gives its settings as `books.set_setting` changes. (b) The viewer's loose-books-folder mode is removed (`--data-dir`, `--books-toml`, `--vault-key-file`, the settings file and config.json followed): it opens only books of the data root, reading settings from the journal and the vault from the book's own `vault/` and `keys/`. (c) `wanderbooks dev-stdio` has no stub: it is an unknown command like any other word. Also: an upload's expiry past the calendar is an error, never an empty string. Tests: every suite that drove the stdio server moved to crates/ledger-cli/tests/ and drives a real `wanderbooks server` on a data root of its own (tests/common), handed files by their download; mcp_stdio.rs is mcp_journeys.rs. Tests of what is gone are deleted and listed in docs/testing.md. Docs: plan D-44 (D-18 superseded), STATUS, testing, rules, mcp-interface, the handbook, README; justfile recipes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
87 files changed
+3477
-6476
No files matched your search
@@ -25,16 +25,25 @@ ledger-gui = { path = "../ledger-gui", optional = true }
|
||||
[dev-dependencies]
|
||||
base64 = "0.22"
|
||||
chrono = "0.4"
|
||||
# Every test that drives the books server's tools: the CPA package they
|
||||
# open, the outputSchemas they check against, the random cases, and the
|
||||
# database read_only_tools_change_nothing compares by its rows.
|
||||
jsonschema = { version = "0.58", default-features = false }
|
||||
libc = "0.2"
|
||||
proptest = "1"
|
||||
reqwest = "0.13"
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "time"] }
|
||||
ledger-config = { path = "../ledger-config" }
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-package = { path = "../ledger-package" }
|
||||
ledger-vault = { path = "../ledger-vault" }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
tempfile = "3"
|
||||
zip = "9.0.1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -1,9 +1,10 @@
|
||||
//! `wanderbooks`: the one executable (plan D-22). Its modes are the
|
||||
//! viewer (`gui`), the books server (`server`, and `dev-stdio` over stdio),
|
||||
//! the host and the agents' bridge (`host`, `mcp`), and the owner's
|
||||
//! commands (`setup`, `books`, `connect`, `token`, `passphrase`, `serve`,
|
||||
//! `close`, `doctor`, `upload`, `download`). A build without the `gui`
|
||||
//! feature (the server-only build for another machine) has no viewer.
|
||||
//! viewer (`gui`), the books server (`server`: the one way books are
|
||||
//! served, D-44), the host and the agents' bridge (`host`, `mcp`), and the
|
||||
//! owner's commands (`setup`, `books`, `connect`, `token`, `passphrase`,
|
||||
//! `serve`, `close`, `doctor`, `upload`, `download`). A build without the
|
||||
//! `gui` feature (the server-only build for another machine) has no
|
||||
//! viewer.
|
||||
//!
|
||||
//! From nothing to the first post: `wanderbooks setup`, `wanderbooks books
|
||||
//! create ID`, `wanderbooks connect claude --book ID` (then talk to the
|
||||
@@ -14,7 +15,7 @@ use std::path::PathBuf;
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!("usage: wanderbooks setup [--root PATH] [--home DIR]");
|
||||
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID | --data-dir PATH ...]");
|
||||
eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID]");
|
||||
eprintln!(
|
||||
" wanderbooks books list | create ID | adopt DATA-DIR --id ID | restore BACKUP | back-up ID | upgrade ID | put-aside ID DATA-DIR [PATH...]"
|
||||
);
|
||||
@@ -27,7 +28,6 @@ fn print_usage() {
|
||||
eprintln!(" wanderbooks upload --book ID [--token NAME] UPLOAD [--] FILE");
|
||||
eprintln!(" wanderbooks download --book ID [--token NAME] ATTACHMENT --to FOLDER");
|
||||
eprintln!(" wanderbooks serve | close | doctor");
|
||||
eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]");
|
||||
eprintln!();
|
||||
eprintln!(" setup make the books' private folder (only your account can open it)");
|
||||
eprintln!(" gui open the live, read-only view of the books (a book of the data root)");
|
||||
@@ -43,7 +43,6 @@ fn print_usage() {
|
||||
eprintln!(" serve hold the books open until Ctrl-C");
|
||||
eprintln!(" close close the books now: everything holding them lets go");
|
||||
eprintln!(" doctor whether the books are open, what holds them, what went wrong");
|
||||
eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)");
|
||||
}
|
||||
|
||||
/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone
|
||||
@@ -135,8 +134,8 @@ fn run_gui(args: Vec<String>) -> i32 {
|
||||
.cloned()
|
||||
};
|
||||
// Only the viewer's own flags for books of the data root, each with
|
||||
// its value: anything else (help, a mistake, an older books dir,
|
||||
// the demo) holds nothing.
|
||||
// its value: anything else (help, a mistake, the demo) holds
|
||||
// nothing.
|
||||
let mut known = true;
|
||||
let mut rest = args.iter();
|
||||
while let Some(a) = rest.next() {
|
||||
@@ -300,20 +299,6 @@ fn owner_passphrase(_root: &ledger_store::root::Root) -> i32 {
|
||||
0
|
||||
}
|
||||
|
||||
/// `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
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// `wanderbooks server`: the books server for the data root, on its
|
||||
/// local sockets (plan 3.9). The host starts it.
|
||||
#[cfg(unix)]
|
||||
@@ -420,7 +405,6 @@ fn main() {
|
||||
let mut all = words.clone().into_iter();
|
||||
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())),
|
||||
Some("setup") => std::process::exit(run_setup(all.collect())),
|
||||
Some("server") => std::process::exit(run_server(all.collect())),
|
||||
Some(
|
||||
@@ -450,8 +434,8 @@ fn main() {
|
||||
.find(|w| !w.starts_with('-'))
|
||||
{
|
||||
Some(
|
||||
mode @ ("gui" | "dev-stdio" | "setup" | "server" | "host" | "mcp" | "serve" | "connect"
|
||||
| "close" | "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
|
||||
mode @ ("gui" | "setup" | "server" | "host" | "mcp" | "serve" | "connect" | "close"
|
||||
| "books" | "doctor" | "token" | "passphrase" | "upload" | "download"),
|
||||
) => {
|
||||
eprintln!("`{mode}` must come first: wanderbooks {mode} [its own flags]");
|
||||
std::process::exit(2);
|
||||
@@ -482,7 +466,7 @@ mod gui_args_tests {
|
||||
fn flags_and_anything_else_pass_as_they_are() {
|
||||
for args in [
|
||||
v(&["--demo"]),
|
||||
v(&["--data-dir", "x"]),
|
||||
v(&["--root", "x"]),
|
||||
v(&["a", "b"]),
|
||||
v(&["--help"]),
|
||||
] {
|
||||
|
||||
@@ -0,0 +1,949 @@
|
||||
//! How every test here reaches books: the one way production serves them
|
||||
//! (plan D-44). Each test file says `mod common;` and uses what it needs.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a test drives books exactly as an agent reaches them: a
|
||||
//! `wanderbooks server` on a data root of the test's own, spoken to
|
||||
//! over the root's MCP socket with a bearer token, with the headers
|
||||
//! the agents' bridge sends; a file the books make comes back by
|
||||
//! its download, never inline.
|
||||
//! SOURCE: plan/served.md D-44 (one way to serve books), 3.1 (the
|
||||
//! server's sockets, the admin socket), 3.3 (tokens), 4.7
|
||||
//! (uploads and downloads); ledger-mcp local.rs INVARIANTS 8 and
|
||||
//! 9; ledger-host bridge.rs (what it sends upstream).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every home is a fresh temp dir (`wb-` prefixed, so a sweep of
|
||||
//! the test temp dir finds it), made by `wanderbooks setup --home`;
|
||||
//! its server runs with an empty environment but PATH, and HOME and
|
||||
//! XDG_CONFIG_HOME inside the home, on that home's root alone: never
|
||||
//! this account's root, keyring, or sockets.
|
||||
//! 2. A server is ended by its own child's PID, never by a search.
|
||||
//! 3. Settings a test gives as a settings file's text go into the books
|
||||
//! as an agent puts them there: the changes ledger-config's import
|
||||
//! reads from the text, made with books.set_setting.
|
||||
//! 4. The harness answers nothing itself: every message a test is
|
||||
//! given is one the server sent.
|
||||
//! ```
|
||||
#![allow(dead_code)]
|
||||
|
||||
use ledger_mcp::admin::{self, Request, Response};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
/// The id of the books [`Books`] makes.
|
||||
pub const BOOK: &str = "acme";
|
||||
|
||||
/// How long one request may take before the server counts as hung: long
|
||||
/// enough for a first answer while other test binaries contend for the
|
||||
/// disk (every journal append is synced).
|
||||
pub const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
|
||||
/// The protocol version the tests speak.
|
||||
pub const PROTOCOL: &str = "2025-06-18";
|
||||
|
||||
/// A home with a data root made by `wanderbooks setup` (INVARIANT 1).
|
||||
pub struct Home {
|
||||
pub dir: tempfile::TempDir,
|
||||
/// A token for each book, made on first use (all scopes).
|
||||
tokens: Mutex<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix(&format!("wb-{tag}-"))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["setup", "--home"])
|
||||
.arg(dir.path())
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", dir.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
Self {
|
||||
dir,
|
||||
tokens: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> PathBuf {
|
||||
self.dir.path().join(".local/share/wanderbooks")
|
||||
}
|
||||
|
||||
pub fn run(&self) -> PathBuf {
|
||||
self.root().join("run")
|
||||
}
|
||||
|
||||
/// The folder of the books `id` in the root.
|
||||
pub fn book_dir(&self, id: &str) -> PathBuf {
|
||||
self.root().join("books").join(id)
|
||||
}
|
||||
|
||||
pub fn admin(&self, request: Request) -> Response {
|
||||
admin::call(&self.run().join("admin.sock"), request).unwrap()
|
||||
}
|
||||
|
||||
/// The token tests use for `book`, made through the admin socket the
|
||||
/// first time (every scope).
|
||||
pub fn token(&self, book: &str) -> String {
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
if let Some(t) = tokens.get(book) {
|
||||
return t.clone();
|
||||
}
|
||||
let text = self.token_with(book, "test", &["read", "write", "post", "admin"]);
|
||||
tokens.insert(book.to_string(), text.clone());
|
||||
text
|
||||
}
|
||||
|
||||
/// A new token named `name` for `book` with these scopes (replacing
|
||||
/// one of that name).
|
||||
pub fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
|
||||
match self.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: name.into(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { text, .. } => text.0,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// The text of every answer must not name the root (local.rs
|
||||
/// INVARIANT 7).
|
||||
pub fn assert_hidden(&self, text: &str) {
|
||||
let root = self.root().display().to_string();
|
||||
assert!(!text.contains(&root), "names the data root: {text}");
|
||||
assert!(
|
||||
!text.contains(&self.dir.path().display().to_string()),
|
||||
"names the home: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
pub fn create(&self, id: &str, currency: &str) {
|
||||
match self.admin(Request::Create {
|
||||
id: id.into(),
|
||||
currency: currency.into(),
|
||||
}) {
|
||||
Response::Created { id: made, problem } => {
|
||||
assert_eq!(made, id);
|
||||
assert_eq!(problem, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Adopt the old data dir `source` as the books `id` (plan 5.5), with
|
||||
/// its settings file's text: previewed, then confirmed at the head the
|
||||
/// preview read, as the owner's `wanderbooks books adopt` does.
|
||||
pub fn adopt(&self, id: &str, source: &Path, settings_toml: Option<&str>) {
|
||||
let request = |confirmed: bool, expect_head: Option<(u64, String)>| Request::Adopt {
|
||||
id: id.into(),
|
||||
source: source.to_path_buf(),
|
||||
settings_toml: settings_toml.map(str::to_string),
|
||||
config_json: None,
|
||||
vault_dir: None,
|
||||
vault_key: None,
|
||||
confirmed,
|
||||
expect_head,
|
||||
skip_unknown: true,
|
||||
};
|
||||
let head = match self.admin(request(false, None)) {
|
||||
Response::Preview(p) => p.source_head,
|
||||
other => panic!("adopt preview: {other:?}"),
|
||||
};
|
||||
match self.admin(request(true, Some(head))) {
|
||||
Response::Adopted(_) => {}
|
||||
other => panic!("adopt: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A running `wanderbooks server`, ended (by its own PID) when dropped.
|
||||
pub struct Server {
|
||||
pub child: Child,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Start one on `home`'s root and wait for its ready line.
|
||||
pub fn start(home: &Home) -> Self {
|
||||
Self::start_in(home, home.dir.path(), &[])
|
||||
}
|
||||
|
||||
/// [`Server::start`], in the folder `cwd`, with `env` beside the
|
||||
/// home's own (INVARIANT 1).
|
||||
pub fn start_in(home: &Home, cwd: &Path, env: &[(&str, &Path)]) -> Self {
|
||||
let log = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(home.dir.path().join("server.log"))
|
||||
.unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.arg("server")
|
||||
.arg("--root")
|
||||
.arg(home.root())
|
||||
.current_dir(cwd)
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", home.dir.path())
|
||||
.env("XDG_CONFIG_HOME", home.dir.path().join(".config"))
|
||||
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(log)
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
assert_eq!(line, "ready\n", "the server didn't say ready");
|
||||
Self { child }
|
||||
}
|
||||
|
||||
/// Close it as the host does (stdin ends) and wait for it to exit.
|
||||
pub fn close(mut self) -> std::process::ExitStatus {
|
||||
drop(self.child.stdin.take());
|
||||
let deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
if let Some(status) = self.child.try_wait().unwrap() {
|
||||
return status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the server didn't close");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP client for `home`'s MCP socket, with the socket's short path
|
||||
/// (good while it lives).
|
||||
pub fn mcp_http(home: &Home) -> reqwest::Client {
|
||||
mcp_http_builder(home).build().unwrap()
|
||||
}
|
||||
|
||||
/// [`mcp_http`]'s builder.
|
||||
pub fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
|
||||
mcp_http_builder_at(&home.run())
|
||||
}
|
||||
|
||||
/// [`mcp_http_builder`], for the root's `run/` folder `run`.
|
||||
pub fn mcp_http_builder_at(run: &Path) -> reqwest::ClientBuilder {
|
||||
// The short path is good in this process only, while it lives: kept.
|
||||
let short = ledger_store::root::SocketPath::new(run, "mcp.sock").unwrap();
|
||||
let path = short.path().to_path_buf();
|
||||
std::mem::forget(short);
|
||||
reqwest::Client::builder().unix_socket(path)
|
||||
}
|
||||
|
||||
/// The settings a settings file's text gives, as books.set_setting changes
|
||||
/// (INVARIANT 3): what ledger-config's import reads from it for books
|
||||
/// that set nothing yet.
|
||||
pub fn settings_changes(toml: &str, config_json: Option<&str>, currency: &str) -> Value {
|
||||
let import = ledger_config::codec::import(
|
||||
toml,
|
||||
config_json,
|
||||
&ledger_core::settings::Settings::default(),
|
||||
currency,
|
||||
)
|
||||
.unwrap_or_else(|e| panic!("the test's settings don't read: {e}\n{toml}"));
|
||||
assert!(
|
||||
import.unknown.is_empty(),
|
||||
"the test's settings hold entries the books don't know: {:?}",
|
||||
import.unknown
|
||||
);
|
||||
Value::Array(
|
||||
import
|
||||
.changes
|
||||
.iter()
|
||||
.map(|c| json!({"key": c.key, "value": c.value}))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
/// What one HTTP exchange came back as, when it wasn't a JSON-RPC
|
||||
/// message: refused at the door (a status and its text).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Refusal {
|
||||
pub status: u16,
|
||||
pub body: String,
|
||||
/// It carried local.rs's REFUSED_HEADER (not run).
|
||||
pub not_run: bool,
|
||||
}
|
||||
|
||||
/// One MCP session with one book, synchronous: what a test sends is
|
||||
/// POSTed to `/b/{book}/mcp` as the bridge does, and every message the
|
||||
/// server answers with is kept for [`Wire::recv`] (INVARIANT 4).
|
||||
pub struct Wire {
|
||||
http: reqwest::Client,
|
||||
book: String,
|
||||
token: Option<String>,
|
||||
session: Option<String>,
|
||||
version: Option<String>,
|
||||
told: VecDeque<Value>,
|
||||
/// The last exchange that came back as no message at all.
|
||||
pub refused: Option<Refusal>,
|
||||
/// The HTTP status of the last exchange.
|
||||
pub status: u16,
|
||||
next_id: u64,
|
||||
/// Last: the client's connections are dropped inside it.
|
||||
rt: tokio::runtime::Runtime,
|
||||
}
|
||||
|
||||
impl Wire {
|
||||
/// A session with `book` (not yet initialized), with its token.
|
||||
pub fn new(home: &Home, book: &str) -> Self {
|
||||
let token = home.token(book);
|
||||
Self::with_token(home, book, Some(token))
|
||||
}
|
||||
|
||||
/// [`Wire::new`], with this token (or none).
|
||||
pub fn with_token(home: &Home, book: &str, token: Option<String>) -> Self {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let http = mcp_http_builder(home).timeout(TIMEOUT).build().unwrap();
|
||||
Self {
|
||||
rt,
|
||||
http,
|
||||
book: book.to_string(),
|
||||
token,
|
||||
session: None,
|
||||
version: None,
|
||||
told: VecDeque::new(),
|
||||
refused: None,
|
||||
status: 0,
|
||||
next_id: 1_000_000,
|
||||
}
|
||||
}
|
||||
|
||||
/// The token this session sends.
|
||||
pub fn token(&self) -> Option<&str> {
|
||||
self.token.as_deref()
|
||||
}
|
||||
|
||||
/// Initialize and send `notifications/initialized`: the initialize
|
||||
/// answer.
|
||||
pub fn initialize(&mut self, client: &str) -> Value {
|
||||
let init = self.rpc(
|
||||
"initialize",
|
||||
json!({"protocolVersion": PROTOCOL, "capabilities": {},
|
||||
"clientInfo": {"name": client, "version": "0.1.0"}}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
self.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
||||
init
|
||||
}
|
||||
|
||||
/// Send a request with an id of the wire's own and take its answer.
|
||||
pub fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
self.send(&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}));
|
||||
let resp = self
|
||||
.recv(TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no answer to {method} (id {id}): {:?}", self.refused));
|
||||
assert_eq!(resp["id"], json!(id), "answer id mismatch: {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
/// Call a tool: the `tools/call` answer.
|
||||
pub fn call(&mut self, tool: &str, args: Value) -> Value {
|
||||
self.rpc("tools/call", json!({"name": tool, "arguments": args}))
|
||||
}
|
||||
|
||||
/// Send one message.
|
||||
pub fn send(&mut self, message: &Value) {
|
||||
self.send_line(&message.to_string());
|
||||
}
|
||||
|
||||
/// Send `text` as one message's body, as it is (a test's own bytes,
|
||||
/// malformed ones included).
|
||||
pub fn send_line(&mut self, text: &str) {
|
||||
let parsed: Option<Value> = serde_json::from_str(text).ok();
|
||||
let method = parsed
|
||||
.as_ref()
|
||||
.and_then(|m| m.get("method"))
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string);
|
||||
let mut req = self
|
||||
.http
|
||||
.post(format!("http://localhost/b/{}/mcp", self.book))
|
||||
.header("content-type", "application/json")
|
||||
.header("accept", "application/json, text/event-stream")
|
||||
.body(text.to_string());
|
||||
if let Some(t) = &self.token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
if let Some(s) = &self.session {
|
||||
req = req.header("mcp-session-id", s);
|
||||
}
|
||||
if let Some(v) = &self.version {
|
||||
req = req.header("mcp-protocol-version", v);
|
||||
}
|
||||
let (status, headers, body) = self.rt.block_on(async {
|
||||
let r = req.send().await.expect("the server's socket answers");
|
||||
let status = r.status().as_u16();
|
||||
let headers = r.headers().clone();
|
||||
let body = r.text().await.unwrap_or_default();
|
||||
(status, headers, body)
|
||||
});
|
||||
if method.as_deref() == Some("initialize")
|
||||
&& let Some(s) = headers.get("mcp-session-id").and_then(|v| v.to_str().ok())
|
||||
{
|
||||
self.session = Some(s.to_string());
|
||||
}
|
||||
let is_stream = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|t| t.starts_with("text/event-stream"));
|
||||
// A message the server sends is JSON, or the test fails here: a
|
||||
// stream's data line (an empty one is the stream's priming), or a
|
||||
// body it says is JSON. Anything else is a refusal, kept below.
|
||||
let is_json = headers
|
||||
.get("content-type")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|t| t.starts_with("application/json"));
|
||||
let messages: Vec<Value> = if is_stream {
|
||||
body.lines()
|
||||
.filter_map(|l| l.strip_prefix("data:"))
|
||||
.map(str::trim)
|
||||
.filter(|d| !d.is_empty())
|
||||
.map(|d| {
|
||||
serde_json::from_str(d)
|
||||
.unwrap_or_else(|e| panic!("the server streamed no JSON ({e}): {d}"))
|
||||
})
|
||||
.collect()
|
||||
} else if is_json && !body.trim().is_empty() {
|
||||
let v: Value = serde_json::from_str(body.trim())
|
||||
.unwrap_or_else(|e| panic!("the server's JSON isn't ({e}): {body}"));
|
||||
[v].into_iter()
|
||||
.filter(|v| v.get("jsonrpc").is_some() || v.is_array())
|
||||
.collect()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
self.status = status;
|
||||
if messages.is_empty() && status != 202 {
|
||||
self.refused = Some(Refusal {
|
||||
status,
|
||||
body: body.clone(),
|
||||
not_run: headers.contains_key(ledger_mcp::local::REFUSED_HEADER),
|
||||
});
|
||||
} else {
|
||||
self.refused = None;
|
||||
}
|
||||
for m in messages {
|
||||
if method.as_deref() == Some("initialize")
|
||||
&& let Some(v) = m.pointer("/result/protocolVersion").and_then(Value::as_str)
|
||||
{
|
||||
self.version = Some(v.to_string());
|
||||
}
|
||||
// Notifications the server sends on its own are not answers.
|
||||
if m.get("id").is_none() && m.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
self.told.push_back(m);
|
||||
}
|
||||
}
|
||||
|
||||
/// The next message the server answered with, if any. An answer
|
||||
/// comes back with its request, so nothing is waited for.
|
||||
pub fn recv(&mut self, _wait: Duration) -> Option<Value> {
|
||||
self.told.pop_front()
|
||||
}
|
||||
|
||||
/// GET `path` (under `/b/{book}/`) with this session's token: the
|
||||
/// status and the bytes.
|
||||
pub fn get(&mut self, path: &str) -> (u16, Vec<u8>) {
|
||||
let mut req = self
|
||||
.http
|
||||
.get(format!("http://localhost/b/{}/{path}", self.book));
|
||||
if let Some(t) = &self.token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
self.rt.block_on(async {
|
||||
let r = req.send().await.expect("the server's socket answers");
|
||||
let status = r.status().as_u16();
|
||||
(status, r.bytes().await.unwrap_or_default().to_vec())
|
||||
})
|
||||
}
|
||||
|
||||
/// The bytes of a file the books handed over (a tool's HandedFile):
|
||||
/// fetched by its download with this session's token, and checked
|
||||
/// against its size and SHA-256.
|
||||
pub fn fetch(&mut self, file: &Value) -> Vec<u8> {
|
||||
let command = file["download"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("no download in {file}"));
|
||||
let id = command
|
||||
.split(' ')
|
||||
.find(|w| w.starts_with("ex_"))
|
||||
.unwrap_or_else(|| panic!("no export id in {command}"));
|
||||
let (status, bytes) = self.get(&format!("x/{id}"));
|
||||
assert_eq!(
|
||||
status,
|
||||
200,
|
||||
"fetching {id}: {}",
|
||||
String::from_utf8_lossy(&bytes)
|
||||
);
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes)),
|
||||
"{file}"
|
||||
);
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// End the session (DELETE, as the bridge does when its agent goes).
|
||||
pub fn end(&mut self) {
|
||||
let Some(s) = self.session.take() else { return };
|
||||
let mut req = self
|
||||
.http
|
||||
.delete(format!("http://localhost/b/{}/mcp", self.book))
|
||||
.header("mcp-session-id", s);
|
||||
if let Some(t) = &self.token {
|
||||
req = req.bearer_auth(t);
|
||||
}
|
||||
let _ = self.rt.block_on(async { req.send().await });
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's own books: a home, its server, and the books [`BOOK`] made in
|
||||
/// it, with the settings the test gives.
|
||||
pub struct Books {
|
||||
pub home: Home,
|
||||
server: std::cell::RefCell<Option<Server>>,
|
||||
pub id: String,
|
||||
pub currency: String,
|
||||
}
|
||||
|
||||
impl Books {
|
||||
/// New books in a new home, kept in USD, with no settings set.
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self::with(tag, "USD", None)
|
||||
}
|
||||
|
||||
/// New books in `currency`, with the settings `settings_toml` (a
|
||||
/// settings file's text) gives, set as an agent sets them.
|
||||
pub fn with(tag: &str, currency: &str, settings_toml: Option<&str>) -> Self {
|
||||
let home = Home::new(tag);
|
||||
let server = Server::start(&home);
|
||||
home.create(BOOK, currency);
|
||||
let books = Self {
|
||||
home,
|
||||
server: std::cell::RefCell::new(Some(server)),
|
||||
id: BOOK.to_string(),
|
||||
currency: currency.to_string(),
|
||||
};
|
||||
if let Some(toml) = settings_toml {
|
||||
books.settle(toml);
|
||||
}
|
||||
books
|
||||
}
|
||||
|
||||
/// Books an earlier wanderbooks left in the data root: the files of
|
||||
/// `fixture` (a book's folder: `journal.jsonl` and what else it holds)
|
||||
/// put in the root before the server opens them, private as the server
|
||||
/// keeps a book.
|
||||
pub fn placed(tag: &str, fixture: &Path) -> Self {
|
||||
let home = Home::new(tag);
|
||||
copy_private(fixture, &home.book_dir(BOOK));
|
||||
let server = Server::start(&home);
|
||||
Self {
|
||||
home,
|
||||
server: std::cell::RefCell::new(Some(server)),
|
||||
id: BOOK.to_string(),
|
||||
currency: "USD".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Books adopted from the old data dir `source`, with its settings
|
||||
/// file's text (plan 5.5).
|
||||
pub fn adopted(tag: &str, source: &Path, settings_toml: Option<&str>) -> Self {
|
||||
let home = Home::new(tag);
|
||||
let server = Server::start(&home);
|
||||
home.adopt(BOOK, source, settings_toml);
|
||||
Self {
|
||||
home,
|
||||
server: std::cell::RefCell::new(Some(server)),
|
||||
id: BOOK.to_string(),
|
||||
currency: "USD".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The books' folder in the root.
|
||||
pub fn dir(&self) -> PathBuf {
|
||||
self.home.book_dir(&self.id)
|
||||
}
|
||||
|
||||
/// The books' journal, as it is on disk.
|
||||
pub fn journal(&self) -> Vec<u8> {
|
||||
std::fs::read(self.dir().join("journal.jsonl")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// A new session with the books (not yet initialized).
|
||||
pub fn wire(&self) -> Wire {
|
||||
Wire::new(&self.home, &self.id)
|
||||
}
|
||||
|
||||
/// A new, initialized session with the books.
|
||||
pub fn session(&self, client: &str) -> Wire {
|
||||
let mut w = self.wire();
|
||||
w.initialize(client);
|
||||
w
|
||||
}
|
||||
|
||||
/// Set the settings a settings file's text gives (INVARIANT 3).
|
||||
pub fn settle(&self, toml: &str) {
|
||||
self.settle_with(toml, None);
|
||||
}
|
||||
|
||||
/// [`Books::settle`], with a receipt policy file's text too (its
|
||||
/// `receipt_policy` wins over the settings file's receipt settings).
|
||||
pub fn settle_with(&self, toml: &str, config_json: Option<&str>) {
|
||||
if let Err(e) = self.try_settle(toml, config_json) {
|
||||
panic!("the test's settings were refused: {e}\n{toml}");
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Books::settle_with`]: Err with the refusal's text.
|
||||
pub fn try_settle(&self, toml: &str, config_json: Option<&str>) -> Result<(), String> {
|
||||
settle_through(&self.home, &self.id, &self.currency, toml, config_json)
|
||||
}
|
||||
|
||||
/// Close the server (as the host does) and wait for it to exit.
|
||||
pub fn stop(&self) {
|
||||
if let Some(s) = self.server.borrow_mut().take() {
|
||||
assert!(s.close().success(), "the server didn't close cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the server again (after [`Books::stop`]).
|
||||
pub fn start(&self) {
|
||||
let mut server = self.server.borrow_mut();
|
||||
assert!(server.is_none(), "the server is running");
|
||||
*server = Some(Server::start(&self.home));
|
||||
}
|
||||
|
||||
/// Close the server and start it again on the same root.
|
||||
pub fn restart(&self) {
|
||||
self.stop();
|
||||
self.start();
|
||||
}
|
||||
|
||||
/// The running server's process id.
|
||||
pub fn server_pid(&self) -> Option<u32> {
|
||||
self.server.borrow().as_ref().map(|s| s.child.id())
|
||||
}
|
||||
|
||||
/// The server log (its stderr).
|
||||
pub fn server_log(&self) -> String {
|
||||
std::fs::read_to_string(self.home.dir.path().join("server.log")).unwrap_or_default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file under `dir`, by path relative to it, with the SHA-256 of its
|
||||
/// contents, leaving out what a call writes by design whatever the call:
|
||||
/// the access log (every call is recorded), the viewer's side channel
|
||||
/// (`ops/`), the books' own backup (`archive.git/`), the files handed over
|
||||
/// and on their way in (`exports/`, `uploads/`), and SQLite's -wal and
|
||||
/// -shm files (it moves pages between them on its own).
|
||||
pub fn book_files(dir: &Path) -> std::collections::BTreeMap<PathBuf, String> {
|
||||
fn walk(base: &Path, dir: &Path, out: &mut std::collections::BTreeMap<PathBuf, String>) {
|
||||
let Ok(rd) = std::fs::read_dir(dir) else {
|
||||
return;
|
||||
};
|
||||
for e in rd.flatten() {
|
||||
let p = e.path();
|
||||
let rel = p.strip_prefix(base).unwrap().to_path_buf();
|
||||
let name = rel.to_string_lossy().into_owned();
|
||||
let first = rel
|
||||
.components()
|
||||
.next()
|
||||
.map(|c| c.as_os_str().to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
if ["ops", "archive.git", "exports", "uploads"].contains(&first.as_str())
|
||||
|| name == "access.jsonl"
|
||||
|| name.ends_with("-wal")
|
||||
|| name.ends_with("-shm")
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
walk(base, &p, out);
|
||||
} else if let Ok(bytes) = std::fs::read(&p) {
|
||||
out.insert(rel, ledger_store::journal::sha256_hex(&bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
walk(dir, dir, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// Copy the tree `from` into `to`, private as the server keeps a book
|
||||
/// (folders 0700, files 0600).
|
||||
pub fn copy_private(from: &Path, to: &Path) {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
std::fs::set_permissions(to, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
for entry in std::fs::read_dir(from).unwrap().flatten() {
|
||||
let (src, dst) = (entry.path(), to.join(entry.file_name()));
|
||||
if entry.file_type().unwrap().is_dir() {
|
||||
copy_private(&src, &dst);
|
||||
} else {
|
||||
std::fs::copy(&src, &dst).unwrap();
|
||||
std::fs::set_permissions(&dst, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's place: a home (whose folder holds the test's own files, its
|
||||
/// settings file among them) and the books [`BOOK`] in its root, served
|
||||
/// by a server [`Place::start`] starts, and starts again on each next
|
||||
/// call, as a new server process reads the same books afresh. A test may
|
||||
/// write books into [`Place::books_data`] before the first start (books
|
||||
/// an earlier version left in the root, a journal written by hand); the
|
||||
/// server opens them as it opens any books in its root.
|
||||
pub struct Place {
|
||||
pub home: Home,
|
||||
server: std::cell::RefCell<Option<Server>>,
|
||||
/// The settings file's text set in the books, once set.
|
||||
settled: std::cell::RefCell<Option<String>>,
|
||||
/// The currency books made by the first start are kept in.
|
||||
pub currency: String,
|
||||
/// The folder the server runs in, when not the home.
|
||||
cwd: std::cell::RefCell<Option<PathBuf>>,
|
||||
}
|
||||
|
||||
impl std::ops::Deref for Place {
|
||||
type Target = Path;
|
||||
fn deref(&self) -> &Path {
|
||||
self.home.dir.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<Path> for Place {
|
||||
fn as_ref(&self) -> &Path {
|
||||
self.home.dir.path()
|
||||
}
|
||||
}
|
||||
|
||||
impl Place {
|
||||
pub fn new(tag: &str) -> Self {
|
||||
Self {
|
||||
home: Home::new(tag),
|
||||
server: Default::default(),
|
||||
settled: Default::default(),
|
||||
currency: "USD".to_string(),
|
||||
cwd: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Run the server in `cwd` from its next start (where a relative path
|
||||
/// would land).
|
||||
pub fn run_in(&self, cwd: &Path) {
|
||||
*self.cwd.borrow_mut() = Some(cwd.to_path_buf());
|
||||
}
|
||||
|
||||
/// The books' folder in the root (where a test may write books before
|
||||
/// the first start).
|
||||
pub fn books_data(&self) -> PathBuf {
|
||||
self.home.book_dir(BOOK)
|
||||
}
|
||||
|
||||
/// Start the server (closing the one running first): books made if
|
||||
/// the root holds none yet, the settings `settings_toml` gives set the
|
||||
/// first time; and a new session with them (not yet initialized).
|
||||
pub fn start(&self, settings_toml: Option<&str>) -> Wire {
|
||||
self.stop();
|
||||
let dir = self.books_data();
|
||||
let fresh = !dir.join("journal.jsonl").exists();
|
||||
if dir.exists() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
}
|
||||
let cwd = self.cwd.borrow().clone();
|
||||
let cwd = cwd.as_deref().unwrap_or(self.home.dir.path());
|
||||
*self.server.borrow_mut() = Some(Server::start_in(&self.home, cwd, &[]));
|
||||
if fresh {
|
||||
self.home.create(BOOK, &self.currency);
|
||||
}
|
||||
if let Some(toml) = settings_toml {
|
||||
let mut settled = self.settled.borrow_mut();
|
||||
match settled.as_deref() {
|
||||
Some(was) => assert_eq!(
|
||||
was, toml,
|
||||
"the test changed its settings file between starts: books keep their settings, change them with books.set_setting"
|
||||
),
|
||||
None => {
|
||||
settle_through(&self.home, BOOK, &self.currency, toml, None).unwrap_or_else(
|
||||
|e| panic!("the test's settings were refused: {e}\n{toml}"),
|
||||
);
|
||||
*settled = Some(toml.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
Wire::new(&self.home, BOOK)
|
||||
}
|
||||
|
||||
/// Close the server, if one runs, and wait for it to exit.
|
||||
pub fn stop(&self) {
|
||||
if let Some(s) = self.server.borrow_mut().take() {
|
||||
assert!(s.close().success(), "the server didn't close cleanly");
|
||||
}
|
||||
}
|
||||
|
||||
/// The running server's process id.
|
||||
pub fn server_pid(&self) -> Option<u32> {
|
||||
self.server.borrow().as_ref().map(|s| s.child.id())
|
||||
}
|
||||
}
|
||||
|
||||
/// Set the settings `toml` (and `config_json`) give in `book`, through a
|
||||
/// session of its own (INVARIANT 3): Err with the refusal.
|
||||
pub fn settle_through(
|
||||
home: &Home,
|
||||
book: &str,
|
||||
currency: &str,
|
||||
toml: &str,
|
||||
config_json: Option<&str>,
|
||||
) -> Result<(), String> {
|
||||
let changes = settings_changes(toml, config_json, currency);
|
||||
if changes.as_array().is_some_and(Vec::is_empty) {
|
||||
return Ok(());
|
||||
}
|
||||
let mut w = Wire::new(home, book);
|
||||
w.initialize("settings");
|
||||
let r = w.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": changes, "decided_by": "Test Owner"}),
|
||||
);
|
||||
w.end();
|
||||
if r["result"]["isError"] == json!(true) || r.get("error").is_some() {
|
||||
return Err(r.to_string());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// An rmcp client session with a book, for async tests.
|
||||
pub type Client =
|
||||
rmcp::service::RunningService<rmcp::service::RoleClient, rmcp::model::ClientConfig>;
|
||||
|
||||
/// An HTTP client for `home`'s MCP socket that sends `book`'s token on
|
||||
/// every request.
|
||||
pub fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", home.token(book)).parse().unwrap(),
|
||||
);
|
||||
mcp_http_builder(home)
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An initialized rmcp client session with `book`, with its token.
|
||||
pub async fn connect(home: &Home, book: &str) -> Result<Client, String> {
|
||||
let token = home.token(book);
|
||||
connect_with(home, book, Some(token)).await
|
||||
}
|
||||
|
||||
/// [`connect`], with this token (or none).
|
||||
pub async fn connect_with(
|
||||
home: &Home,
|
||||
book: &str,
|
||||
token: Option<String>,
|
||||
) -> Result<Client, String> {
|
||||
connect_at(&home.run(), book, token).await
|
||||
}
|
||||
|
||||
/// [`connect_with`], to the server whose `run/` folder is `run` (for a
|
||||
/// task that holds no [`Home`]).
|
||||
pub async fn connect_at(run: &Path, book: &str, token: Option<String>) -> Result<Client, String> {
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
let http = mcp_http_builder_at(run).build().unwrap();
|
||||
let mut config =
|
||||
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
|
||||
if let Some(token) = token {
|
||||
config = config.auth_header(token);
|
||||
}
|
||||
let transport = StreamableHttpClientTransport::with_client(http, config);
|
||||
rmcp::model::ClientConfig::default()
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Call a tool: Ok(structured result) or Err(the error text).
|
||||
pub async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
|
||||
use rmcp::model::CallToolRequestParams;
|
||||
let args = args.as_object().cloned().unwrap_or_default();
|
||||
// A client whose server went away may wait forever: a call that
|
||||
// takes this long counts as refused.
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "no answer".to_string())?
|
||||
.map_err(|e| format!("protocol: {e}"))?;
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_text().map(|t| t.text.clone()))
|
||||
.unwrap_or_default();
|
||||
if r.is_error == Some(true) {
|
||||
Err(text)
|
||||
} else {
|
||||
Ok(r.structured_content
|
||||
.unwrap_or_else(|| serde_json::from_str(&text).unwrap_or(Value::String(text))))
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch the export a tool handed over in `file` (its `download`
|
||||
/// command's id) from `book` with `token`: its bytes, checked against its
|
||||
/// size and SHA-256.
|
||||
pub async fn fetch_export(home: &Home, book: &str, token: &str, file: &Value) -> Vec<u8> {
|
||||
let command = file["download"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{file}"));
|
||||
let id = command
|
||||
.split(' ')
|
||||
.find(|w| w.starts_with("ex_"))
|
||||
.unwrap_or_else(|| panic!("{command}"));
|
||||
let r = mcp_http(home)
|
||||
.get(format!("http://localhost/b/{book}/x/{id}"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers());
|
||||
let bytes = r.bytes().await.unwrap().to_vec();
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes))
|
||||
);
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()));
|
||||
bytes
|
||||
}
|
||||
+121
-112
@@ -23,22 +23,24 @@
|
||||
//! books.chart and books.verify (P0 review round 4: a call a command
|
||||
//! accepts but replay refuses would brick the books).
|
||||
//! 5. Books an earlier version wrote (ledger-store's old-books fixtures)
|
||||
//! open, and every tool and resource answers over them.
|
||||
//! open, and every tool and resource answers over them: with the
|
||||
//! fixture company's settings where their format keeps settings, on
|
||||
//! the defaults where they wait for their format upgrade.
|
||||
//! ```
|
||||
//!
|
||||
//! Against a small fixture company with every kind of record, so ids and
|
||||
//! names the generator picks often exist. `MCP_CASES` sets the number of
|
||||
//! cases (default 256).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use proptest::prelude::*;
|
||||
use proptest::test_runner::{Config, TestCaseError, TestRunner};
|
||||
use serde_json::{Map, Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
mod common;
|
||||
|
||||
/// The longest any one call may take: a call that hangs is a denial of
|
||||
/// service too.
|
||||
const TIMEOUT: Duration = Duration::from_secs(120);
|
||||
@@ -53,68 +55,42 @@ fn cases() -> u32 {
|
||||
.unwrap_or(256)
|
||||
}
|
||||
|
||||
/// One session with the books (common.rs).
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
/// The settings the fixture company keeps.
|
||||
const SETTINGS: &str = "[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\
|
||||
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n";
|
||||
|
||||
/// The places one run uses: the books, the vault key, a folder of files on
|
||||
/// their way in (as a downloads folder), the folder the server runs in
|
||||
/// (where a relative path lands).
|
||||
/// The places one run uses: the test's home and its books (common.rs), a
|
||||
/// folder of files on their way in (as a downloads folder), and the
|
||||
/// folder the server runs in (where a relative path lands), both in the
|
||||
/// home and outside the data root.
|
||||
struct Places {
|
||||
base: PathBuf,
|
||||
place: Place,
|
||||
/// The books' folder in the data root.
|
||||
books: PathBuf,
|
||||
inbox: PathBuf,
|
||||
sandbox: PathBuf,
|
||||
}
|
||||
|
||||
/// The places go when the test ends, pass or panic.
|
||||
impl Drop for Places {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.base);
|
||||
}
|
||||
}
|
||||
|
||||
fn places(tag: &str) -> Places {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("ledger-mcp-hostile-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let place = Place::new(&format!("hostile-{tag}"));
|
||||
let p = Places {
|
||||
books: base.join("books"),
|
||||
inbox: base.join("inbox"),
|
||||
sandbox: base.join("sandbox"),
|
||||
base,
|
||||
books: place.books_data(),
|
||||
inbox: place.join("inbox"),
|
||||
sandbox: place.join("sandbox"),
|
||||
place,
|
||||
};
|
||||
for d in [
|
||||
&p.books,
|
||||
&p.inbox,
|
||||
&p.sandbox.join("out"),
|
||||
&p.base.join("home"),
|
||||
&p.base.join("config"),
|
||||
] {
|
||||
for d in [&p.inbox, &p.sandbox.join("out")] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
p.books.join("wanderbooks.toml"),
|
||||
format!(
|
||||
"[company]\nname = \"Hostile Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\
|
||||
[[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\nparties = \"suggest\"\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
p.base.join("config/vault.key").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
p.place.run_in(&p.sandbox);
|
||||
std::fs::write(p.inbox.join("receipt.pdf"), b"%PDF-1.4 a receipt\n%%EOF\n").unwrap();
|
||||
std::fs::write(
|
||||
p.inbox.join("bank.csv"),
|
||||
@@ -125,53 +101,70 @@ fn places(tag: &str) -> Places {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// Start (or restart) the books' server, and a session with it.
|
||||
fn start(p: &Places) -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(p.books.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(p.books.join("wanderbooks.toml"))
|
||||
.current_dir(&p.sandbox)
|
||||
.env("HOME", p.base.join("home"))
|
||||
.env("XDG_CONFIG_HOME", p.base.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", p.base.join("config"))
|
||||
.env_remove("XDG_DOWNLOAD_DIR")
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::from(
|
||||
std::fs::File::create(p.base.join("stderr.log")).unwrap(),
|
||||
))
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
Self::session(p.place.start(Some(SETTINGS)))
|
||||
}
|
||||
|
||||
/// Start (or restart) the server on books an earlier version wrote,
|
||||
/// with the fixture company's settings set as an agent sets them, one
|
||||
/// at a time. Books in a journal format before 2 keep no settings
|
||||
/// until the owner's format upgrade (rules.md B-27, D-39): each change
|
||||
/// is refused as `format_upgrade_pending`, and those books are served
|
||||
/// on the defaults (no members, no 1065, no cash accounts), as they
|
||||
/// would be in a data root. Books with periods closed under the
|
||||
/// calendar year keep it (a changed tax year needs the IRS's consent,
|
||||
/// rules.md B-29), whatever their format. Any other answer fails the
|
||||
/// test when the books are first served (`first`); on a restart,
|
||||
/// after hostile calls that may have changed a setting and closed
|
||||
/// periods under it, the books may refuse a change for what they now
|
||||
/// hold, as they would any agent.
|
||||
fn start_old(p: &Places, first: bool) -> Self {
|
||||
let mut m = Self::session(p.place.start(None));
|
||||
let changes = common::settings_changes(SETTINGS, None, "USD");
|
||||
let mut refused = Vec::new();
|
||||
let mut held = 0;
|
||||
for change in changes.as_array().unwrap() {
|
||||
let r = m.rpc(
|
||||
"tools/call",
|
||||
json!({"name": "books.set_setting",
|
||||
"arguments": {"changes": [change], "decided_by": "Test Owner"}}),
|
||||
);
|
||||
assert!(r.get("error").is_none(), "{change}: {r}");
|
||||
if first && r["result"]["isError"] == json!(true) {
|
||||
let why = r["result"]["content"][0]["text"]
|
||||
.as_str()
|
||||
.unwrap_or_default();
|
||||
if change["key"] == "company.fiscal_year_start"
|
||||
&& why.contains("periods are closed under it")
|
||||
{
|
||||
held += 1;
|
||||
continue;
|
||||
}
|
||||
assert!(
|
||||
why.starts_with("format_upgrade_pending:"),
|
||||
"{change}: {why}"
|
||||
);
|
||||
refused.push(change["key"].clone());
|
||||
}
|
||||
});
|
||||
let mut m = Self {
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
};
|
||||
}
|
||||
assert!(
|
||||
refused.is_empty() || refused.len() + held == changes.as_array().unwrap().len(),
|
||||
"books that take some settings take them all: {refused:?} refused"
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
fn session(wire: Wire) -> Self {
|
||||
let mut m = Self { wire, next_id: 1 };
|
||||
let init = m.rpc(
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "hostile", "version": "0"}}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "{init}");
|
||||
writeln!(
|
||||
m.stdin,
|
||||
"{}",
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.unwrap();
|
||||
m.wire
|
||||
.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}));
|
||||
m
|
||||
}
|
||||
|
||||
@@ -185,18 +178,17 @@ impl Mcp {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").map_err(|e| format!("write: {e}"))?;
|
||||
self.stdin.flush().map_err(|e| format!("flush: {e}"))?;
|
||||
loop {
|
||||
let line = self
|
||||
.rx
|
||||
.recv_timeout(TIMEOUT)
|
||||
.map_err(|_| format!("no answer within {TIMEOUT:?} (or the server died)"))?;
|
||||
let v: Value =
|
||||
serde_json::from_str(&line).map_err(|e| format!("non-JSON {line:?}: {e}"))?;
|
||||
if v.get("id") == Some(&json!(id)) {
|
||||
return Ok(v);
|
||||
}
|
||||
self.wire.send(&msg);
|
||||
let v = self.wire.recv(TIMEOUT).ok_or_else(|| {
|
||||
format!(
|
||||
"no answer within {TIMEOUT:?} (or the server died): {:?}",
|
||||
self.wire.refused
|
||||
)
|
||||
})?;
|
||||
if v.get("id") == Some(&json!(id)) {
|
||||
Ok(v)
|
||||
} else {
|
||||
Err(format!("an answer to another request: {v}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +203,20 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every file under `dir`, with its bytes.
|
||||
fn files_in(dir: &Path) -> std::collections::BTreeMap<PathBuf, Vec<u8>> {
|
||||
let mut out = std::collections::BTreeMap::new();
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
if p.is_dir() {
|
||||
out.extend(files_in(&p));
|
||||
} else {
|
||||
out.insert(p.clone(), std::fs::read(&p).unwrap_or_default());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A small company with one of everything, so generated ids and names
|
||||
/// often find a record.
|
||||
fn fixture(m: &mut Mcp, p: &Places) {
|
||||
@@ -518,10 +524,9 @@ fn hostile_paths(p: &Places) -> Vec<String> {
|
||||
s(&p.inbox.join("x.zip")),
|
||||
format!("{}/../inbox/receipt.pdf", s(&p.inbox)),
|
||||
format!("{}/./receipt.pdf", s(&p.inbox)),
|
||||
s(&p.books.join("wanderbooks.toml")),
|
||||
s(&p.books.join("books-data/journal.jsonl")),
|
||||
s(&p.books.join("journal.jsonl")),
|
||||
s(&p.books.join("out.zip")),
|
||||
s(&p.base.join("config/vault.key")),
|
||||
s(&p.books.join("keys/legacy.key")),
|
||||
"relative.pdf".into(),
|
||||
"out/rel.zip".into(),
|
||||
String::new(),
|
||||
@@ -813,6 +818,9 @@ fn hostile_tool_arguments_never_stop_the_server() {
|
||||
let p = places("tools");
|
||||
let mut m = Mcp::start(&p);
|
||||
fixture(&mut m, &p);
|
||||
// The files on their way in, and the folder the server runs in: no
|
||||
// call writes or changes one.
|
||||
let outside = (files_in(&p.inbox), files_in(&p.sandbox));
|
||||
let list = m.rpc("tools/list", json!({}));
|
||||
let tools: Vec<(String, Value)> = list["result"]["tools"]
|
||||
.as_array()
|
||||
@@ -882,8 +890,11 @@ fn hostile_tool_arguments_never_stop_the_server() {
|
||||
let r = m.try_rpc("tools/call", json!({"name": tool, "arguments": args}));
|
||||
check_answer(&mut m, &what, r).unwrap();
|
||||
}
|
||||
assert!(
|
||||
outside == (files_in(&p.inbox), files_in(&p.sandbox)),
|
||||
"a call wrote outside the books"
|
||||
);
|
||||
reopens(m, &p, "every tool");
|
||||
let _ = std::fs::remove_dir_all(&p.base);
|
||||
}
|
||||
|
||||
/// INVARIANT 4: whatever the calls journaled, the books open again. A
|
||||
@@ -1219,7 +1230,6 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
] {
|
||||
pinned(&mut m, tool, args);
|
||||
}
|
||||
let _ = std::fs::remove_dir_all(&p.base);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: resources/read with generated URIs.
|
||||
@@ -1280,7 +1290,6 @@ fn hostile_resource_uris_never_stop_the_server() {
|
||||
})
|
||||
.unwrap();
|
||||
reopens(m.into_inner(), &p, "the resources");
|
||||
let _ = std::fs::remove_dir_all(&p.base);
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
@@ -1492,8 +1501,9 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
|
||||
let mut failures: Vec<String> = Vec::new();
|
||||
let name = dir.file_name().unwrap().to_string_lossy().to_string();
|
||||
let p = places(&format!("old-{name}"));
|
||||
copy_books(dir, &p.books.join("books-data"));
|
||||
let mut m = Mcp::start(&p);
|
||||
// As an earlier version left them in the data root.
|
||||
copy_books(dir, &p.books);
|
||||
let mut m = Mcp::start_old(&p, true);
|
||||
let list = m.rpc("tools/list", json!({}));
|
||||
let tools: Vec<(String, Value)> = list["result"]["tools"]
|
||||
.as_array()
|
||||
@@ -1526,7 +1536,7 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
|
||||
if let Err(e) = check_answer(&mut m, &what, r) {
|
||||
failures.push(e.to_string());
|
||||
drop(m);
|
||||
m = Mcp::start(&p);
|
||||
m = Mcp::start_old(&p, false);
|
||||
}
|
||||
}
|
||||
for uri in [
|
||||
@@ -1540,12 +1550,12 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
|
||||
if let Err(e) = check_answer(&mut m, &format!("{name}: {uri}"), r) {
|
||||
failures.push(e.to_string());
|
||||
drop(m);
|
||||
m = Mcp::start(&p);
|
||||
m = Mcp::start_old(&p, false);
|
||||
}
|
||||
}
|
||||
// Whatever the calls wrote, the books reopen.
|
||||
drop(m);
|
||||
let mut m = Mcp::start(&p);
|
||||
let mut m = Mcp::start_old(&p, false);
|
||||
let r = m.try_rpc(
|
||||
"tools/call",
|
||||
json!({"name": "books.chart", "arguments": {}}),
|
||||
@@ -1554,7 +1564,6 @@ fn old_book_answers_every_tool(dir: &Path, per_tool: usize) -> Vec<String> {
|
||||
failures.push(e.to_string());
|
||||
}
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&p.base);
|
||||
failures
|
||||
}
|
||||
|
||||
+28
-116
@@ -1,50 +1,20 @@
|
||||
//! Concurrency over HTTP: several agents at once against one store.
|
||||
//! Concurrency: several agents at once against one book's store.
|
||||
//!
|
||||
//! In `--http` mode every connection gets its own server over one shared,
|
||||
//! locked store (main.rs `serve_http`). Oracle (server.rs INVARIANTS 2 and
|
||||
//! The books server gives every session its own service over the book's
|
||||
//! one shared, locked store (ledger-mcp local.rs INVARIANT 4). Oracle (server.rs INVARIANTS 2 and
|
||||
//! 5, under contention): clients posting at the same time leave books
|
||||
//! that verify, a trial balance that balances, every posted entry with
|
||||
//! its receipt (threshold 0.00), and each draft posted exactly once even
|
||||
//! when two clients race to post it; a restart replays the same books.
|
||||
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::{CallToolRequestParams, ClientConfig};
|
||||
use rmcp::service::{RoleClient, RunningService};
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use serde_json::{Value, json};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
use common::{Client, call};
|
||||
use serde_json::json;
|
||||
|
||||
type Client = RunningService<RoleClient, ClientConfig>;
|
||||
mod common;
|
||||
|
||||
const CLIENTS: usize = 6;
|
||||
const ENTRIES: usize = 12;
|
||||
|
||||
struct Server(Child);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
const T: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut out = String::new();
|
||||
@@ -63,38 +33,6 @@ fn b64(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
async fn connect(url: &str) -> Client {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let transport = StreamableHttpClientTransport::from_uri(url.to_string());
|
||||
match ClientConfig::default().serve(transport).await {
|
||||
Ok(c) => return c,
|
||||
Err(e) if Instant::now() > deadline => panic!("connect: {e}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a tool: Ok(structured result) or Err(the tool's error text).
|
||||
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
|
||||
let args = args.as_object().cloned().unwrap_or_default();
|
||||
let r = c
|
||||
.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args))
|
||||
.await
|
||||
.map_err(|e| format!("protocol: {e}"))?;
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_text().map(|t| t.text.clone()))
|
||||
.unwrap_or_default();
|
||||
if r.is_error == Some(true) {
|
||||
Err(text)
|
||||
} else {
|
||||
Ok(r.structured_content
|
||||
.unwrap_or_else(|| serde_json::from_str(&text).unwrap()))
|
||||
}
|
||||
}
|
||||
|
||||
async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
|
||||
let e = call(
|
||||
c,
|
||||
@@ -130,33 +68,24 @@ async fn draft(c: &Client, tag: &str, cents: u64, with_receipt: bool) -> u64 {
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn agents_posting_at_once_leave_whole_books() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-mcp-http-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
// Declared before the server, so dropped after it: pass or panic.
|
||||
let _cleanup = TestDir(dir.clone());
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
|
||||
let data = dir.join("books-data");
|
||||
let port = free_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
let server = Server(
|
||||
Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(&data)
|
||||
.arg("--books-toml")
|
||||
.arg(&toml)
|
||||
.arg("--http")
|
||||
.arg(&addr)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let url = format!("http://{addr}/mcp");
|
||||
let setup = connect(&url).await;
|
||||
// Made on a thread of its own: the settings go in through a session
|
||||
// with a runtime of its own (common.rs), never inside the test's.
|
||||
let books = std::thread::spawn(|| {
|
||||
common::Books::with(
|
||||
"http-concurrency",
|
||||
"USD",
|
||||
Some("[books]\nreceipt_threshold = \"0.00\"\n"),
|
||||
)
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
let (run, token) = (books.home.run(), books.home.token(common::BOOK));
|
||||
let connect = |run: std::path::PathBuf, token: String| async move {
|
||||
common::connect_at(&run, common::BOOK, Some(token))
|
||||
.await
|
||||
.unwrap()
|
||||
};
|
||||
let setup = connect(run.clone(), token.clone()).await;
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
@@ -178,10 +107,10 @@ async fn agents_posting_at_once_leave_whole_books() {
|
||||
|
||||
let mut tasks = Vec::new();
|
||||
for n in 0..CLIENTS {
|
||||
let url = url.clone();
|
||||
let (run, token) = (run.clone(), token.clone());
|
||||
let contested = contested.clone();
|
||||
tasks.push(tokio::spawn(async move {
|
||||
let c = connect(&url).await;
|
||||
let c = connect(run, token).await;
|
||||
let (mut posted, mut refused, mut won) = (0usize, 0usize, 0usize);
|
||||
for (k, &race) in contested.iter().enumerate() {
|
||||
// A receipted entry posts; a bare one is refused.
|
||||
@@ -256,26 +185,10 @@ async fn agents_posting_at_once_leave_whole_books() {
|
||||
}
|
||||
let head = v["head"].clone();
|
||||
let _ = setup.cancel().await;
|
||||
drop(server);
|
||||
|
||||
// A restart replays the same books.
|
||||
let port = free_port();
|
||||
let addr = format!("127.0.0.1:{port}");
|
||||
let _server = Server(
|
||||
Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(&data)
|
||||
.arg("--books-toml")
|
||||
.arg(&toml)
|
||||
.arg("--http")
|
||||
.arg(&addr)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap(),
|
||||
);
|
||||
let c = connect(&format!("http://{addr}/mcp")).await;
|
||||
books.restart();
|
||||
let c = connect(run.clone(), token.clone()).await;
|
||||
let v2 = call(&c, "books.verify", json!({})).await.unwrap();
|
||||
assert_eq!(v2["status"], "ok");
|
||||
assert_eq!(
|
||||
@@ -283,5 +196,4 @@ async fn agents_posting_at_once_leave_whole_books() {
|
||||
"the restarted server sees the same journal"
|
||||
);
|
||||
let _ = c.cancel().await;
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -4,12 +4,8 @@
|
||||
//! finishes, every writer lock is released").
|
||||
#![cfg(unix)]
|
||||
|
||||
use common::{Client, Home, Server, call, connect, connect_with, mcp_http, mcp_http_for};
|
||||
use ledger_mcp::admin::{self, Request, Response};
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::{CallToolRequestParams, ClientConfig};
|
||||
use rmcp::service::{RoleClient, RunningService};
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
@@ -17,190 +13,7 @@ use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Client = RunningService<RoleClient, ClientConfig>;
|
||||
|
||||
/// A home with a data root made by `wanderbooks setup`.
|
||||
struct Home {
|
||||
dir: tempfile::TempDir,
|
||||
/// A token for each book, made on first use (all scopes).
|
||||
tokens: std::sync::Mutex<std::collections::HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl Home {
|
||||
fn new(tag: &str) -> Self {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix(&format!("wb-srv-{tag}-"))
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.args(["setup", "--home"])
|
||||
.arg(dir.path())
|
||||
.env_remove("XDG_DATA_HOME")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
Self {
|
||||
dir,
|
||||
tokens: Default::default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The token tests use for `book`, made through the admin socket the
|
||||
/// first time.
|
||||
fn token(&self, book: &str) -> String {
|
||||
let mut tokens = self.tokens.lock().unwrap();
|
||||
if let Some(t) = tokens.get(book) {
|
||||
return t.clone();
|
||||
}
|
||||
let text = match self.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: "test".into(),
|
||||
scopes: ["read", "write", "post", "admin"]
|
||||
.map(String::from)
|
||||
.to_vec(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { text, .. } => text.0,
|
||||
other => panic!("{other:?}"),
|
||||
};
|
||||
tokens.insert(book.to_string(), text.clone());
|
||||
text
|
||||
}
|
||||
|
||||
fn root(&self) -> PathBuf {
|
||||
self.dir.path().join(".local/share/wanderbooks")
|
||||
}
|
||||
|
||||
fn run(&self) -> PathBuf {
|
||||
self.root().join("run")
|
||||
}
|
||||
|
||||
fn admin(&self, request: Request) -> Response {
|
||||
admin::call(&self.run().join("admin.sock"), request).unwrap()
|
||||
}
|
||||
|
||||
/// The text of every answer must not name the root (local.rs
|
||||
/// INVARIANT 7).
|
||||
fn assert_hidden(&self, text: &str) {
|
||||
let root = self.root().display().to_string();
|
||||
assert!(!text.contains(&root), "names the data root: {text}");
|
||||
assert!(
|
||||
!text.contains(&self.dir.path().display().to_string()),
|
||||
"names the home: {text}"
|
||||
);
|
||||
}
|
||||
|
||||
fn create(&self, id: &str, currency: &str) {
|
||||
match self.admin(Request::Create {
|
||||
id: id.into(),
|
||||
currency: currency.into(),
|
||||
}) {
|
||||
Response::Created { id: made, problem } => {
|
||||
assert_eq!(made, id);
|
||||
assert_eq!(problem, None);
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A running `wanderbooks server`, ended (kill) when dropped.
|
||||
struct Server {
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
/// Start one and wait for its ready line.
|
||||
fn start(home: &Home) -> Self {
|
||||
// Its environment is the test home's alone: the default import
|
||||
// folder and vault key are there, never the real ones.
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_wanderbooks"))
|
||||
.arg("server")
|
||||
.arg("--root")
|
||||
.arg(home.root())
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", home.dir.path())
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut line = String::new();
|
||||
BufReader::new(child.stdout.take().unwrap())
|
||||
.read_line(&mut line)
|
||||
.unwrap();
|
||||
assert_eq!(line, "ready\n", "the server didn't say ready");
|
||||
Self { child }
|
||||
}
|
||||
|
||||
/// Close it as the host does (stdin ends) and wait for it to exit.
|
||||
fn close(mut self) -> std::process::ExitStatus {
|
||||
drop(self.child.stdin.take());
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
if let Some(status) = self.child.try_wait().unwrap() {
|
||||
return status;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "the server didn't close");
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// An HTTP client for the server's MCP socket.
|
||||
fn mcp_http(home: &Home) -> reqwest::Client {
|
||||
mcp_http_builder(home).build().unwrap()
|
||||
}
|
||||
|
||||
fn mcp_http_builder(home: &Home) -> reqwest::ClientBuilder {
|
||||
// The short path is good in this process only, while it lives: kept.
|
||||
let short = ledger_store::root::SocketPath::new(&home.run(), "mcp.sock").unwrap();
|
||||
let path = short.path().to_path_buf();
|
||||
std::mem::forget(short);
|
||||
reqwest::Client::builder().unix_socket(path)
|
||||
}
|
||||
|
||||
/// An HTTP client for the server's MCP socket that sends `book`'s token
|
||||
/// on every request.
|
||||
fn mcp_http_for(home: &Home, book: &str) -> reqwest::Client {
|
||||
let mut headers = reqwest::header::HeaderMap::new();
|
||||
headers.insert(
|
||||
reqwest::header::AUTHORIZATION,
|
||||
format!("Bearer {}", home.token(book)).parse().unwrap(),
|
||||
);
|
||||
mcp_http_builder(home)
|
||||
.default_headers(headers)
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn connect(home: &Home, book: &str) -> Result<Client, String> {
|
||||
let token = home.token(book);
|
||||
connect_with(home, book, Some(token)).await
|
||||
}
|
||||
|
||||
/// [`connect`], with this token (or none).
|
||||
async fn connect_with(home: &Home, book: &str, token: Option<String>) -> Result<Client, String> {
|
||||
let http = mcp_http(home);
|
||||
let mut config =
|
||||
StreamableHttpClientTransportConfig::with_uri(format!("http://localhost/b/{book}/mcp"));
|
||||
if let Some(token) = token {
|
||||
config = config.auth_header(token);
|
||||
}
|
||||
let transport = StreamableHttpClientTransport::with_client(http, config);
|
||||
ClientConfig::default()
|
||||
.serve(transport)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
mod common;
|
||||
|
||||
/// Name cash 1000 the books' cash account (adding it if the chart lacks
|
||||
/// it): the 1099 worksheet counts payments out of the books' cash
|
||||
@@ -221,31 +34,6 @@ async fn name_cash(c: &Client) {
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// Call a tool: Ok(structured result) or Err(the error text).
|
||||
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
|
||||
let args = args.as_object().cloned().unwrap_or_default();
|
||||
// A client whose server went away may wait forever: a call that
|
||||
// takes this long counts as refused.
|
||||
let r = tokio::time::timeout(
|
||||
Duration::from_secs(20),
|
||||
c.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args)),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "no answer".to_string())?
|
||||
.map_err(|e| format!("protocol: {e}"))?;
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_text().map(|t| t.text.clone()))
|
||||
.unwrap_or_default();
|
||||
if r.is_error == Some(true) {
|
||||
Err(text)
|
||||
} else {
|
||||
Ok(r.structured_content
|
||||
.unwrap_or_else(|| serde_json::from_str(&text).unwrap_or(Value::String(text))))
|
||||
}
|
||||
}
|
||||
|
||||
async fn chart(c: &Client) {
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
@@ -1166,25 +954,6 @@ fn adoption_checks_the_vault_key_and_the_settings_file() {
|
||||
// P2 slice 4: tokens, scopes and the access log (local.rs INVARIANT 8,
|
||||
// ledger-mcp access.rs, scopes.rs; ledger-store auth.rs, access.rs).
|
||||
|
||||
impl Home {
|
||||
/// A token for `book` named `name` with `scopes`, made (or remade).
|
||||
fn token_with(&self, book: &str, name: &str, scopes: &[&str]) -> String {
|
||||
match self.admin(Request::TokenCreate {
|
||||
book: book.into(),
|
||||
name: name.into(),
|
||||
scopes: scopes.iter().map(|s| s.to_string()).collect(),
|
||||
replace: true,
|
||||
}) {
|
||||
Response::TokenCreated { text, .. } => text.0,
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
fn book_dir(&self, book: &str) -> PathBuf {
|
||||
self.root().join("books").join(book)
|
||||
}
|
||||
}
|
||||
|
||||
/// A raw initialize on `book`'s route with `bearer` (if any): its status,
|
||||
/// its refusal header, its session id and its body.
|
||||
async fn raw_initialize(
|
||||
@@ -2523,7 +2292,10 @@ async fn a_w9_and_a_file_holding_a_tin_go_by_upload_only_where_they_belong() {
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got["file"]["file_name"], json!("payee-w9.pdf"));
|
||||
assert_eq!(fetch_export(&home, &token, &got["file"]).await, w9);
|
||||
assert_eq!(
|
||||
common::fetch_export(&home, "acme", &token, &got["file"]).await,
|
||||
w9
|
||||
);
|
||||
// The W-9's upload is gone; the refused file's waits to lapse.
|
||||
let left: Vec<String> = std::fs::read_dir(home.book_dir("acme").join("uploads"))
|
||||
.unwrap()
|
||||
@@ -2609,32 +2381,6 @@ async fn an_mcp_request_is_at_most_a_mebibyte() {
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Fetch the export a tool handed over in `file` (its `download`
|
||||
/// command's id) with `token`: its bytes, checked against its sha256.
|
||||
async fn fetch_export(home: &Home, token: &str, file: &Value) -> Vec<u8> {
|
||||
let command = file["download"]
|
||||
.as_str()
|
||||
.unwrap_or_else(|| panic!("{file}"));
|
||||
let id = command
|
||||
.split(' ')
|
||||
.find(|w| w.starts_with("ex_"))
|
||||
.unwrap_or_else(|| panic!("{command}"));
|
||||
let r = mcp_http(home)
|
||||
.get(format!("http://localhost/b/acme/x/{id}"))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers());
|
||||
let bytes = r.bytes().await.unwrap().to_vec();
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes))
|
||||
);
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()));
|
||||
bytes
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
|
||||
async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
let home = Home::new("exports");
|
||||
@@ -2689,9 +2435,9 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
);
|
||||
assert!(file["expires_at"].as_str().unwrap().ends_with('Z'));
|
||||
// Fetched whole, as often as needed while it's kept.
|
||||
let zip = fetch_export(&home, &token, file).await;
|
||||
let zip = common::fetch_export(&home, "acme", &token, file).await;
|
||||
assert!(zip.starts_with(b"PK"), "a zip");
|
||||
assert_eq!(fetch_export(&home, &token, file).await, zip);
|
||||
assert_eq!(common::fetch_export(&home, "acme", &token, file).await, zip);
|
||||
let id = command.split(' ').find(|w| w.starts_with("ex_")).unwrap();
|
||||
let get = |bearer: Option<String>, what: String| {
|
||||
let http = mcp_http(&home);
|
||||
@@ -2730,7 +2476,7 @@ async fn a_file_the_books_make_is_fetched_by_its_own_token() {
|
||||
let w = call(&ca, "books.vendor_1099", json!({"year": 2026, "csv": true}))
|
||||
.await
|
||||
.unwrap();
|
||||
let csv = fetch_export(&home, &admin, &w["csv"]).await;
|
||||
let csv = common::fetch_export(&home, "acme", &admin, &w["csv"]).await;
|
||||
assert!(csv.starts_with(b"vendor_id,name,"));
|
||||
drop(ca);
|
||||
// Each fetch is in the access log as an export, under its id.
|
||||
|
||||
@@ -1,73 +1,30 @@
|
||||
//! End-to-end smoke test: drive the real `ledger-mcp` binary over stdio
|
||||
//! with raw JSON-RPC, exercising draft → preview → receipt policy → post.
|
||||
//! End-to-end journeys: drive a real `wanderbooks server` with raw
|
||||
//! JSON-RPC, as an agent reaches it (common.rs), exercising draft →
|
||||
//! preview → receipt policy → post, and the rest of an agent's day.
|
||||
|
||||
use common::{Books, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
mod common;
|
||||
|
||||
/// One agent's session with the test's books, over the books server's
|
||||
/// socket (common.rs).
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
/// Closing stdin ends the server; wait for it to exit, so the next server
|
||||
/// on the same books never races it for the writer lock.
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||||
Self::spawn_with(data_dir, &[])
|
||||
}
|
||||
|
||||
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
|
||||
Self::spawn_env(data_dir, extra, &[])
|
||||
}
|
||||
|
||||
fn spawn_env(
|
||||
data_dir: &std::path::Path,
|
||||
extra: &[&std::ffi::OsStr],
|
||||
env: &[(&str, &std::path::Path)],
|
||||
) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.args(extra)
|
||||
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
/// A new session with `books` (initialized by the test itself).
|
||||
fn spawn(books: &Books) -> Self {
|
||||
Self {
|
||||
stdin,
|
||||
stdout,
|
||||
wire: books.wire(),
|
||||
next_id: 1,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout
|
||||
.read_line(&mut line)
|
||||
.expect("read response line");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse response");
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
let resp = self.request_raw(method, params);
|
||||
assert!(
|
||||
resp.get("error").is_none(),
|
||||
"JSON-RPC error for {method}: {}",
|
||||
@@ -80,21 +37,18 @@ impl Mcp {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout
|
||||
.read_line(&mut line)
|
||||
.expect("read response line");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse response");
|
||||
self.wire.send(&msg);
|
||||
let resp = self
|
||||
.wire
|
||||
.recv(common::TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str) {
|
||||
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire
|
||||
.send(&json!({"jsonrpc": "2.0", "method": method, "params": {}}));
|
||||
}
|
||||
|
||||
/// Call a tool; panic on tool error. Returns structuredContent.
|
||||
@@ -124,41 +78,19 @@ impl Mcp {
|
||||
);
|
||||
result["content"][0]["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by
|
||||
/// its download, matching the size and SHA-256 it states.
|
||||
fn handed(&mut self, file: &Value) -> Vec<u8> {
|
||||
assert!(file["expires_at"].is_string(), "{file}");
|
||||
self.wire.fetch(file)
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_end_to_end_over_stdio() {
|
||||
let dir = test_dir("main");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_end_to_end() {
|
||||
let books = Books::new("main");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
|
||||
// Initialize: the server identifies itself and delivers the contract.
|
||||
let init = mcp.request(
|
||||
@@ -178,7 +110,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 116, "expected 116 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 118, "expected 118 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
@@ -330,9 +262,9 @@ fn mcp_end_to_end_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_assets_end_to_end_over_stdio() {
|
||||
let dir = test_dir("assets");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_assets_end_to_end() {
|
||||
let books = Books::new("assets");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -501,8 +433,8 @@ fn mcp_assets_end_to_end_over_stdio() {
|
||||
|
||||
#[test]
|
||||
fn mcp_receipt_policy_rejects_split_entries() {
|
||||
let dir = test_dir("split");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("split");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -592,9 +524,9 @@ fn mcp_receipt_policy_rejects_split_entries() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_receipt_corrections_over_stdio() {
|
||||
let dir = test_dir("receipt-corrections");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_receipt_corrections() {
|
||||
let books = Books::new("receipt-corrections");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -729,9 +661,9 @@ fn mcp_receipt_corrections_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_resources_over_stdio() {
|
||||
let dir = test_dir("resources");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_resources() {
|
||||
let books = Books::new("resources");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -805,16 +737,9 @@ fn init(mcp: &mut Mcp) -> Value {
|
||||
/// books.get_receipt (S7 of the P0 review, 2026-10-16).
|
||||
#[test]
|
||||
fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
let dir = test_dir("tin-files");
|
||||
let books = dir.join("books");
|
||||
let config = dir.join("config");
|
||||
std::fs::create_dir_all(&config).unwrap();
|
||||
let books = Books::new("tin-files");
|
||||
let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846";
|
||||
let mut mcp = Mcp::spawn_env(
|
||||
&books.join("books-data"),
|
||||
&[],
|
||||
&[("XDG_CONFIG_HOME", &config)],
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
mcp.call(
|
||||
"books.add_account",
|
||||
@@ -835,9 +760,9 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
"tin": "291-12-2846", "tin_type": "ssn",
|
||||
"w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}),
|
||||
);
|
||||
// Each comes back inline over stdio.
|
||||
// Each comes back by its download.
|
||||
let got = mcp.call("books.get_w9", json!({"party": "Acme"}));
|
||||
let w9_back = handed(&got["file"]);
|
||||
let w9_back = mcp.handed(&got["file"]);
|
||||
assert_eq!(w9_back, w9, "the W-9 file, as stored");
|
||||
let ws = mcp.call(
|
||||
"books.vendor_1099",
|
||||
@@ -849,10 +774,10 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
json!({"cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert_eq!(pkg["tins_included"], true);
|
||||
let handed_over = [w9_back, handed(&ws["csv"]), handed(&pkg["file"])];
|
||||
let handed_over = [w9_back, mcp.handed(&ws["csv"]), mcp.handed(&pkg["file"])];
|
||||
// Given back, each is refused by its bytes, by every way a file is
|
||||
// filed.
|
||||
let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let journal = books.journal();
|
||||
for (n, bytes) in handed_over.iter().enumerate() {
|
||||
for (tool, args) in [
|
||||
(
|
||||
@@ -876,17 +801,13 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
);
|
||||
}
|
||||
}
|
||||
assert_eq!(
|
||||
std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"a refusal stored something"
|
||||
);
|
||||
assert_eq!(books.journal(), journal, "a refusal stored something");
|
||||
// Without TINs, the 1099 worksheet is an ordinary file: it may be
|
||||
// filed like any other.
|
||||
let plain = mcp.call("books.vendor_1099", json!({"year": 2026, "csv": true}));
|
||||
assert!(plain["warning"].is_null(), "{plain}");
|
||||
assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv");
|
||||
let bytes = handed(&plain["csv"]);
|
||||
let bytes = mcp.handed(&plain["csv"]);
|
||||
mcp.call(
|
||||
"books.file_document",
|
||||
json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv",
|
||||
@@ -899,20 +820,21 @@ fn tin_bearing_files_are_refused_when_given_back_to_be_filed() {
|
||||
/// uncut, so no account the rule names is out of the agent's reach.
|
||||
#[test]
|
||||
fn every_receipt_account_left_out_of_the_connect_text_is_in_books_settings() {
|
||||
let dir = test_dir("receipt-accounts");
|
||||
let long = "6000-A-CODE-LONGER-THAN-ANY-RULE-SHOWS-IT";
|
||||
let required: Vec<String> = std::iter::once(long.to_string())
|
||||
.chain((1..20).map(|i| format!("{}", 6000 + i)))
|
||||
.collect();
|
||||
let exempt: Vec<String> = (0..12).map(|i| format!("{}", 1100 + i)).collect();
|
||||
std::fs::write(
|
||||
dir.join("config.json"),
|
||||
json!({"receipt_policy": {"require_receipts_above": "0.00",
|
||||
"receipt_required_accounts": required, "receipt_exempt_accounts": exempt}})
|
||||
.to_string(),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("receipt-accounts");
|
||||
books.settle_with(
|
||||
"",
|
||||
Some(
|
||||
&json!({"receipt_policy": {"require_receipts_above": "0.00",
|
||||
"receipt_required_accounts": required, "receipt_exempt_accounts": exempt}})
|
||||
.to_string(),
|
||||
),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -973,8 +895,8 @@ fn digest_resource_bare_reads() {
|
||||
/// One run of `digest_resource_bare_reads`: false if the server's month
|
||||
/// turned during it (nothing is checked then).
|
||||
fn digest_bare_reads_once(attempt: u32) -> bool {
|
||||
let dir = test_dir(&format!("digest-bare-{attempt}"));
|
||||
let mut mcp = Mcp::spawn(&dir.join("books-data"));
|
||||
let books = Books::new(&format!("digest-bare-{attempt}"));
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -1042,9 +964,9 @@ fn digest_bare_reads_once(attempt: u32) -> bool {
|
||||
/// The handbook is one tool and one resource per section, with the
|
||||
/// receipt rule in force rendered in; the connect text points to it.
|
||||
#[test]
|
||||
fn handbook_tool_and_resources_over_stdio() {
|
||||
let dir = test_dir("handbook");
|
||||
let mut mcp = Mcp::spawn(&dir.join("books-data"));
|
||||
fn handbook_tool_and_resources() {
|
||||
let books = Books::new("handbook");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -1159,25 +1081,10 @@ fn base64_like(bytes: &[u8]) -> String {
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): over stdio
|
||||
/// they come inline, with no download command, and match the size and
|
||||
/// SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_import_export_over_stdio() {
|
||||
let dir = test_dir("import_export");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_import_export() {
|
||||
let books = Books::new("import_export");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -1221,11 +1128,11 @@ fn mcp_import_export_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
fn mcp_statement_pdfs_and_cpa_package() {
|
||||
use base64::Engine;
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let dir = test_dir("statement-pdfs");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("statement-pdfs");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -1317,7 +1224,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
assert_eq!(pkg["file"]["file_name"], "cpa-package-2026-10-31.zip");
|
||||
assert_eq!(pkg["file"]["mime_type"], "application/zip");
|
||||
assert_eq!(pkg["tins_included"], false);
|
||||
let zip_bytes = handed(&pkg["file"]);
|
||||
let zip_bytes = mcp.handed(&pkg["file"]);
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap();
|
||||
let mut names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
@@ -1369,26 +1276,22 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let light = zip::ZipArchive::new(std::io::Cursor::new(handed(&light["file"]))).unwrap();
|
||||
let light = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&light["file"]))).unwrap();
|
||||
assert_eq!(light.len(), rest.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_period_statements_over_stdio() {
|
||||
let dir = test_dir("period-statements");
|
||||
fn mcp_period_statements() {
|
||||
// A rejected receipt policy would show up as a failed post below: the
|
||||
// toml lowers the threshold from 25.00 to 5.00.
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&data,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
// settings lower the threshold from 25.00 to 5.00.
|
||||
let books = Books::with(
|
||||
"period-statements",
|
||||
"USD",
|
||||
Some(
|
||||
"[company]\nname = \"Test Co\"\nfiscal_year_start = \"02-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"Owner\"\nshare = \"100%\"\ncapital_account = \"3000\"\n[books]\nreceipt_threshold = \"5.00\"\n",
|
||||
),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -1608,7 +1511,7 @@ fn mcp_period_statements_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_partnership_from_template_over_stdio() {
|
||||
fn mcp_partnership_from_template() {
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
@@ -1621,31 +1524,42 @@ fn mcp_partnership_from_template_over_stdio() {
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
// 1. Ask for a template (any server will do).
|
||||
let dir = test_dir("template");
|
||||
let mut scratch = Mcp::spawn(&dir.join("scratch"));
|
||||
let scratch_books = Books::new("template-scratch");
|
||||
let mut scratch = Mcp::spawn(&scratch_books);
|
||||
init(&mut scratch);
|
||||
let t = scratch.call(
|
||||
"books.chart_template",
|
||||
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
|
||||
);
|
||||
let settings = t["settings_toml"].as_str().unwrap();
|
||||
assert!(settings.contains("distribution_account = \"3101\""));
|
||||
let settings = t["settings"].clone();
|
||||
let members = settings
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|c| c["key"] == "company.members")
|
||||
.unwrap_or_else(|| panic!("{settings}"));
|
||||
assert_eq!(
|
||||
members["value"][1]["distribution_account"],
|
||||
json!("3101"),
|
||||
"{settings}"
|
||||
);
|
||||
let err = scratch.call_err("books.chart_template", json!({"entity_type": "LLC"}));
|
||||
assert!(err.contains("entity_type"), "{err}");
|
||||
|
||||
// 2. Its settings become the company config; its chart, the chart.
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
// 2. Its chart becomes the chart; its settings, the company's
|
||||
// (books.set_setting, the one way settings change).
|
||||
let books = Books::with(
|
||||
"template",
|
||||
"USD",
|
||||
Some("[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": settings, "decided_by": "Ann"}),
|
||||
);
|
||||
let post = |mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str| {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
@@ -1730,30 +1644,29 @@ fn mcp_partnership_from_template_over_stdio() {
|
||||
/// 0.01 each.
|
||||
#[test]
|
||||
fn close_period_shows_each_members_running_split() {
|
||||
let dir = test_dir("members-split");
|
||||
let mut scratch = Mcp::spawn(&dir.join("scratch"));
|
||||
let scratch_books = Books::new("members-split-scratch");
|
||||
let mut scratch = Mcp::spawn(&scratch_books);
|
||||
init(&mut scratch);
|
||||
let t = scratch.call(
|
||||
"books.chart_template",
|
||||
json!({"entity_type": "multi-member LLC", "owners": ["Ann", "Bo"]}),
|
||||
);
|
||||
let settings = t["settings_toml"].as_str().unwrap().to_string();
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!("{settings}\n[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
)
|
||||
.unwrap();
|
||||
let spawn = || {
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
let books = Books::with(
|
||||
"members-split",
|
||||
"USD",
|
||||
Some("[books]\nreceipt_threshold = \"10000\"\n"),
|
||||
);
|
||||
let spawn = |books: &Books| {
|
||||
let mut mcp = Mcp::spawn(books);
|
||||
init(&mut mcp);
|
||||
mcp
|
||||
};
|
||||
let mut mcp = spawn();
|
||||
let mut mcp = spawn(&books);
|
||||
mcp.call("books.import_chart_csv", json!({"csv_text": t["csv"]}));
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": t["settings"], "decided_by": "Ann"}),
|
||||
);
|
||||
let sale = |mcp: &mut Mcp, date: &str| {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
@@ -1825,7 +1738,8 @@ fn close_period_shows_each_members_running_split() {
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
let mut mcp = spawn();
|
||||
books.restart();
|
||||
let mut mcp = spawn(&books);
|
||||
assert_eq!(mcp.call("books.verify", json!({}))["status"], "ok");
|
||||
let periods = mcp.call("books.list_periods", json!({}));
|
||||
let rules: Vec<&Value> = periods["periods"]
|
||||
@@ -1855,35 +1769,36 @@ fn close_period_shows_each_members_running_split() {
|
||||
/// old-books fixture x_member_closes (two members at 50%, A with a
|
||||
/// distributions account; three closes untagged, three
|
||||
/// "largest_remainder", then "cumulative" broken by a close to retained
|
||||
/// earnings), opened by the server. list_periods names each close's rule;
|
||||
/// December's first close under the rule since P3 counts from close 1
|
||||
/// and carries the true-up, the same in the preview as once confirmed.
|
||||
/// earnings), left in a data root in P2's format. list_periods names each
|
||||
/// close's rule; December's first close under the rule since P3 counts
|
||||
/// from close 1 and carries the true-up, refused (naming its feature)
|
||||
/// until the owner upgrades the books, then the same as previewed.
|
||||
/// By hand (the fixture's writer): the members closed 0.12, A 0.07 and B
|
||||
/// 0.05 of it (exact 0.06 each). December earns 0.02: the running 0.14
|
||||
/// splits 0.07 each, less 0.07 / 0.05 given, so Ann gets 0.00 and Bo
|
||||
/// 0.02, both at their exact 0.07.
|
||||
#[test]
|
||||
fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
let dir = test_dir("members-old");
|
||||
// Books P1 kept, which P2 moved to its journal format (format 2, none
|
||||
// of the features since), left in a data root, with their members set
|
||||
// as an agent sets them: books waiting for their format upgrade.
|
||||
let fixture = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books/x_member_closes/journal.jsonl");
|
||||
let books = dir.join("books-data");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
std::fs::copy(&fixture, books.join("journal.jsonl")).unwrap();
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
.join("../ledger-store/tests/fixtures/old-books/x_member_closes");
|
||||
let books = Books::placed("members-old", &fixture);
|
||||
books.stop();
|
||||
ledger_store::Store::open(&books.dir())
|
||||
.unwrap()
|
||||
.declare_journal_format(2, &[])
|
||||
.unwrap();
|
||||
books.start();
|
||||
books.settle(
|
||||
"[company]\ntax_form = \"1065\"\n\n\
|
||||
[[company.members]]\nname = \"Ann\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
distribution_account = \"3100\"\n\n\
|
||||
[[company.members]]\nname = \"Bo\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\n\
|
||||
[books]\nreceipt_threshold = \"10000\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&books,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
let rules = |mcp: &mut Mcp| -> Vec<Value> {
|
||||
mcp.call("books.list_periods", json!({}))["periods"]
|
||||
@@ -1956,18 +1871,17 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
"{refused}"
|
||||
);
|
||||
// The owner's go-ahead, as the app gives it (ledger-gui owner.rs, after
|
||||
// the passphrase and a verified backup): the books move to this
|
||||
// version's format.
|
||||
// the passphrase and a verified backup): the server stopped, the books
|
||||
// moved to this version's format, and served again.
|
||||
drop(mcp);
|
||||
ledger_store::Store::open(&books)
|
||||
books.stop();
|
||||
ledger_store::Store::open(&books.dir())
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
.expect("old books wait for the upgrade");
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&books,
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
);
|
||||
books.start();
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
let closed = mcp.call(
|
||||
"books.close_period",
|
||||
@@ -1981,8 +1895,8 @@ fn old_books_name_each_closes_rule_and_true_up_at_the_next_close() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_schedules_and_recurring_over_stdio() {
|
||||
let dir = test_dir("schedules");
|
||||
fn mcp_schedules_and_recurring() {
|
||||
let books = Books::new("schedules");
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
@@ -1994,7 +1908,7 @@ fn mcp_schedules_and_recurring_over_stdio() {
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
@@ -2088,7 +2002,8 @@ fn mcp_schedules_and_recurring_over_stdio() {
|
||||
drop(mcp);
|
||||
|
||||
// Restart: everything replays from the journal.
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
books.restart();
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
let list = mcp.call("books.list_schedules", json!({}));
|
||||
assert_eq!(list["schedules"][0]["remaining"], "333.34");
|
||||
@@ -2102,9 +2017,9 @@ fn mcp_schedules_and_recurring_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_payout_gross_up_over_stdio() {
|
||||
let dir = test_dir("payout");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_payout_gross_up() {
|
||||
let books = Books::new("payout");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2206,8 +2121,8 @@ fn mcp_payout_gross_up_over_stdio() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_vendor_1099_over_stdio() {
|
||||
let dir = test_dir("form1099");
|
||||
fn mcp_vendor_1099() {
|
||||
let books = Books::new("form1099");
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
@@ -2219,7 +2134,7 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1000", "Bank", "asset"),
|
||||
@@ -2298,7 +2213,8 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
);
|
||||
drop(mcp);
|
||||
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
books.restart();
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
let r = mcp.call("books.vendor_1099", json!({"year": 2026}));
|
||||
assert_eq!(r["vendors"][0]["paid"], "2500.00");
|
||||
@@ -2327,13 +2243,13 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
assert_eq!(vendors["vendors"][0]["tin_on_file"], true);
|
||||
// The server made the books' ops/ as they opened: a viewer open from
|
||||
// before the first books.show is seen by it.
|
||||
let viewer = ledger_store::ops::Presence::hold(&dir).expect("ops/ made at start");
|
||||
let viewer = ledger_store::ops::Presence::hold(&books.dir()).expect("ops/ made at start");
|
||||
// Show the entry in the viewer: a request in the books' ops/, never
|
||||
// journaled.
|
||||
let shown = mcp.call("books.show", json!({"entry_id": id}));
|
||||
assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}")));
|
||||
assert_eq!(shown["viewer_open"], true);
|
||||
let cmd = ledger_store::ops::read(&dir).expect("request written");
|
||||
let cmd = ledger_store::ops::read(&books.dir()).expect("request written");
|
||||
assert_eq!(
|
||||
cmd.target,
|
||||
ledger_store::ops::ViewerTarget::Entry { entry_id: id }
|
||||
@@ -2356,13 +2272,12 @@ fn mcp_vendor_1099_over_stdio() {
|
||||
|
||||
#[test]
|
||||
fn mcp_receipts_always_required_when_threshold_is_zero() {
|
||||
let dir = test_dir("always-receipts");
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(&toml, "[books]\nreceipt_threshold = \"0.00\"\n").unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
let books = Books::with(
|
||||
"always-receipts",
|
||||
"USD",
|
||||
Some("[books]\nreceipt_threshold = \"0.00\"\n"),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
let init = mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2405,32 +2320,6 @@ fn mcp_receipts_always_required_when_threshold_is_zero() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A client reconnecting starts a new server while the old one still has
|
||||
/// the books: the new one waits for the lock instead of dying.
|
||||
#[test]
|
||||
fn a_restarted_server_waits_for_the_old_one_to_let_go() {
|
||||
let dir = test_dir("restart-race");
|
||||
let init = |mcp: &mut Mcp| {
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "smoke-test", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
mcp.notify("notifications/initialized");
|
||||
};
|
||||
let mut old = Mcp::spawn(&dir);
|
||||
init(&mut old);
|
||||
let mut new = Mcp::spawn(&dir);
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
drop(old);
|
||||
init(&mut new);
|
||||
let chart = new.call("books.chart", json!({}));
|
||||
assert!(chart.is_object());
|
||||
}
|
||||
|
||||
/// `books.cash_flow` with no accounts passed reads the roles from
|
||||
/// `[books]` in wanderbooks.toml (rules.md B-14): the expense the owners
|
||||
/// paid through 2100 is borrowed from them and spent on its date, each
|
||||
@@ -2439,17 +2328,14 @@ fn a_restarted_server_waits_for_the_old_one_to_let_go() {
|
||||
/// method's, and it foots.
|
||||
#[test]
|
||||
fn cash_flow_reads_its_account_roles_from_the_books_settings() {
|
||||
let dir = test_dir("cash-roles");
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
let books = Books::with(
|
||||
"cash-roles",
|
||||
"USD",
|
||||
Some(
|
||||
"[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\nfinancing_accounts = [\"2100\"]\ninvesting_accounts = [\"1400\"]\n",
|
||||
),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2547,8 +2433,8 @@ fn cash_flow_reads_its_account_roles_from_the_books_settings() {
|
||||
/// naming the setting and the fix, never a statement on a wrong partition.
|
||||
#[test]
|
||||
fn cash_flow_refuses_contradictory_roles() {
|
||||
let dir = test_dir("cash-refuse");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("cash-refuse");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2594,8 +2480,8 @@ fn cash_flow_refuses_contradictory_roles() {
|
||||
/// parent listed after its child).
|
||||
#[test]
|
||||
fn a_chart_file_with_a_bad_row_imports_nothing() {
|
||||
let dir = test_dir("chart-atomic");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("chart-atomic");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2636,8 +2522,8 @@ fn a_chart_file_with_a_bad_row_imports_nothing() {
|
||||
/// transaction that can't become a draft refuses the whole file.
|
||||
#[test]
|
||||
fn bank_import_reads_dates_rules_and_files_whole() {
|
||||
let dir = test_dir("bank-import");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
let books = Books::new("bank-import");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2699,7 +2585,7 @@ fn bank_import_reads_dates_rules_and_files_whole() {
|
||||
),
|
||||
);
|
||||
assert!(err.contains("closed"), "{err}");
|
||||
let journal = std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let journal = String::from_utf8(books.journal()).unwrap();
|
||||
assert!(
|
||||
!journal.contains("2026-06-01"),
|
||||
"the first row was journaled"
|
||||
@@ -2711,17 +2597,12 @@ fn bank_import_reads_dates_rules_and_files_whole() {
|
||||
/// and settings naming the old code are listed.
|
||||
#[test]
|
||||
fn renumbering_an_account_keeps_its_history() {
|
||||
let dir = test_dir("renumber");
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
"[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), toml.as_os_str()],
|
||||
let books = Books::with(
|
||||
"renumber",
|
||||
"USD",
|
||||
Some("[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n"),
|
||||
);
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2793,16 +2674,17 @@ fn renumbering_an_account_keeps_its_history() {
|
||||
.iter()
|
||||
.any(|a| a["code"] == "6100")
|
||||
);
|
||||
// The cash account is named in wanderbooks.toml: listed.
|
||||
// The cash account is named in the books' settings: listed.
|
||||
let out = mcp.call(
|
||||
"books.renumber_account",
|
||||
json!({"account": "1000", "code": "1010"}),
|
||||
);
|
||||
assert_eq!(out["config_mentions"], json!(["[books] cash_accounts"]));
|
||||
assert_eq!(out["config_mentions"], json!(["books.cash_accounts"]));
|
||||
drop(mcp);
|
||||
|
||||
// A restart replays the journal to the same chart.
|
||||
let mut mcp = Mcp::spawn(&dir.join("books-data"));
|
||||
books.restart();
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
mcp.request(
|
||||
"initialize",
|
||||
json!({
|
||||
@@ -2829,19 +2711,15 @@ fn renumbering_an_account_keeps_its_history() {
|
||||
);
|
||||
}
|
||||
|
||||
/// A server on books whose settings come from `toml`, initialized.
|
||||
fn served(dir: &std::path::Path, toml: &str) -> Mcp {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(&path, toml).unwrap();
|
||||
let mut mcp = Mcp::spawn_with(
|
||||
&dir.join("books-data"),
|
||||
&[std::ffi::OsStr::new("--books-toml"), path.as_os_str()],
|
||||
);
|
||||
/// A session with `books`, settings set from `toml` first, initialized.
|
||||
fn served(books: &Books, toml: &str) -> Mcp {
|
||||
books.settle(toml);
|
||||
let mut mcp = Mcp::spawn(books);
|
||||
init(&mut mcp);
|
||||
mcp
|
||||
}
|
||||
|
||||
/// Post one balanced two-line entry over stdio.
|
||||
/// Post one balanced two-line entry.
|
||||
fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64 {
|
||||
let e = mcp.call(
|
||||
"books.draft_entry",
|
||||
@@ -2873,9 +2751,9 @@ fn post_pair(mcp: &mut Mcp, date: &str, dr: &str, cr: &str, amount: &str) -> u64
|
||||
/// after, so midnight can't fail it).
|
||||
#[test]
|
||||
fn one_default_period_everywhere_defect_16() {
|
||||
let dir = test_dir("one-default");
|
||||
let books = Books::new("one-default");
|
||||
let mut mcp = served(
|
||||
&dir,
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\ntax_classification = \"partnership\"\n[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
);
|
||||
mcp.call(
|
||||
@@ -2941,7 +2819,7 @@ fn one_default_period_everywhere_defect_16() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap();
|
||||
let mut readme = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name("README.txt").unwrap(), &mut readme).unwrap();
|
||||
assert!(
|
||||
@@ -3003,9 +2881,9 @@ fn iif_docnums(iif: &str) -> Vec<u64> {
|
||||
/// one before its fiscal year included.
|
||||
#[test]
|
||||
fn the_default_period_never_shortens_the_iif_or_the_package() {
|
||||
let dir = test_dir("default-vs-iif");
|
||||
let books = Books::new("default-vs-iif");
|
||||
let mut mcp = served(
|
||||
&dir,
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\nfiscal_year_start = \"07-01\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
);
|
||||
mcp.call(
|
||||
@@ -3080,7 +2958,7 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-03-31", "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(handed(&pkg["file"]))).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(mcp.handed(&pkg["file"]))).unwrap();
|
||||
let mut read = |name: &str| {
|
||||
let mut text = String::new();
|
||||
std::io::Read::read_to_string(&mut zip.by_name(name).unwrap(), &mut text).unwrap();
|
||||
@@ -3103,9 +2981,9 @@ fn the_default_period_never_shortens_the_iif_or_the_package() {
|
||||
/// a restarted server reads the new name back.
|
||||
#[test]
|
||||
fn rename_account_end_to_end_defect_15() {
|
||||
let dir = test_dir("rename-e2e");
|
||||
let books = Books::new("rename-e2e");
|
||||
let mut mcp = served(
|
||||
&dir,
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
);
|
||||
mcp.call(
|
||||
@@ -3191,7 +3069,8 @@ fn rename_account_end_to_end_defect_15() {
|
||||
);
|
||||
assert!(!out["notes"].to_string().contains("cash account"), "{out}");
|
||||
drop(mcp);
|
||||
let mut mcp = Mcp::spawn(&dir.join("books-data"));
|
||||
books.restart();
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
let chart = mcp.call("books.chart", json!({}));
|
||||
let names: Vec<(String, String)> = chart["accounts"]
|
||||
@@ -3228,9 +3107,9 @@ fn rename_account_end_to_end_defect_15() {
|
||||
/// name is listed, and one that doesn't isn't.
|
||||
#[test]
|
||||
fn rename_says_what_the_new_name_moves_defect_15() {
|
||||
let dir = test_dir("rename-sets");
|
||||
let books = Books::new("rename-sets");
|
||||
let mut mcp = served(
|
||||
&dir,
|
||||
&books,
|
||||
"[company]\nname = \"Fernhill Labs LLC\"\n[books]\nreceipt_threshold = \"1000000.00\"\n",
|
||||
);
|
||||
mcp.call(
|
||||
@@ -3292,9 +3171,9 @@ fn rename_says_what_the_new_name_moves_defect_15() {
|
||||
/// 12 months: March 100 × 17/31 = 54.84, then 100.00 a month, the last
|
||||
/// March 45.16; through April 30, 154.84 posted, 1,045.16 left.
|
||||
#[test]
|
||||
fn mcp_daily_schedule_over_stdio() {
|
||||
let dir = test_dir("daily-schedule");
|
||||
let mut mcp = Mcp::spawn(&dir);
|
||||
fn mcp_daily_schedule() {
|
||||
let books = Books::new("daily-schedule");
|
||||
let mut mcp = Mcp::spawn(&books);
|
||||
init(&mut mcp);
|
||||
for (code, name, typ) in [
|
||||
("1300", "Prepaid Expenses", "asset"),
|
||||
@@ -3350,13 +3229,7 @@ fn mcp_daily_schedule_over_stdio() {
|
||||
assert_eq!(one["next"], "2026-05-31");
|
||||
assert_eq!(one["remaining"], "1045.16");
|
||||
|
||||
// The books take their settings in (there was no file: the defaults),
|
||||
// then new schedules are set to equal months.
|
||||
let done = mcp.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": "", "decided_by": "Owner", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(done["status"], "imported");
|
||||
// New schedules are set to equal months.
|
||||
mcp.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.schedule_convention", "value": "monthly_even"}],
|
||||
@@ -1,102 +1,18 @@
|
||||
//! 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.
|
||||
//! (`server`) 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};
|
||||
|
||||
#[cfg(all(feature = "gui", target_os = "linux"))]
|
||||
mod common;
|
||||
|
||||
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);
|
||||
@@ -107,11 +23,7 @@ fn each_mode_names_itself_in_its_usage() {
|
||||
|
||||
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",
|
||||
] {
|
||||
for line in ["wanderbooks gui [BOOK", "wanderbooks mcp --book ID"] {
|
||||
assert!(err.contains(line), "{line:?} missing from {err}");
|
||||
}
|
||||
}
|
||||
@@ -131,9 +43,9 @@ fn the_viewer_names_itself_in_its_usage() {
|
||||
#[test]
|
||||
fn a_mode_refuses_what_it_does_not_know() {
|
||||
let modes: &[&str] = if cfg!(feature = "gui") {
|
||||
&["dev-stdio", "gui", "server"]
|
||||
&["gui", "server"]
|
||||
} else {
|
||||
&["dev-stdio", "server"]
|
||||
&["server"]
|
||||
};
|
||||
for &mode in modes {
|
||||
let out = wanderbooks()
|
||||
@@ -143,10 +55,7 @@ fn a_mode_refuses_what_it_does_not_know() {
|
||||
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();
|
||||
let out = wanderbooks().args(["server", "--force"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{out:?}");
|
||||
}
|
||||
|
||||
@@ -166,7 +75,7 @@ fn the_books_folder_scaffold_is_gone_and_says_so() {
|
||||
|
||||
#[test]
|
||||
fn a_mode_comes_first() {
|
||||
for mode in ["gui", "dev-stdio", "server"] {
|
||||
for mode in ["gui", "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);
|
||||
@@ -177,13 +86,10 @@ fn a_mode_comes_first() {
|
||||
#[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();
|
||||
let out = wanderbooks().args(["server", "--root"]).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}");
|
||||
assert!(err.contains("error: --root needs a folder"), "{err}");
|
||||
}
|
||||
|
||||
/// With no display to open a window on, the viewer fails and says so
|
||||
@@ -192,28 +98,33 @@ fn a_server_that_cannot_start_says_why_and_exits_1() {
|
||||
#[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();
|
||||
// Empty books in a data root of the test's own, and a home of its
|
||||
// own: the viewer opens no loose books folder (D-44), and it reads its
|
||||
// preferences from the home, never this account's.
|
||||
let home = common::Home::new("one-exe");
|
||||
let server = common::Server::start(&home);
|
||||
home.create(common::BOOK, "USD");
|
||||
assert!(server.close().success());
|
||||
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")
|
||||
.args(["gui", "--root"])
|
||||
.arg(home.root())
|
||||
.args(["--book", common::BOOK])
|
||||
.env_clear()
|
||||
.env("PATH", "/usr/bin:/bin")
|
||||
.env("HOME", home.dir.path())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
let deadline = std::time::Instant::now() + common::TIMEOUT;
|
||||
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");
|
||||
let _ = child.wait();
|
||||
panic!("the viewer neither opened nor failed in time");
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
};
|
||||
@@ -235,50 +146,10 @@ fn the_server_only_build_has_no_viewer() {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// The books server needs a data root: with none, it says to run setup.
|
||||
#[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());
|
||||
|
||||
fn the_books_server_needs_a_root() {
|
||||
// No root yet: the books server says to run setup.
|
||||
let empty = tempfile::Builder::new()
|
||||
.prefix("wb-one-noroot-")
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
//! Oracle tests for `ledger-mcp`, driven over stdio against the real binary.
|
||||
//! Oracle tests for the books server's tools, driven through a real
|
||||
//! `wanderbooks server` as an agent reaches it (common.rs).
|
||||
//!
|
||||
//! Every number a tool reports is checked against what `ledger-core` (or
|
||||
//! `ledger-store`) computes for the same books, computed here from their
|
||||
@@ -7,10 +8,12 @@
|
||||
//! JSON deserializes, zips and XLSX unzip, IIF splits into its records.
|
||||
//!
|
||||
//! The books are mostly built in-process with [`Store`] (fast, and the
|
||||
//! oracle sees exactly the books the server will open), then the store is
|
||||
//! closed and the server started on the same data directory.
|
||||
//! oracle sees exactly the books the server will open), in the books'
|
||||
//! folder of a data root of the test's own; then the store is closed and
|
||||
//! the server started on that root.
|
||||
|
||||
use base64::Engine as _;
|
||||
use common::{Place, Wire};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, CashFlowParams, Currency, EntryStatus, Form1099, Ledger, MemberCapital,
|
||||
Money, NewAsset, Side, depreciation_schedule, reports,
|
||||
@@ -20,15 +23,11 @@ use ledger_store::ops::{ViewerCommand, ViewerTarget};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, HashMap};
|
||||
use std::io::{BufRead, BufReader, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::io::{Read, Write};
|
||||
use std::path::PathBuf;
|
||||
use std::str::FromStr;
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -40,110 +39,47 @@ enum Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
run: PathBuf,
|
||||
/// The books' folder in the data root.
|
||||
data: PathBuf,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-oracle-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(&format!("oracle-{name}"))
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` for two 50/50 members on 3000/3001, cash 1000,
|
||||
/// financing 2100, with the given receipt threshold.
|
||||
fn books_toml(dir: &Path, threshold: &str) -> PathBuf {
|
||||
let path = dir.join(format!("wanderbooks-{threshold}.toml"));
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n\
|
||||
financing_accounts = [\"2100\"]\n"
|
||||
),
|
||||
fn books_toml(threshold: &str) -> String {
|
||||
format!(
|
||||
"[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n\
|
||||
financing_accounts = [\"2100\"]\n"
|
||||
)
|
||||
.unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn data_dir(dir: &Path) -> PathBuf {
|
||||
dir.join("books-data")
|
||||
/// The books' folder in `dir`'s data root, where the oracle writes the
|
||||
/// books the server then opens.
|
||||
fn data_dir(dir: &TestDir) -> PathBuf {
|
||||
dir.books_data()
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, threshold: &str) -> Self {
|
||||
let toml = books_toml(dir, threshold);
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir(dir))
|
||||
.arg("--books-toml")
|
||||
.arg(&toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
/// Start (or restart) `dir`'s server, and a session whose books keep
|
||||
/// [`books_toml`]'s settings with this receipt threshold (set as an
|
||||
/// agent sets them: a value already set changes nothing).
|
||||
fn start(dir: &TestDir, threshold: &str) -> Self {
|
||||
let wire = dir.start(None);
|
||||
let mut m = Self {
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
run,
|
||||
data: dir.books_data(),
|
||||
};
|
||||
let init = m.rpc(
|
||||
"initialize",
|
||||
@@ -152,12 +88,16 @@ impl Mcp {
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
m.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string());
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": common::settings_changes(&books_toml(threshold), None, "USD"),
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
m
|
||||
}
|
||||
|
||||
fn send(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -166,18 +106,12 @@ impl Mcp {
|
||||
self.send(
|
||||
&json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}).to_string(),
|
||||
);
|
||||
loop {
|
||||
let line = self
|
||||
.rx
|
||||
.recv_timeout(TIMEOUT)
|
||||
.unwrap_or_else(|_| panic!("no response to {method}"));
|
||||
let v: Value = serde_json::from_str(&line).expect("server wrote JSON");
|
||||
if v.get("id").is_none() {
|
||||
continue;
|
||||
}
|
||||
assert_eq!(v["id"], json!(id));
|
||||
return v;
|
||||
}
|
||||
let v = self
|
||||
.wire
|
||||
.recv(common::TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no response to {method}: {:?}", self.wire.refused));
|
||||
assert_eq!(v["id"], json!(id));
|
||||
v
|
||||
}
|
||||
|
||||
fn tool(&mut self, name: &str, args: Value) -> Out {
|
||||
@@ -236,14 +170,16 @@ impl Mcp {
|
||||
}
|
||||
|
||||
/// The viewer command the last `books.show` wrote, in the books' own
|
||||
/// `ops/` (nothing in the runtime folder).
|
||||
/// `ops/`.
|
||||
fn viewer_command(&self) -> ViewerCommand {
|
||||
let books = self.run.parent().unwrap();
|
||||
assert!(
|
||||
!self.run.join("wanderbooks").exists(),
|
||||
"nothing written in the runtime folder"
|
||||
);
|
||||
ledger_store::ops::read(&data_dir(books)).expect("a show request")
|
||||
ledger_store::ops::read(&self.data).expect("a show request")
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by
|
||||
/// its download, matching the size and SHA-256 it states.
|
||||
fn handed(&mut self, file: &Value) -> Vec<u8> {
|
||||
assert!(file["expires_at"].is_string(), "{file}");
|
||||
self.wire.fetch(file)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,18 +199,6 @@ fn sha256(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): over stdio
|
||||
/// they come inline, with no download command, and match the size and
|
||||
/// SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = b64d(file["content_base64"].as_str().expect("content_base64"));
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha256(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
fn d(s: &str) -> chrono::NaiveDate {
|
||||
chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap()
|
||||
}
|
||||
@@ -441,7 +365,7 @@ const CHART: &[(&str, &str, AccountType)] = &[
|
||||
("7100", "Gain or Loss on Disposal", AccountType::Expense),
|
||||
];
|
||||
|
||||
fn new_store(dir: &Path) -> Store {
|
||||
fn new_store(dir: &TestDir) -> Store {
|
||||
let mut s = Store::create(&data_dir(dir), Currency::USD).unwrap();
|
||||
for (code, name, ty) in CHART {
|
||||
s.add_account(code, name, *ty, None, false).unwrap();
|
||||
@@ -1993,6 +1917,7 @@ fn dispose_asset_previews_equal_the_store_and_gate_receipts_on_debits() {
|
||||
assert_eq!(out["status"], "posted");
|
||||
let entry = out["entry_id"].as_u64().unwrap();
|
||||
drop(m);
|
||||
dir.stop();
|
||||
let s = Store::open(&data_dir(&dir)).unwrap();
|
||||
let l = s.ledger();
|
||||
assert_eq!(
|
||||
@@ -2121,6 +2046,7 @@ fn remit_payroll_total_is_what_payroll_withheld_and_owed() {
|
||||
assert_eq!(jamt(&out["total"]), owed);
|
||||
let entry = out["entry_id"].as_u64().unwrap();
|
||||
drop(m);
|
||||
dir.stop();
|
||||
let s = Store::open(&data_dir(&dir)).unwrap();
|
||||
let e = s.ledger().get_entry(entry).unwrap();
|
||||
let cash = id_of(&s, "1000");
|
||||
@@ -2316,6 +2242,7 @@ fn digest_reports_journal_repairs_since_the_date() {
|
||||
let out = m.ok("books.digest", json!({"since": "2025-01-01"}));
|
||||
let repairs = out["journal_repairs"].as_array().unwrap();
|
||||
drop(m);
|
||||
dir.stop();
|
||||
let s = Store::open(&data_dir(&dir)).unwrap();
|
||||
let want = s.tail_repairs().unwrap();
|
||||
drop(s);
|
||||
@@ -2485,8 +2412,7 @@ fn show_writes_the_viewer_command_for_each_target() {
|
||||
|
||||
/// The command `books.show` last wrote, or None before any.
|
||||
fn last_show(m: &Mcp) -> Option<ledger_store::ops::ViewerCommand> {
|
||||
let books = m.run.parent().unwrap().to_path_buf();
|
||||
ledger_store::ops::read(&data_dir(&books))
|
||||
ledger_store::ops::read(&m.data)
|
||||
}
|
||||
|
||||
/// Defect 15, repros 1.1.1 and 1.1.2: a start after the end, given, is
|
||||
@@ -3032,11 +2958,12 @@ fn inline_files_up_to_256_kib_are_accepted_and_no_more() {
|
||||
/// or an upload; never both, never neither. A sha256 missing, or not the
|
||||
/// bytes' own (a byte lost on the way), or base64 that isn't, is refused
|
||||
/// (guard base64_corrupt); inline bytes need their file_name and
|
||||
/// mime_type. Over stdio there are no uploads: an upload_id is refused,
|
||||
/// and books.begin_upload answers only for bytes stored already (the
|
||||
/// attachment holding them). Every refusal stores nothing.
|
||||
/// mime_type. An upload_id that names no upload is refused, and
|
||||
/// books.begin_upload names the attachment for bytes stored already, and
|
||||
/// begins an upload for new ones (journaling nothing). Every refusal
|
||||
/// stores nothing.
|
||||
#[test]
|
||||
fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
fn inline_bytes_come_with_their_own_sha256_or_as_an_upload() {
|
||||
let dir = test_dir("inline");
|
||||
let s = new_store(&dir);
|
||||
drop(s);
|
||||
@@ -3083,8 +3010,11 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
with(json!({"content_base64": "!!not base64", "sha256": sha256(bytes)})),
|
||||
"base64_corrupt",
|
||||
),
|
||||
// An upload, over stdio.
|
||||
(with(json!({"upload_id": "u1"})), "takes files inline only"),
|
||||
// An upload there is none of.
|
||||
(
|
||||
with(json!({"upload_id": "u1"})),
|
||||
"there is no upload \"u1\"",
|
||||
),
|
||||
] {
|
||||
let why = m.refuse(tool, args.clone());
|
||||
assert!(why.contains(said), "{tool} {args}: {why}");
|
||||
@@ -3109,7 +3039,7 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
let id = out["attachment_id"].as_u64().unwrap();
|
||||
assert_ne!(journal(), before);
|
||||
// begin_upload: the bytes stored already name their attachment; new
|
||||
// ones need the books server.
|
||||
// ones begin an upload.
|
||||
let up = |sha: String| {
|
||||
json!({"sha256": sha, "size": bytes.len(), "mime_type": "application/pdf",
|
||||
"file_name": "r.pdf", "purpose": "receipt"})
|
||||
@@ -3121,10 +3051,11 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
let got = m.ok("books.begin_upload", up(sha256(bytes).to_ascii_uppercase()));
|
||||
assert_eq!(got["existing_attachment_id"], json!(id), "{got}");
|
||||
let stored = journal();
|
||||
let why = m.refuse("books.begin_upload", up(sha256(other)));
|
||||
assert!(why.contains("takes files inline only"), "{why}");
|
||||
let begun = m.ok("books.begin_upload", up(sha256(other)));
|
||||
assert!(begun["upload_id"].is_string(), "{begun}");
|
||||
assert_eq!(journal(), stored, "beginning an upload journals nothing");
|
||||
// The CSV imports take csv_text or an upload, never both, never
|
||||
// neither; an upload over stdio is refused.
|
||||
// neither; an upload there is none of is refused.
|
||||
for (tool, base) in [
|
||||
(
|
||||
"books.import_bank_csv",
|
||||
@@ -3135,7 +3066,7 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
for (extra, said) in [
|
||||
(json!({}), "csv_text"),
|
||||
(json!({"csv_text": "a,b\n", "upload_id": "u1"}), "not both"),
|
||||
(json!({"upload_id": "u1"}), "takes files inline only"),
|
||||
(json!({"upload_id": "u1"}), "there is no upload \"u1\""),
|
||||
] {
|
||||
let mut args = base.clone();
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
@@ -3148,11 +3079,12 @@ fn inline_bytes_come_with_their_own_sha256_and_uploads_need_the_server() {
|
||||
assert_eq!(journal(), stored, "a refusal stored something");
|
||||
}
|
||||
|
||||
/// books.get_receipt's view bytes gives the bytes of a file of at most
|
||||
/// 256 KiB; a larger one (filed before, or by an upload) is refused over
|
||||
/// stdio, where there is no download, while its meta view still answers.
|
||||
/// books.get_receipt with view "bytes" gives a file's bytes inline up to
|
||||
/// 256 KiB; a larger one, its download (a stored file, fetched with
|
||||
/// books:read by `GET /b/{book}/a/{id}`: those very bytes), never its
|
||||
/// bytes inline.
|
||||
#[test]
|
||||
fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() {
|
||||
fn get_receipt_gives_bytes_up_to_256_kib_and_a_download_above() {
|
||||
const LIMIT: usize = 256 * 1024;
|
||||
let dir = test_dir("get-bytes");
|
||||
let mut s = new_store(&dir);
|
||||
@@ -3171,17 +3103,22 @@ fn get_receipt_gives_bytes_up_to_256_kib_over_stdio() {
|
||||
json!({"attachment_id": at_id, "view": "bytes"}),
|
||||
);
|
||||
assert_eq!(b64d(got["content_base64"].as_str().unwrap()), at);
|
||||
assert!(got.get("download").is_none(), "{got}");
|
||||
let why = m.refuse(
|
||||
let big = m.ok(
|
||||
"books.get_receipt",
|
||||
json!({"attachment_id": over_id, "view": "bytes"}),
|
||||
);
|
||||
assert!(why.contains(&(LIMIT + 1).to_string()), "{why}");
|
||||
assert!(big.get("content_base64").is_none(), "{big}");
|
||||
let download = big["download"].as_str().unwrap_or_else(|| panic!("{big}"));
|
||||
assert!(download.contains(&format!(" {over_id} ")), "{download}");
|
||||
assert_eq!(big["size_bytes"], json!(LIMIT + 1));
|
||||
assert_eq!(big["sha256"], sha256(&over));
|
||||
let (status, bytes) = m.wire.get(&format!("a/{over_id}"));
|
||||
assert_eq!(status, 200);
|
||||
assert_eq!(bytes, over);
|
||||
let meta = m.ok("books.get_receipt", json!({"attachment_id": over_id}));
|
||||
assert_eq!(meta["size_bytes"], json!(LIMIT + 1));
|
||||
assert_eq!(meta["sha256"], sha256(&over));
|
||||
assert!(meta.get("content_base64").is_none(), "{meta}");
|
||||
assert!(meta.get("download").is_none(), "{meta}");
|
||||
}
|
||||
|
||||
/// Bank CSV import: money in debits the bank, money out credits it; the
|
||||
@@ -3533,7 +3470,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
);
|
||||
assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip"));
|
||||
assert_eq!(out["file"]["mime_type"], "application/zip");
|
||||
let files = unzip(&handed(&out["file"]));
|
||||
let files = unzip(&m.handed(&out["file"]));
|
||||
let names: Vec<&str> = files.keys().map(String::as_str).collect();
|
||||
|
||||
let mut want = vec![
|
||||
@@ -3703,7 +3640,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
// payment after as_of counts, and the files differ.
|
||||
let mut tool_csv = |args: Value| {
|
||||
let out = m.ok("books.vendor_1099", args);
|
||||
handed(&out["csv"])
|
||||
m.handed(&out["csv"])
|
||||
};
|
||||
let same =
|
||||
tool_csv(json!({"year": 2025, "csv": true, "cash_accounts": ["1000"], "through": END}));
|
||||
@@ -3735,7 +3672,7 @@ fn cpa_package_holds_the_year_and_its_evidence() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}),
|
||||
);
|
||||
let files = unzip(&handed(&out["file"]));
|
||||
let files = unzip(&m.handed(&out["file"]));
|
||||
assert!(
|
||||
!files
|
||||
.keys()
|
||||
@@ -1,7 +1,8 @@
|
||||
//! P3's seams through the tools: on the seam review's books (a year of a
|
||||
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), built
|
||||
//! in-process with the real `Store`, closed, then served by the real
|
||||
//! binary over stdio from the same folder and its settings file, every
|
||||
//! in-process with the real `Store`, closed, then adopted with its
|
||||
//! settings file into a data root and served by a real `wanderbooks
|
||||
//! server` (common.rs), every
|
||||
//! figure a tool reports is the engine's for the same accounts and dates,
|
||||
//! and the tools agree with each other: the cash flow statement, party
|
||||
//! totals on both bases, the 1099 worksheet as of a day, the fixed
|
||||
@@ -11,101 +12,27 @@
|
||||
mod books;
|
||||
|
||||
use books::{books, d, dec};
|
||||
use common::{Books, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::mpsc::{Receiver, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const START: &str = "2026-01-01";
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
wire: Wire,
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(dir.join("wanderbooks.toml"))
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let mut mcp = Mcp {
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
next_id: 1,
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
"initialize",
|
||||
json!({
|
||||
"protocolVersion": "2025-06-18",
|
||||
"capabilities": {},
|
||||
"clientInfo": {"name": "p3-seams", "version": "0.1.0"},
|
||||
}),
|
||||
);
|
||||
assert!(init.get("result").is_some(), "initialize failed: {init}");
|
||||
writeln!(
|
||||
mcp.stdin,
|
||||
"{}",
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.unwrap();
|
||||
mcp
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
loop {
|
||||
let line = self
|
||||
.rx
|
||||
.recv_timeout(TIMEOUT)
|
||||
.unwrap_or_else(|_| panic!("no response to {method}"));
|
||||
let v: Value = serde_json::from_str(&line).unwrap();
|
||||
if v.get("id") == Some(&json!(id)) {
|
||||
return v;
|
||||
}
|
||||
}
|
||||
fn start(books: &Books) -> Self {
|
||||
let mut wire = books.wire();
|
||||
wire.initialize("p3-seams");
|
||||
Mcp { wire }
|
||||
}
|
||||
|
||||
/// A tool's structured result; a refusal fails the test.
|
||||
fn ok(&mut self, name: &str, args: Value) -> Value {
|
||||
let resp = self.rpc("tools/call", json!({"name": name, "arguments": args}));
|
||||
let resp = self.wire.call(name, args.clone());
|
||||
let r = &resp["result"];
|
||||
assert!(
|
||||
resp.get("error").is_none() && r.get("isError") != Some(&json!(true)),
|
||||
@@ -199,7 +126,13 @@ fn the_tools_figures_are_the_engines_on_every_date() {
|
||||
.collect();
|
||||
drop(b);
|
||||
|
||||
let mut mcp = Mcp::start(dir.path());
|
||||
let settings_toml = std::fs::read_to_string(dir.path().join("wanderbooks.toml")).unwrap();
|
||||
let served = Books::adopted(
|
||||
"p3-seams",
|
||||
&dir.path().join("books-data"),
|
||||
Some(&settings_toml),
|
||||
);
|
||||
let mut mcp = Mcp::start(&served);
|
||||
for x in &expected {
|
||||
let end = x.end.as_str();
|
||||
// The cash flow statement: every line, kind and reconciling item.
|
||||
@@ -12,8 +12,8 @@ use ledger_gui::pages::projects::{Snap, page_figures};
|
||||
use ledger_gui::{Source, data};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
mod common;
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
@@ -28,89 +28,39 @@ fn s(v: Decimal) -> String {
|
||||
format!("{v:.2}")
|
||||
}
|
||||
|
||||
/// The books server over stdio (`wanderbooks dev-stdio`), one request at
|
||||
/// a time.
|
||||
/// The settings both tests' books keep.
|
||||
const SETTINGS: &str = "[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n";
|
||||
|
||||
/// A session with the books server (common.rs), one request at a time.
|
||||
struct Server {
|
||||
child: std::process::Child,
|
||||
stdin: std::process::ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
id: u64,
|
||||
wire: common::Wire,
|
||||
}
|
||||
|
||||
impl Server {
|
||||
fn start(data: &std::path::Path) -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_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 stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut s = Server {
|
||||
child,
|
||||
stdin,
|
||||
stdout,
|
||||
id: 0,
|
||||
};
|
||||
s.ask(
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "projects-parity", "version": "0"}}),
|
||||
);
|
||||
writeln!(
|
||||
s.stdin,
|
||||
"{}",
|
||||
json!({"jsonrpc": "2.0", "method": "notifications/initialized"})
|
||||
)
|
||||
.unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn ask(&mut self, method: &str, params: Value) -> Value {
|
||||
self.id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": self.id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout.read_line(&mut line).expect("a response");
|
||||
let resp: Value = serde_json::from_str(&line).expect("JSON");
|
||||
assert_eq!(resp["id"], self.id, "{resp}");
|
||||
resp
|
||||
/// Start `place`'s server on the books the test wrote in its root,
|
||||
/// with [`SETTINGS`], and a session with them.
|
||||
fn start(place: &common::Place) -> Self {
|
||||
let mut wire = place.start(Some(SETTINGS));
|
||||
wire.initialize("projects-parity");
|
||||
Server { wire }
|
||||
}
|
||||
|
||||
/// A tool's structured result.
|
||||
fn tool(&mut self, name: &str, args: Value) -> Value {
|
||||
let r = self.ask("tools/call", json!({"name": name, "arguments": args}));
|
||||
let r = self.wire.call(name, args);
|
||||
let out = &r["result"];
|
||||
assert_ne!(out["isError"], true, "{name}: {r}");
|
||||
out["structuredContent"].clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-projects-parity-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books-data");
|
||||
std::fs::write(
|
||||
dir.path().join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
// The books written in a data root by the store itself, then served
|
||||
// there.
|
||||
let place = common::Place::new("projects-parity");
|
||||
let data = place.books_data();
|
||||
let today = d(2026, 10, 15);
|
||||
let id = {
|
||||
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
|
||||
@@ -162,12 +112,12 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
|
||||
.unwrap();
|
||||
id
|
||||
};
|
||||
let mut server = Server::start(&place);
|
||||
let b = data::load_once(&Source {
|
||||
data_dir: data.clone(),
|
||||
today: Some(today),
|
||||
})
|
||||
.unwrap();
|
||||
let mut server = Server::start(&data);
|
||||
|
||||
// To date, and by month over the viewer's chart span.
|
||||
let page = page_figures(&b, id, Some(d(2025, 11, 1)), false);
|
||||
@@ -264,24 +214,15 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
|
||||
/// project) and from the server (books.project, books.get_entry).
|
||||
#[test]
|
||||
fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-projects-parity-docs-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books-data");
|
||||
std::fs::write(
|
||||
dir.path().join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let place = common::Place::new("projects-parity-docs");
|
||||
let data = place.books_data();
|
||||
let today = d(2026, 10, 15);
|
||||
{
|
||||
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
|
||||
st.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
}
|
||||
let mut server = Server::start(&data);
|
||||
let mut server = Server::start(&place);
|
||||
server.tool(
|
||||
"books.add_project",
|
||||
json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}),
|
||||
|
||||
@@ -1,20 +1,19 @@
|
||||
//! The 1099 decision and payment methods through the server (plan P3,
|
||||
//! defects 12, 18 and 23; D-40), driven over stdio against the real
|
||||
//! binary: every success validates against the tool's outputSchema, and
|
||||
//! defects 12, 18 and 23; D-40), driven through a real books server as
|
||||
//! an agent reaches it (common.rs): every success validates against the tool's outputSchema, and
|
||||
//! every refusal leaves `journal.jsonl` byte-identical (the harness is
|
||||
//! `tests/spec_parties.rs`'s). Every name is fictional; the reasons are
|
||||
//! the ones the first company's cutover records.
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -39,57 +38,20 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-1099-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
@@ -108,38 +70,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
@@ -186,24 +123,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -366,7 +290,7 @@ fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
&dir.books_data(),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
@@ -390,7 +314,8 @@ impl Mcp {
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
dir.stop();
|
||||
ledger_store::Store::open(&dir.books_data())
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Spec-blind tests for the fixed-asset tag tools of `ledger-mcp`, driven
|
||||
//! over stdio against the real binary.
|
||||
//! Spec-blind tests for the fixed-asset tag tools of the books server,
|
||||
//! driven through a real `wanderbooks server` as an agent reaches it
|
||||
//! (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
|
||||
@@ -9,16 +10,15 @@
|
||||
//! the tool's `outputSchema`, and every refusal leaves `journal.jsonl`
|
||||
//! byte-identical (server invariant 2: a refused call changes nothing).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -43,57 +43,20 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-assets-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
@@ -112,38 +75,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
@@ -190,24 +128,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -788,7 +713,7 @@ fn asset_labels_refuses_an_asset_without_a_tag() {
|
||||
// Books written by the store, outside the server: an untagged asset.
|
||||
{
|
||||
use ledger_core::{AccountType, Currency, Money, NewAsset};
|
||||
let data = dir.join("books-data");
|
||||
let data = dir.books_data();
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
let mut s = ledger_store::Store::create(&data, Currency::USD).unwrap();
|
||||
let mut add = |c: &str, n: &str, t| s.add_account(c, n, t, None, false).unwrap();
|
||||
@@ -1780,6 +1705,7 @@ fn correcting_a_legacy_contributions_cost_says_its_built_in_gain_moved() {
|
||||
let dir = m.books_dir.take().unwrap();
|
||||
let data = m.data.clone();
|
||||
drop(m);
|
||||
dir.stop();
|
||||
let mut w = ledger_store::JournalWriter::open(&data.join("journal.jsonl")).unwrap();
|
||||
w.append(
|
||||
"asset.tax_basis_set",
|
||||
+22
-121
@@ -1,5 +1,6 @@
|
||||
//! Spec-blind tests for the attachment-link, statement, and reconciliation
|
||||
//! tools of `ledger-mcp`, driven over stdio against the real binary.
|
||||
//! tools of the books server, driven through a real `wanderbooks server`
|
||||
//! as an agent reaches it (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
|
||||
@@ -10,16 +11,15 @@
|
||||
//! against the tool's `outputSchema`, and every refusal leaves
|
||||
//! `journal.jsonl` byte-identical (server invariant 2).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -44,82 +44,33 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// Every response line the server wrote, with the tool it answered.
|
||||
transcript: Vec<(String, String)>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folder beside it (`-outside`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-attachments-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// A directory beside the books dir `dir`, outside it: the vault key,
|
||||
/// files the tests hand the server, files it writes, its stderr.
|
||||
fn outside(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name, the receipt threshold, and
|
||||
/// extra `[books]` TOML; the vault key file is outside the books dir.
|
||||
/// extra `[books]` TOML.
|
||||
fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
outside(dir).join("vault.key").display()
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -127,43 +78,13 @@ fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
// Never the user's real vault key (the default key file is
|
||||
// under XDG_CONFIG_HOME); the tests name theirs anyway.
|
||||
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::from(
|
||||
std::fs::File::create(outside(dir).join("stderr.log")).unwrap(),
|
||||
))
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
transcript: Vec::new(),
|
||||
};
|
||||
@@ -211,24 +132,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -1282,7 +1190,6 @@ fn a_mis_recorded_statement_is_voided_and_recorded_again() {
|
||||
/// of the package entirely.
|
||||
#[test]
|
||||
fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||||
use base64::Engine as _;
|
||||
let mut m = books_at("cpa-linked", "0.00", "");
|
||||
let receipt = m.receipt(None, b"%PDF linked receipt");
|
||||
let e = m.draft(
|
||||
@@ -1305,14 +1212,8 @@ fn the_cpa_package_carries_receipts_linked_to_entries() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}),
|
||||
);
|
||||
// The zip comes back inline over stdio, as stated (D-29).
|
||||
let file = &pkg["file"];
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
// The zip is handed over by its download, as stated (D-29).
|
||||
let bytes = m.wire.fetch(&pkg["file"]);
|
||||
let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap();
|
||||
let names: Vec<String> = (0..zip.len())
|
||||
.map(|i| zip.by_index(i).unwrap().name().unwrap().to_string())
|
||||
@@ -1,59 +1,24 @@
|
||||
//! P1 through the real server: settings moving into the books, the books'
|
||||
//! own settings, conventions, approvals, books.start (plan/served.md P1).
|
||||
//! Drives the real `ledger-mcp` binary over stdio with raw JSON-RPC.
|
||||
//! P1 through the real server: the books' own settings, conventions,
|
||||
//! approvals, books.start (plan/served.md P1). Drives a real `wanderbooks
|
||||
//! server` with raw JSON-RPC, as an agent reaches it (common.rs).
|
||||
|
||||
use common::{Books, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
mod common;
|
||||
|
||||
/// One agent's session with the test's books (common.rs).
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
/// Closing stdin ends the server; wait for it to exit, so the next server
|
||||
/// on the same books never races it for the writer lock.
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||||
Self::spawn_with(data_dir, &[])
|
||||
}
|
||||
|
||||
fn spawn_with(data_dir: &std::path::Path, extra: &[&std::ffi::OsStr]) -> Self {
|
||||
Self::spawn_env(data_dir, extra, &[])
|
||||
}
|
||||
|
||||
fn spawn_env(
|
||||
data_dir: &std::path::Path,
|
||||
extra: &[&std::ffi::OsStr],
|
||||
env: &[(&str, &std::path::Path)],
|
||||
) -> Self {
|
||||
let bin = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
let mut child = Command::new(bin)
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.args(extra)
|
||||
.envs(env.iter().map(|(k, v)| (*k, v.as_os_str())))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
/// A new session with `books` (initialized by the test).
|
||||
fn spawn(books: &Books) -> Self {
|
||||
Self {
|
||||
stdin,
|
||||
stdout,
|
||||
wire: books.wire(),
|
||||
next_id: 1,
|
||||
child,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,13 +26,11 @@ impl Mcp {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout
|
||||
.read_line(&mut line)
|
||||
.expect("read response line");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse response");
|
||||
self.wire.send(&msg);
|
||||
let resp = self
|
||||
.wire
|
||||
.recv(common::TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
assert!(
|
||||
resp.get("error").is_none(),
|
||||
@@ -78,9 +41,8 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn notify(&mut self, method: &str) {
|
||||
let msg = json!({"jsonrpc": "2.0", "method": method, "params": {}});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire
|
||||
.send(&json!({"jsonrpc": "2.0", "method": method, "params": {}}));
|
||||
}
|
||||
|
||||
/// Call a tool; panic on tool error. Returns structuredContent.
|
||||
@@ -112,35 +74,6 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-mcp-test-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn init(m: &mut Mcp) -> String {
|
||||
let r = m.request(
|
||||
"initialize",
|
||||
@@ -191,78 +124,21 @@ fn chart(m: &mut Mcp) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A settings file moves into the books, once, with the human's go: set
|
||||
/// is refused before (it would drop the rest of the file); the preview
|
||||
/// changes nothing; after, the file is never read, even to start, and the
|
||||
/// company's EIN only ever leaves the server masked.
|
||||
/// Books keep their settings, changed through the tools with who decided
|
||||
/// and when; the company's EIN only ever leaves the server masked (in
|
||||
/// books.settings, books.set_setting, books.export_settings and
|
||||
/// books.start); and a restarted server reads them from the books.
|
||||
#[test]
|
||||
fn a_settings_file_moves_into_the_books() {
|
||||
let dir = test_dir("kept-move");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
fn settings_live_in_the_books_and_the_ein_leaves_masked() {
|
||||
let books = Books::with("kept-ein", "USD", Some(TOML));
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let told = init(&mut m);
|
||||
assert!(told.contains("books.start"), "{told}");
|
||||
assert!(
|
||||
told.contains("the settings file books.settings names"),
|
||||
"{told}"
|
||||
);
|
||||
chart(&mut m);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
|
||||
let start = m.call("books.start", json!({}));
|
||||
assert_eq!(start["settings_source"], "file");
|
||||
assert!(
|
||||
start["next_steps"]
|
||||
.to_string()
|
||||
.contains("books.import_settings")
|
||||
);
|
||||
let refused = m.call_err(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.cpa", "value": "Casey"}], "decided_by": "A"}),
|
||||
);
|
||||
assert!(refused.contains("books.import_settings"), "{refused}");
|
||||
// Nor is there an export yet: the file is the copy.
|
||||
let refused = m.call_err("books.export_settings", json!({}));
|
||||
assert!(
|
||||
refused.contains("still read their settings file")
|
||||
&& refused.contains("books.import_settings"),
|
||||
"{refused}"
|
||||
);
|
||||
// The preview records nothing.
|
||||
let head = |m: &mut Mcp| m.call("books.start", json!({}))["head"]["seq"].clone();
|
||||
let before = head(&mut m);
|
||||
let preview = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "note": "Moving in."}),
|
||||
);
|
||||
assert_eq!(preview["status"], "preview");
|
||||
assert_eq!(head(&mut m), before);
|
||||
assert!(
|
||||
preview["notes"].to_string().contains("data_dir"),
|
||||
"{preview}"
|
||||
);
|
||||
assert!(
|
||||
preview["unknown"].as_array().unwrap().is_empty(),
|
||||
"{preview}"
|
||||
);
|
||||
// The file's comments are listed, never dropped unseen.
|
||||
assert_eq!(
|
||||
preview["comments"],
|
||||
json!(["Form 1065; a Schedule K-1 per member"]),
|
||||
"{preview}"
|
||||
);
|
||||
let shown = preview.to_string();
|
||||
assert!(!shown.contains("2215893"), "the EIN is masked: {shown}");
|
||||
assert!(shown.contains("**-***5893"), "{shown}");
|
||||
let done = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(done["status"], "imported");
|
||||
let settings = m.call("books.settings", json!({}));
|
||||
assert_eq!(settings["source"], "books");
|
||||
assert_eq!(settings["books_toml"], Value::Null);
|
||||
assert_eq!(settings["parties"], "require");
|
||||
let shown = settings.to_string();
|
||||
assert!(!shown.contains("8525893"), "the EIN is masked: {shown}");
|
||||
let ein = settings["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
@@ -271,12 +147,10 @@ fn a_settings_file_moves_into_the_books() {
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(ein["value"], "**-***5893");
|
||||
assert_eq!(ein["set_by"]["decided_by"], "A");
|
||||
// The file is no longer read: an edit changes nothing.
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML.replace("0.00", "99.00")).unwrap();
|
||||
let rule = m.call("books.settings", json!({}))["receipt_rule"].clone();
|
||||
assert_eq!(ein["set_by"]["decided_by"], "Test Owner");
|
||||
let rule = settings["receipt_rule"].clone();
|
||||
assert!(rule.as_str().unwrap().contains("every entry"), "{rule}");
|
||||
// Settings change through the tools now.
|
||||
// Settings change through the tools.
|
||||
let set = m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"},
|
||||
@@ -289,88 +163,41 @@ fn a_settings_file_moves_into_the_books() {
|
||||
assert!(text.contains("timezone = \"America/Denver\""), "{text}");
|
||||
assert!(!text.contains("3456789"), "{text}");
|
||||
let start = m.call("books.start", json!({}));
|
||||
assert_eq!(start["settings_source"], "books");
|
||||
assert_eq!(start["book"]["timezone"], "America/Denver");
|
||||
assert!(!start.to_string().contains("3456789"));
|
||||
drop(m);
|
||||
// A broken settings file beside books that keep their own can't stop
|
||||
// the server: it is never read.
|
||||
std::fs::write(dir.join("wanderbooks.toml"), "[books\nthis is not toml").unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
books.restart();
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let told = init(&mut m);
|
||||
assert!(
|
||||
told.contains("Settings and conventions live in the books"),
|
||||
"{told}"
|
||||
);
|
||||
assert!(!told.contains("local file you may edit"), "{told}");
|
||||
assert!(told.contains("Kept Co LLC"), "{told}");
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
// What was set before the restart, read back from the books: the
|
||||
// timezone, and the new EIN, still masked.
|
||||
let start = m.call("books.start", json!({}));
|
||||
assert_eq!(start["book"]["timezone"], "America/Denver");
|
||||
let settings = m.call("books.settings", json!({}));
|
||||
let ein = settings["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k["key"] == "company.ein")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(ein["value"], "**-***6789");
|
||||
assert_eq!(ein["set_by"]["decided_by"], "B");
|
||||
assert!(!settings.to_string().contains("3456789"), "{settings}");
|
||||
assert_eq!(m.call("books.verify", json!({}))["status"], "ok");
|
||||
}
|
||||
|
||||
/// INVARIANT 2 (tools/kept.rs): an import that would read any setting
|
||||
/// differently than the server does now is refused. Here the server reads
|
||||
/// its receipt policy from config.json, which the import must carry.
|
||||
#[test]
|
||||
fn an_import_that_reads_differently_is_refused() {
|
||||
let dir = test_dir("kept-differs");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::create_dir_all(&data).unwrap();
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let policy = r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#;
|
||||
std::fs::write(data.join("config.json"), policy).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("policy.evidence.threshold"), "{e}");
|
||||
assert!(e.contains("config_json_text"), "{e}");
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "file");
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true,
|
||||
"config_json_text": policy}),
|
||||
);
|
||||
let s = m.call("books.settings", json!({}));
|
||||
assert_eq!(s["source"], "books");
|
||||
assert_eq!(s["receipt_exempt_accounts"], json!(["6999"]));
|
||||
}
|
||||
|
||||
/// The vault key a settings file names must come from the command line
|
||||
/// before the settings move in, or the vault would stop opening.
|
||||
#[test]
|
||||
fn an_import_needs_the_vault_key_on_the_command_line() {
|
||||
let dir = test_dir("kept-vault");
|
||||
let data = dir.join("books-data");
|
||||
let key = dir.join("elsewhere").join("vault.key");
|
||||
let toml = format!("{TOML}\n[vault]\nkey_file = \"{}\"\n", key.display());
|
||||
std::fs::write(dir.join("wanderbooks.toml"), &toml).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("--vault-key-file"), "{e}");
|
||||
drop(m);
|
||||
let mut m = Mcp::spawn_with(&data, &["--vault-key-file".as_ref(), key.as_os_str()]);
|
||||
init(&mut m);
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
}
|
||||
|
||||
/// Approval rules through the server: enforced, a covered post is refused
|
||||
/// until its approver approves, then posts; off, they block nothing.
|
||||
#[test]
|
||||
fn approval_rules_through_the_server() {
|
||||
let dir = test_dir("kept-approvals");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-approvals");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
@@ -431,9 +258,8 @@ fn approval_rules_through_the_server() {
|
||||
/// A posting template drafts through the server, to the cent.
|
||||
#[test]
|
||||
fn templates_draft_through_the_server() {
|
||||
let dir = test_dir("kept-template");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-template");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
@@ -492,9 +318,8 @@ fn templates_draft_through_the_server() {
|
||||
/// it left out.
|
||||
#[test]
|
||||
fn start_fits_in_8_kb() {
|
||||
let dir = test_dir("kept-start");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-start");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
let body =
|
||||
"A first line long enough to fill its summary to the brim, and then some more words. "
|
||||
@@ -528,17 +353,14 @@ fn start_fits_in_8_kb() {
|
||||
assert_eq!(start["isolation"], "same_user (advisory)");
|
||||
}
|
||||
|
||||
/// Review of P1 (2026-10-17): the text imported is the file the server
|
||||
/// reads, whole; books that keep their own settings take no import that
|
||||
/// changes them; a settings file never turns approvals on; after the
|
||||
/// move, a broken config.json can't stop the server; and books.settings
|
||||
/// shows a file's values before the move.
|
||||
/// Review of P1 (2026-10-17) and D-44 (owner, 2026-10-23: the import
|
||||
/// tool goes): books keep their own settings and take no settings file;
|
||||
/// books.settings shows each value (an identity value masked), and
|
||||
/// books.import_settings is no tool (an unknown tool: a protocol error).
|
||||
#[test]
|
||||
fn an_import_is_the_file_the_server_reads() {
|
||||
let dir = test_dir("kept-whole");
|
||||
let data = dir.join("books-data");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
fn books_keeping_their_settings_take_no_settings_file() {
|
||||
let books = Books::with("kept-whole", "USD", Some(TOML));
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
let keys = m.call("books.settings", json!({}))["keys"].clone();
|
||||
let ein = keys
|
||||
@@ -548,48 +370,20 @@ fn an_import_is_the_file_the_server_reads() {
|
||||
.find(|k| k["key"] == "company.ein")
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert_eq!(ein["value"], "**-***5893", "the file's value, masked");
|
||||
// A text that leaves the EIN out is not the file.
|
||||
let short = TOML.replace("ein = \"28-8525893\"\n", "");
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
|
||||
assert_eq!(ein["value"], "**-***5893", "the value, masked");
|
||||
let tools = m.request("tools/list", json!({}))["result"]["tools"].clone();
|
||||
assert!(
|
||||
!tools.to_string().contains("books.import_settings"),
|
||||
"{tools}"
|
||||
);
|
||||
assert!(e.contains("company.ein"), "{e}");
|
||||
// Approval rules never come in from a file.
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
format!("{TOML}approval_rules = \"enforce\"\n")
|
||||
.replace("[viewer]\nopen_at_start = \"ask\"\n", ""),
|
||||
)
|
||||
.unwrap();
|
||||
let with_rules = std::fs::read_to_string(dir.join("wanderbooks.toml")).unwrap();
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": with_rules, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("approval"), "{e}");
|
||||
std::fs::write(dir.join("wanderbooks.toml"), TOML).unwrap();
|
||||
m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
// Imported books: the same text again is no change; another is refused.
|
||||
let again = m.call(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": TOML, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(again["changes"].as_array().unwrap().is_empty(), "{again}");
|
||||
let e = m.call_err(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": short, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
assert!(e.contains("books.set_setting"), "{e}");
|
||||
drop(m);
|
||||
std::fs::write(data.join("config.json"), "{ not json").unwrap();
|
||||
let mut m = Mcp::spawn(&data);
|
||||
init(&mut m);
|
||||
assert_eq!(m.call("books.settings", json!({}))["source"], "books");
|
||||
let id = m.next_id;
|
||||
m.next_id += 1;
|
||||
m.wire
|
||||
.send(&json!({"jsonrpc": "2.0", "id": id, "method": "tools/call",
|
||||
"params": {"name": "books.import_settings",
|
||||
"arguments": {"toml_text": TOML, "decided_by": "A", "confirmed": true}}}));
|
||||
let resp = m.wire.recv(common::TIMEOUT).expect("an answer");
|
||||
assert!(resp.get("error").is_some(), "{resp}");
|
||||
}
|
||||
|
||||
/// A convention that names an account is listed when that account is
|
||||
@@ -597,9 +391,8 @@ fn an_import_is_the_file_the_server_reads() {
|
||||
/// chart.
|
||||
#[test]
|
||||
fn renumbering_names_the_conventions_to_revise() {
|
||||
let dir = test_dir("kept-renumber");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-renumber");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
@@ -632,9 +425,8 @@ fn renumbering_names_the_conventions_to_revise() {
|
||||
/// tools do.
|
||||
#[test]
|
||||
fn the_kept_resources_read() {
|
||||
let dir = test_dir("kept-resources");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-resources");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
@@ -651,22 +443,19 @@ fn the_kept_resources_read() {
|
||||
assert_eq!(start["state"], "active", "{start}");
|
||||
assert_eq!(start["conventions"][0]["id"], "software", "{start}");
|
||||
let settings = read(&mut m, "books://settings");
|
||||
assert_eq!(settings["source"], "file", "{settings}");
|
||||
assert_eq!(settings, m.call("books.settings", json!({})), "{settings}");
|
||||
let conventions = read(&mut m, "books://conventions");
|
||||
assert_eq!(
|
||||
conventions["conventions"][0]["id"], "software",
|
||||
"{conventions}"
|
||||
);
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
/// A renumber lists exactly the conventions naming the old code.
|
||||
#[test]
|
||||
fn renumbering_lists_only_the_conventions_naming_it() {
|
||||
let dir = test_dir("kept-renumber-only");
|
||||
let data = dir.join("books-data");
|
||||
let mut m = Mcp::spawn(&data);
|
||||
let books = Books::new("kept-renumber-only");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
for (id, dr, cr) in [("software", "6000", "1000"), ("deposit", "1000", "3000")] {
|
||||
@@ -696,6 +485,4 @@ fn renumbering_lists_only_the_conventions_naming_it() {
|
||||
["convention software (revise it with books.set_convention)"],
|
||||
"{out}"
|
||||
);
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Spec-blind tests for `ledger-mcp`, driven over stdio against the real
|
||||
//! binary.
|
||||
//! Spec-blind tests for the books server, driven through a real
|
||||
//! `wanderbooks server` as an agent reaches it (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the GOAL/SOURCE/INVARIANTS blocks of
|
||||
//! `src/server.rs` and `src/policy.rs`, the server's connect text and handbook,
|
||||
@@ -19,20 +19,19 @@
|
||||
//! files under the data dir as they were, success or refusal (plan 4.9,
|
||||
//! "Annotations (P0)": the hint is true in behaviour).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use proptest::prelude::*;
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::{BTreeMap, BTreeSet, HashMap};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
mod common;
|
||||
|
||||
/// How long any single response may take before the server counts as hung.
|
||||
/// Long enough for a server's first answer while a mutation run's jobs
|
||||
/// contend for the disk (every journal append is synced).
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
/// Random cases for the state machine: 64 on every check; `MCP_CASES`
|
||||
/// sets fewer for a mutation run, more for a deeper one.
|
||||
@@ -146,9 +145,7 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
schemas: HashMap<String, Value>,
|
||||
@@ -159,64 +156,16 @@ struct Mcp {
|
||||
read_only_checked: BTreeSet<String>,
|
||||
/// Invariant 3 (amounts out) violations: `tool: path = value`.
|
||||
money_violations: Vec<String>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folders beside it (`.inbox`, `-outside`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
for beside in [".inbox", "-outside"] {
|
||||
let p = PathBuf::from(format!("{}{beside}", self.0.display()));
|
||||
let _ = std::fs::remove_dir_all(&p);
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A fresh, empty per-test directory under the system temp dir.
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir =
|
||||
std::env::temp_dir().join(format!("ledger-mcp-spec-{}-{name}-{n}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// A directory beside the books dir `dir`, outside it (vault keys, files
|
||||
/// with TINs).
|
||||
fn outside(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// Write a `wanderbooks.toml` with the given receipt threshold and extra
|
||||
@@ -234,56 +183,16 @@ fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
// A settings file naming the vault's key (the happy path's) names
|
||||
// it on the command line too, as books that move their settings
|
||||
// in must.
|
||||
.args(
|
||||
std::fs::read_to_string(toml)
|
||||
.is_ok_and(|t| t.contains("[vault]"))
|
||||
.then(|| ["--vault-key-file".into(), outside(dir).join("vault.key")])
|
||||
.into_iter()
|
||||
.flatten(),
|
||||
)
|
||||
// The TIN vault's default key file is under XDG_CONFIG_HOME: never
|
||||
// the user's real one (the happy path names its own anyway).
|
||||
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp")
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// Spawn on `dir` with `toml`, initialize, and load `tools/list`.
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let mut child = spawn_child(dir, toml);
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
/// Start `dir`'s server with `toml`'s settings, initialize, and load
|
||||
/// `tools/list`.
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
schemas: HashMap::new(),
|
||||
validators: HashMap::new(),
|
||||
exercised: BTreeSet::new(),
|
||||
@@ -319,45 +228,28 @@ impl Mcp {
|
||||
std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default()
|
||||
}
|
||||
|
||||
/// What a read-only tool must leave as it was: every file under the
|
||||
/// data dir, the books dir's vault/ and its settings file, by path,
|
||||
/// each with the SHA-256 of its contents. The database is compared by
|
||||
/// what it holds (every row of every table, in order: SQLite moves
|
||||
/// pages between the file and its write-ahead log on its own, and a
|
||||
/// reader writes its read marks into the shared-memory file), so the
|
||||
/// -wal and -shm files are left out.
|
||||
/// What a read-only tool must leave as it was: every file of the books
|
||||
/// (their folder in the data root, the vault in it), by path, each with
|
||||
/// the SHA-256 of its contents; but what every call writes by design
|
||||
/// (common.rs `book_files`: the access log, the viewer's side channel,
|
||||
/// the books' own backup, files handed over or on their way in). The
|
||||
/// database is compared by what it holds (every row of every table, in
|
||||
/// order: SQLite moves pages between the file and its write-ahead log
|
||||
/// on its own, and a reader writes its read marks into the
|
||||
/// shared-memory file), so the -wal and -shm files are left out.
|
||||
fn data_files(&self) -> BTreeMap<PathBuf, String> {
|
||||
fn walk(dir: &Path, out: &mut BTreeMap<PathBuf, String>) {
|
||||
for e in std::fs::read_dir(dir).into_iter().flatten().flatten() {
|
||||
let p = e.path();
|
||||
let name = p.file_name().unwrap().to_string_lossy().into_owned();
|
||||
// The viewer's side channel (books.show's requests) is not
|
||||
// the books: never journaled (ledger_store::ops).
|
||||
if p.is_dir() && name == ledger_store::ops::OPS_DIR {
|
||||
continue;
|
||||
}
|
||||
if p.is_dir() {
|
||||
walk(&p, out);
|
||||
} else if name.ends_with("-wal") || name.ends_with("-shm") {
|
||||
continue;
|
||||
} else if ledger_store::is_database_file(&name) {
|
||||
out.insert(p.clone(), db_digest(&p));
|
||||
common::book_files(&self.data)
|
||||
.into_iter()
|
||||
.map(|(rel, sha)| {
|
||||
let p = self.data.join(&rel);
|
||||
let name = rel.file_name().unwrap().to_string_lossy().into_owned();
|
||||
if ledger_store::is_database_file(&name) {
|
||||
(p.clone(), db_digest(&p))
|
||||
} else {
|
||||
let bytes = std::fs::read(&p).unwrap_or_default();
|
||||
out.insert(p, ledger_store::journal::sha256_hex(&bytes));
|
||||
(p, sha)
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut out = BTreeMap::new();
|
||||
walk(&self.data, &mut out);
|
||||
let books = self.data.parent().unwrap();
|
||||
walk(&books.join("vault"), &mut out);
|
||||
let toml = books.join("wanderbooks.toml");
|
||||
out.insert(
|
||||
toml.clone(),
|
||||
ledger_store::journal::sha256_hex(&std::fs::read(&toml).unwrap_or_default()),
|
||||
);
|
||||
out
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn read_only(&self, name: &str) -> bool {
|
||||
@@ -367,26 +259,13 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
/// The next JSON-RPC response (messages without an id, i.e. server
|
||||
/// notifications, are skipped). `None` on timeout or EOF.
|
||||
/// notifications, are skipped). `None` when the server sent none.
|
||||
fn recv(&mut self, timeout: Duration) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(timeout) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(timeout)
|
||||
}
|
||||
|
||||
/// Send a request and return its response, checking JSON-RPC 2.0 §5:
|
||||
@@ -480,7 +359,21 @@ impl Mcp {
|
||||
);
|
||||
let mut bad = Vec::new();
|
||||
money_walk(&sc, "$", None, &mut bad);
|
||||
// A key the tool's outputSchema declares an integer is a count,
|
||||
// not money (books.access_log's `total`).
|
||||
let counts: Vec<String> = self.schemas[name]["outputSchema"]["properties"]
|
||||
.as_object()
|
||||
.map(|p| {
|
||||
p.iter()
|
||||
.filter(|(_, s)| s["type"] == "integer")
|
||||
.map(|(k, _)| format!("$.{k} "))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for b in bad {
|
||||
if counts.iter().any(|c| b.starts_with(c.as_str())) {
|
||||
continue;
|
||||
}
|
||||
self.money_violations.push(format!("{name}: {b}"));
|
||||
}
|
||||
self.exercised.insert(name.to_string());
|
||||
@@ -696,13 +589,9 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let toml = books_toml(
|
||||
&dir,
|
||||
"1000000.00",
|
||||
&format!(
|
||||
"financing_accounts = [\"2100\"]\n\
|
||||
"financing_accounts = [\"2100\"]\n\
|
||||
[[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\
|
||||
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
outside(&dir).join("vault.key").display()
|
||||
),
|
||||
[[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n",
|
||||
);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
|
||||
@@ -940,7 +829,7 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}),
|
||||
);
|
||||
// An upload of bytes already stored: begin_upload names the
|
||||
// attachment that holds them (over stdio, the only upload answered).
|
||||
// attachment that holds them.
|
||||
let local = m.attach(None, b"a local receipt");
|
||||
let up = m.ok(
|
||||
"books.begin_upload",
|
||||
@@ -1217,15 +1106,6 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"id": item, "resolution": "Mailed.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.open_items", json!({"include_resolved": true}));
|
||||
let toml_text = std::fs::read_to_string(&toml).unwrap();
|
||||
m.ok(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml_text, "decided_by": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": toml_text, "decided_by": "A", "confirmed": true}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
|
||||
@@ -1311,6 +1191,12 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"as_of": "2026-12-31", "include_receipts": false}),
|
||||
);
|
||||
|
||||
// The access log, the backups' status, and a backup now (none is set
|
||||
// up: it says so).
|
||||
m.ok("books.access_log", json!({}));
|
||||
m.ok("books.backup", json!({}));
|
||||
m.ok("books.back_up_now", json!({}));
|
||||
|
||||
m.books_dir = Some(dir);
|
||||
m
|
||||
}
|
||||
@@ -2053,56 +1939,26 @@ fn inv5_reversals_are_exempt() {
|
||||
}
|
||||
|
||||
/// Policy invariant 4: a threshold that isn't a decimal, or is below
|
||||
/// zero, is refused at startup.
|
||||
/// zero, is refused. The books keep their settings, so it is refused as it
|
||||
/// is set (books.set_setting), journaling nothing, and the rule in force
|
||||
/// stays.
|
||||
#[test]
|
||||
fn inv5_bad_threshold_refused_at_startup() {
|
||||
fn inv5_bad_threshold_refused_when_set() {
|
||||
let dir = test_dir("inv5-set");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
let before = m.journal();
|
||||
assert!(!before.is_empty(), "the books keep a journal");
|
||||
for bad in ["-1", "abc", "-0.01", "", "1,000", "ten"] {
|
||||
let dir = test_dir("inv5-startup");
|
||||
let toml = books_toml(&dir, bad, "");
|
||||
let mut child = spawn_child(&dir, &toml);
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let stdout = child.stdout.take().unwrap();
|
||||
let init = json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "spec-test", "version": "0.1.0"}}});
|
||||
let _ = writeln!(stdin, "{init}");
|
||||
let _ = stdin.flush();
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
let mut line = String::new();
|
||||
let _ = BufReader::new(stdout).read_line(&mut line);
|
||||
let _ = tx.send(line);
|
||||
});
|
||||
let line = rx.recv_timeout(TIMEOUT).unwrap_or_default();
|
||||
let answered = serde_json::from_str::<Value>(&line)
|
||||
.ok()
|
||||
.is_some_and(|v| v.get("result").is_some());
|
||||
drop(stdin);
|
||||
let status = wait_with_timeout(&mut child, TIMEOUT);
|
||||
assert!(
|
||||
!answered,
|
||||
"threshold {bad:?}: server completed initialize: {line}"
|
||||
);
|
||||
assert!(
|
||||
status.is_some_and(|s| !s.success()),
|
||||
"threshold {bad:?}: server did not exit nonzero ({status:?})"
|
||||
m.refuse(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.evidence.threshold", "value": bad}],
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_with_timeout(child: &mut Child, timeout: Duration) -> Option<std::process::ExitStatus> {
|
||||
let start = std::time::Instant::now();
|
||||
loop {
|
||||
if let Ok(Some(s)) = child.try_wait() {
|
||||
return Some(s);
|
||||
}
|
||||
if start.elapsed() > timeout {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return None;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(20));
|
||||
}
|
||||
assert_eq!(m.journal(), before, "a refused threshold journals nothing");
|
||||
let rule = m.ok("books.settings", json!({}))["receipt_rule"].clone();
|
||||
assert!(rule.as_str().unwrap().contains("25.00"), "{rule}");
|
||||
}
|
||||
|
||||
// --------------------------------------- invariant 6: ambiguous references
|
||||
@@ -2230,8 +2086,8 @@ fn inv6_ambiguous_class_reference_refused() {
|
||||
/// Books an earlier version wrote holding these classes, in id order: a
|
||||
/// name of digits only can't be added now (it would read as an id), so
|
||||
/// only old books hold the id/name pairs invariant 6 refuses.
|
||||
fn old_classes(dir: &Path, names: &[&str]) {
|
||||
let books = dir.join("books-data");
|
||||
fn old_classes(dir: &TestDir, names: &[&str]) {
|
||||
let books = dir.books_data();
|
||||
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
|
||||
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
@@ -2321,23 +2177,37 @@ fn jsonrpc_unknown_method_is_32601() {
|
||||
m.ok("books.chart", json!({}));
|
||||
}
|
||||
|
||||
/// jsonrpc-2.0.md §5 and §5.1: invalid JSON → a response with code
|
||||
/// -32700 and id null; the server keeps serving.
|
||||
/// jsonrpc-2.0.md §5 and §5.1: invalid JSON is refused, never run, and
|
||||
/// the server keeps serving. Over the server's Streamable HTTP door
|
||||
/// (mcp-transports: "the server MUST return an HTTP error status code";
|
||||
/// its body MAY be a JSON-RPC error with no id) that is a 4xx, and any
|
||||
/// error it carries is -32700 with id null. (An agent's malformed line is
|
||||
/// answered -32700 by its bridge: ledger-host bridge.rs,
|
||||
/// the_agents_input_is_read_message_by_message.)
|
||||
#[test]
|
||||
fn jsonrpc_parse_error_is_32700_with_null_id() {
|
||||
fn jsonrpc_parse_error_is_refused_with_32700() {
|
||||
let dir = test_dir("rpc-parse");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.send_line("{\"jsonrpc\": \"2.0\", \"id\": 99, \"method\": ");
|
||||
let resp = m.recv(Duration::from_secs(5));
|
||||
let resp = resp.expect("no response to a malformed JSON line");
|
||||
check_response_shape(&resp);
|
||||
assert_eq!(resp["error"]["code"], -32700, "{resp}");
|
||||
assert_eq!(resp["id"], Value::Null, "{resp}");
|
||||
let status = m.wire.status;
|
||||
assert!(
|
||||
(400..500).contains(&status),
|
||||
"{status} {:?}",
|
||||
m.wire.refused
|
||||
);
|
||||
if let Some(resp) = m.recv(Duration::from_secs(5)) {
|
||||
check_response_shape(&resp);
|
||||
assert_eq!(resp["error"]["code"], -32700, "{resp}");
|
||||
assert_eq!(resp["id"], Value::Null, "{resp}");
|
||||
}
|
||||
m.ok("books.chart", json!({}));
|
||||
}
|
||||
|
||||
/// jsonrpc-2.0.md §5: `id` is REQUIRED in every response; when it can't
|
||||
/// be detected (Invalid Request) it MUST be null, else the request's.
|
||||
/// jsonrpc-2.0.md §5: an Invalid Request is refused; an answer to it
|
||||
/// carries an `id`, the request's when it can be read, else null. Over the
|
||||
/// server's door (as above) that is a 4xx, with any error it carries
|
||||
/// -32600.
|
||||
#[test]
|
||||
fn jsonrpc_invalid_request_response_has_id() {
|
||||
let dir = test_dir("rpc-invalid");
|
||||
@@ -2348,15 +2218,23 @@ fn jsonrpc_invalid_request_response_has_id() {
|
||||
("{\"jsonrpc\": \"2.0\", \"id\": 98}", json!(98)),
|
||||
] {
|
||||
m.send_line(junk);
|
||||
let resp = m.recv(Duration::from_secs(5)).expect("no response");
|
||||
assert_eq!(resp["error"]["code"], -32600, "{resp}");
|
||||
let status = m.wire.status;
|
||||
assert!(
|
||||
resp.get("id").is_some(),
|
||||
"{junk}: response has no id: {resp}"
|
||||
(400..500).contains(&status),
|
||||
"{junk}: {status} {:?}",
|
||||
m.wire.refused
|
||||
);
|
||||
// A detectable id should be echoed; Null when it isn't.
|
||||
assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}");
|
||||
if let Some(resp) = m.recv(Duration::from_secs(5)) {
|
||||
assert_eq!(resp["error"]["code"], -32600, "{resp}");
|
||||
assert!(
|
||||
resp.get("id").is_some(),
|
||||
"{junk}: response has no id: {resp}"
|
||||
);
|
||||
// A detectable id should be echoed; Null when it isn't.
|
||||
assert!(resp["id"] == id || resp["id"].is_null(), "{junk}: {resp}");
|
||||
}
|
||||
}
|
||||
m.ok("books.chart", json!({}));
|
||||
}
|
||||
|
||||
/// Whatever the server does with a malformed line, it keeps serving
|
||||
@@ -2431,36 +2309,32 @@ fn helpers_self_check() {
|
||||
assert_eq!(bad.len(), 2, "{bad:?}");
|
||||
}
|
||||
|
||||
// ------------------------------------- settings reload (settings.rs spec)
|
||||
// ------------------------------------------- settings (the books keep them)
|
||||
|
||||
/// INVARIANT 1: a call after an edit sees it, with no restart, and two
|
||||
/// same-size edits in a row are both seen (compared by content).
|
||||
/// A setting changed (books.set_setting) is read by the next call, with no
|
||||
/// restart, and two changes in a row are both seen.
|
||||
#[test]
|
||||
fn settings_reload_on_the_next_call() {
|
||||
let dir = test_dir("reload");
|
||||
fn a_changed_setting_is_read_on_the_next_call() {
|
||||
let dir = test_dir("reread");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
let s = m.ok("books.settings", json!({}));
|
||||
assert_eq!(s["company"], "Spec Co");
|
||||
assert_eq!(s["loads"], 1);
|
||||
for name in ["Aaaa Co", "Bbbb Co"] {
|
||||
let text = std::fs::read_to_string(&toml)
|
||||
.unwrap()
|
||||
.replace("Spec Co", name)
|
||||
.replace("Aaaa Co", name);
|
||||
std::fs::write(&toml, text).unwrap();
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.name", "value": name}],
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
let s = m.ok("books.settings", json!({}));
|
||||
assert_eq!(s["company"], name);
|
||||
assert_eq!(s["reload_error"], Value::Null);
|
||||
}
|
||||
assert_eq!(m.ok("books.settings", json!({}))["loads"], 3);
|
||||
}
|
||||
|
||||
/// INVARIANT 1, enforced: a lowered receipt threshold refuses the next
|
||||
/// post without a receipt.
|
||||
/// A lowered receipt threshold refuses the next post without a receipt.
|
||||
#[test]
|
||||
fn a_reloaded_threshold_is_enforced() {
|
||||
let dir = test_dir("reload-threshold");
|
||||
fn a_lowered_threshold_is_enforced() {
|
||||
let dir = test_dir("lowered-threshold");
|
||||
let toml = books_toml(&dir, "1000.00", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
@@ -2470,10 +2344,11 @@ fn a_reloaded_threshold_is_enforced() {
|
||||
&[("6000", "debit", "1.00"), ("1000", "credit", "1.00")],
|
||||
);
|
||||
m.post(first).ok("under the threshold");
|
||||
let text = std::fs::read_to_string(&toml)
|
||||
.unwrap()
|
||||
.replace("\"1000.00\"", "\"0.00\"");
|
||||
std::fs::write(&toml, text).unwrap();
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.evidence.threshold", "value": "0.00"}],
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
let second = m.draft(
|
||||
"2026-03-02",
|
||||
"small",
|
||||
@@ -2484,46 +2359,6 @@ fn a_reloaded_threshold_is_enforced() {
|
||||
assert_eq!(m.entry_status(second), "draft");
|
||||
}
|
||||
|
||||
/// INVARIANTS 2 and 3: a broken edit, and a named file gone missing, keep
|
||||
/// the last good settings and say why; fixing it loads again.
|
||||
#[test]
|
||||
fn a_broken_edit_keeps_the_last_good_settings() {
|
||||
let dir = test_dir("reload-broken");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
let good = std::fs::read_to_string(&toml).unwrap();
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
std::fs::write(
|
||||
&toml,
|
||||
good.replace("Spec Co", "Next Co")
|
||||
.replace("[books]", "[books"),
|
||||
)
|
||||
.unwrap();
|
||||
let s = m.ok("books.settings", json!({}));
|
||||
assert_eq!(s["company"], "Spec Co");
|
||||
assert!(s["reload_error"].is_string(), "{s}");
|
||||
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||
assert!(
|
||||
digest["settings_problems"][0]
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.contains("didn't load"),
|
||||
"{digest}"
|
||||
);
|
||||
std::fs::remove_file(&toml).unwrap();
|
||||
let s = m.ok("books.settings", json!({}));
|
||||
assert!(
|
||||
s["reload_error"].is_string(),
|
||||
"a named file gone missing: {s}"
|
||||
);
|
||||
assert_eq!(s["books_toml_exists"], false);
|
||||
assert_eq!(s["company"], "Spec Co");
|
||||
std::fs::write(&toml, good.replace("Spec Co", "Fixed Co")).unwrap();
|
||||
let s = m.ok("books.settings", json!({}));
|
||||
assert_eq!(s["company"], "Fixed Co");
|
||||
assert_eq!(s["reload_error"], Value::Null);
|
||||
assert_eq!(s["loads"], 2);
|
||||
}
|
||||
|
||||
/// books.settings and the digest list settings that disagree with the
|
||||
/// chart, each naming the setting.
|
||||
#[test]
|
||||
@@ -2545,27 +2380,31 @@ fn settings_that_disagree_with_the_chart_are_problems() {
|
||||
.collect();
|
||||
assert_eq!(problems.len(), 1, "{problems:?}");
|
||||
assert!(
|
||||
problems[0].contains("[books] financing_accounts lists 6000"),
|
||||
problems[0].contains("books.financing_accounts lists 6000"),
|
||||
"{problems:?}"
|
||||
);
|
||||
assert!(problems[0].ends_with("remove it from [books] financing_accounts in the settings"));
|
||||
let text = std::fs::read_to_string(&toml)
|
||||
.unwrap()
|
||||
.replace("cash_accounts = [\"1000\"]", "cash_accounts = [\"1010\"]")
|
||||
.replace("close_into = \"3000\"", "close_into = \"4000\"");
|
||||
std::fs::write(&toml, text).unwrap();
|
||||
assert!(
|
||||
problems[0].ends_with("remove it from books.financing_accounts with books.set_setting"),
|
||||
"{problems:?}"
|
||||
);
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "books.cash_accounts", "value": ["1010"]},
|
||||
{"key": "books.close_into", "value": "4000"}],
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
let digest = m.ok("books.digest", json!({"since": "2026-01-01"}));
|
||||
let problems = digest["settings_problems"].to_string();
|
||||
assert!(
|
||||
problems.contains("[books] cash_accounts names 1010, which isn't in the chart"),
|
||||
problems.contains("books.cash_accounts names 1010, which isn't in the chart"),
|
||||
"{problems}"
|
||||
);
|
||||
assert!(
|
||||
problems.contains("[books] close_into names 4000"),
|
||||
problems.contains("books.close_into names 4000"),
|
||||
"{problems}"
|
||||
);
|
||||
assert!(
|
||||
problems.contains("[books] financing_accounts lists 6000"),
|
||||
problems.contains("books.financing_accounts lists 6000"),
|
||||
"{problems}"
|
||||
);
|
||||
}
|
||||
+27
-106
@@ -1,5 +1,6 @@
|
||||
//! Spec-blind tests for the party directory tools of `ledger-mcp`, driven
|
||||
//! over stdio against the real binary.
|
||||
//! Spec-blind tests for the party directory tools of the books server,
|
||||
//! driven through a real `wanderbooks server` as an agent reaches it
|
||||
//! (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
|
||||
@@ -10,16 +11,15 @@
|
||||
//! `journal.jsonl` byte-identical (server invariant 2: a refused call
|
||||
//! changes nothing).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -44,57 +44,20 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-parties-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
@@ -113,38 +76,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
@@ -191,24 +129,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -1721,22 +1646,18 @@ fn a_posted_derived_split_survives_settings_changes_and_archiving() {
|
||||
both(),
|
||||
&[("2100", "debit", "100.00"), ("1000", "credit", "100.00")],
|
||||
);
|
||||
// The members' split changes to 60/40.
|
||||
let toml = m.data.parent().unwrap().join("wanderbooks.toml");
|
||||
// The members' split changes to 60/40, as an agent changes it.
|
||||
let toml = m.books_dir.as_ref().unwrap().join("wanderbooks.toml");
|
||||
let text = std::fs::read_to_string(&toml).unwrap();
|
||||
let changed = text
|
||||
.replacen("share = \"50%\"", "share = \"60%\"", 1)
|
||||
.replacen("share = \"50%\"", "share = \"40%\"", 1);
|
||||
std::fs::write(&toml, changed).unwrap();
|
||||
// The server reloads it on change: wait until the draft reads 60/40.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
let d = loop {
|
||||
let d = m.ok("books.post_entry", json!({"entry_id": draft}));
|
||||
if resolved(&d)[0].1 == "60.00" || std::time::Instant::now() > deadline {
|
||||
break d;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
};
|
||||
m.ok(
|
||||
"books.set_setting",
|
||||
json!({"changes": common::settings_changes(&changed, None, "USD"),
|
||||
"decided_by": "Test Owner"}),
|
||||
);
|
||||
let d = m.ok("books.post_entry", json!({"entry_id": draft}));
|
||||
assert_eq!(
|
||||
resolved(&d),
|
||||
[
|
||||
+25
-99
@@ -1,8 +1,9 @@
|
||||
//! P3 defects 14 and 24 through the MCP, over stdio against the real
|
||||
//! binary: each party's part of an entry (`books.get_entry` `resolved`,
|
||||
//! `direction`, `cash`, `in_kind`), party totals on a basis
|
||||
//! (`books.party`, `books.parties` `basis`), and a party named the
|
||||
//! creditor of a liability (`books.update_party` `creditor_of`).
|
||||
//! P3 defects 14 and 24 through the MCP, through a real books server as
|
||||
//! an agent reaches it (common.rs): each party's part of an entry
|
||||
//! (`books.get_entry` `resolved`, `direction`, `cash`, `in_kind`), party
|
||||
//! totals on a basis (`books.party`, `books.parties` `basis`), and a
|
||||
//! party named the creditor of a liability (`books.update_party`
|
||||
//! `creditor_of`).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (as
|
||||
@@ -11,16 +12,15 @@
|
||||
//! every success validates against the tool's `outputSchema`, and every
|
||||
//! refusal leaves `journal.jsonl` byte-identical.
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -45,57 +45,20 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-party-totals-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
@@ -114,38 +77,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
@@ -192,24 +130,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -371,7 +296,7 @@ fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
&dir.books_data(),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
@@ -404,7 +329,8 @@ impl Mcp {
|
||||
fn upgraded(mut self) -> Mcp {
|
||||
let dir = self.books_dir.take().expect("books_dir");
|
||||
drop(self);
|
||||
ledger_store::Store::open(&dir.join("books-data"))
|
||||
dir.stop();
|
||||
ledger_store::Store::open(&dir.books_data())
|
||||
.unwrap()
|
||||
.upgrade_format()
|
||||
.unwrap()
|
||||
+23
-102
@@ -1,5 +1,6 @@
|
||||
//! Spec-blind tests for the project tools of `ledger-mcp` (rules.md
|
||||
//! B-37), driven over stdio against the real binary.
|
||||
//! Spec-blind tests for the project tools of the books server (rules.md
|
||||
//! B-37), driven through a real `wanderbooks server` as an agent reaches
|
||||
//! it (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs` (the declared
|
||||
@@ -10,16 +11,15 @@
|
||||
//! `journal.jsonl` byte-identical (server invariant 2: a refused call
|
||||
//! changes nothing).
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -44,57 +44,20 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-projects-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The
|
||||
@@ -113,38 +76,13 @@ fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
};
|
||||
let init = mcp.rpc(
|
||||
@@ -191,24 +129,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -330,7 +255,7 @@ fn books_in(name: &str, p2: bool) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
if p2 {
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
&dir.books_data(),
|
||||
ledger_core::Currency::USD,
|
||||
&ledger_store::JournalFormat {
|
||||
format: 2,
|
||||
@@ -1132,7 +1057,7 @@ fn a_name_resolves_as_the_duplicate_check_compares() {
|
||||
/// checks names): no uniqueness, digits allowed.
|
||||
fn old_classes(name: &str, names: &[&str]) -> Mcp {
|
||||
let dir = test_dir(name);
|
||||
let books = dir.join("books-data");
|
||||
let books = dir.books_data();
|
||||
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
|
||||
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
@@ -1212,12 +1137,8 @@ fn books_before_document_projects(name: &str) -> Mcp {
|
||||
format
|
||||
.features
|
||||
.retain(|f| f != ledger_store::format::DOCUMENT_LINE_PROJECTS);
|
||||
ledger_store::Store::create_in_format(
|
||||
&dir.join("books-data"),
|
||||
ledger_core::Currency::USD,
|
||||
&format,
|
||||
)
|
||||
.unwrap();
|
||||
ledger_store::Store::create_in_format(&dir.books_data(), ledger_core::Currency::USD, &format)
|
||||
.unwrap();
|
||||
let toml = books_toml(&dir, "Spec Co", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
+24
-62
@@ -1,5 +1,6 @@
|
||||
//! Tax categories at account creation, and `policy.require_tax_category`
|
||||
//! (P3, defect 8; rules.md B-32), through the real server over stdio.
|
||||
//! (P3, defect 8; rules.md B-32), through a real books server as an
|
||||
//! agent reaches it (common.rs).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every revenue and expense account maps to a line of the return,
|
||||
@@ -12,51 +13,30 @@
|
||||
//!
|
||||
//! Fixtures are fictional (Fernhill Labs LLC).
|
||||
|
||||
use common::{Books, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
mod common;
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
child: Child,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// A server on books that keep their own settings, initialized.
|
||||
fn spawn(data_dir: &std::path::Path) -> Self {
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(data_dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
/// A session with books that keep their own settings, initialized.
|
||||
fn spawn(books: &Books) -> Self {
|
||||
let mut m = Self {
|
||||
stdin,
|
||||
stdout,
|
||||
wire: books.wire(),
|
||||
next_id: 1,
|
||||
child,
|
||||
};
|
||||
m.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "spec-tax-policy", "version": "0"}}),
|
||||
);
|
||||
let msg = json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}});
|
||||
writeln!(m.stdin, "{msg}").unwrap();
|
||||
m.stdin.flush().unwrap();
|
||||
m.wire
|
||||
.send(&json!({"jsonrpc": "2.0", "method": "notifications/initialized", "params": {}}));
|
||||
m
|
||||
}
|
||||
|
||||
@@ -64,13 +44,11 @@ impl Mcp {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
writeln!(self.stdin, "{msg}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
let mut line = String::new();
|
||||
self.stdout
|
||||
.read_line(&mut line)
|
||||
.expect("read response line");
|
||||
let resp: Value = serde_json::from_str(&line).expect("parse response");
|
||||
self.wire.send(&msg);
|
||||
let resp = self
|
||||
.wire
|
||||
.recv(common::TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
assert!(resp.get("error").is_none(), "{method}: {}", resp["error"]);
|
||||
resp
|
||||
@@ -121,22 +99,6 @@ impl Mcp {
|
||||
}
|
||||
}
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-mcp-tax-{}-{name}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn add(m: &mut Mcp, args: Value) -> Value {
|
||||
m.call("books.add_account", args)
|
||||
}
|
||||
@@ -158,8 +120,8 @@ fn chart_codes(m: &mut Mcp) -> Vec<String> {
|
||||
/// a category.
|
||||
#[test]
|
||||
fn add_account_warns_without_tax_category_defect_8() {
|
||||
let dir = test_dir("warn");
|
||||
let mut m = Mcp::spawn(&dir.0.join("books-data"));
|
||||
let books = Books::new("warn");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let out = add(
|
||||
&mut m,
|
||||
json!({"code": "6150", "name": "Software", "account_type": "expense"}),
|
||||
@@ -238,8 +200,8 @@ fn add_account_warns_without_tax_category_defect_8() {
|
||||
/// warning.
|
||||
#[test]
|
||||
fn require_tax_category_refuses_defect_8() {
|
||||
let dir = test_dir("refuse");
|
||||
let mut m = Mcp::spawn(&dir.0.join("books-data"));
|
||||
let books = Books::new("refuse");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
add(
|
||||
&mut m,
|
||||
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
|
||||
@@ -349,8 +311,8 @@ fn require_tax_category_refuses_defect_8() {
|
||||
/// free.
|
||||
#[test]
|
||||
fn a_new_account_named_like_a_sibling_is_refused() {
|
||||
let dir = test_dir("names");
|
||||
let mut m = Mcp::spawn(&dir.0.join("books-data"));
|
||||
let books = Books::new("names");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
add(
|
||||
&mut m,
|
||||
json!({"code": "6000", "name": "Operating", "account_type": "expense", "placeholder": true}),
|
||||
@@ -399,8 +361,8 @@ fn a_new_account_named_like_a_sibling_is_refused() {
|
||||
/// summary could place).
|
||||
#[test]
|
||||
fn a_gain_on_business_property_is_exported_apart() {
|
||||
let dir = test_dir("form4797");
|
||||
let mut m = Mcp::spawn(&dir.0.join("books-data"));
|
||||
let books = Books::new("form4797");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
for args in [
|
||||
json!({"code": "1000", "name": "Business Checking", "account_type": "asset"}),
|
||||
json!({"code": "4000", "name": "Sales", "account_type": "revenue", "tax_category": "gross_receipts"}),
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Spec-blind tests for the TIN vault tools of `ledger-mcp`, driven over
|
||||
//! stdio against the real binary.
|
||||
//! Spec-blind tests for the TIN vault tools of the books server, driven
|
||||
//! through a real `wanderbooks server` as an agent reaches it (common.rs).
|
||||
//!
|
||||
//! Expected behavior comes only from the tool descriptions and parameter /
|
||||
//! result doc comments in `src/server.rs` and `src/types.rs`,
|
||||
@@ -10,22 +10,21 @@
|
||||
//! worksheet for the CPA when asked (`books.vendor_1099` with
|
||||
//! `include_tins`); and the vault holds everyone's, members included, for
|
||||
//! the CPA package, which carries them only in the zip it hands over,
|
||||
//! never kept in the books dir (DECISION, owner, 2026-10-15: "In the vault, that's the
|
||||
//! never kept in the books' folder but as the file handed over (DECISION, owner, 2026-10-15: "In the vault, that's the
|
||||
//! WHOLE reason we built it. And it needs to be in the damn CPA
|
||||
//! package."). The harness is `tests/spec_parties.rs`'s, with a
|
||||
//! transcript of every response and the server's stderr kept, and the vault
|
||||
//! key in a temp file outside the books dir (never `~/.config`).
|
||||
//! transcript of every response and the server's stderr kept; the vault's
|
||||
//! key is the book's own `keys/legacy.key` in the test's data root.
|
||||
|
||||
use common::{Place, Wire};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(180);
|
||||
mod common;
|
||||
|
||||
const TIMEOUT: Duration = common::TIMEOUT;
|
||||
|
||||
// ---------------------------------------------------------------- harness
|
||||
|
||||
@@ -50,71 +49,17 @@ impl Out {
|
||||
}
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
rx: Receiver<String>,
|
||||
child: Child,
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
data: PathBuf,
|
||||
validators: HashMap<String, jsonschema::Validator>,
|
||||
/// Every response line the server wrote, with the tool it answered.
|
||||
transcript: Vec<(String, String)>,
|
||||
/// The books dir when the server owns it: removed after the server
|
||||
/// stops (fields drop after `Drop::drop`).
|
||||
/// The test's place, when the session owns it (its server is closed
|
||||
/// and its home removed after the session).
|
||||
books_dir: Option<TestDir>,
|
||||
}
|
||||
|
||||
impl Drop for Mcp {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.child.kill();
|
||||
let _ = self.child.wait();
|
||||
}
|
||||
}
|
||||
|
||||
static DIR_SEQ: AtomicU64 = AtomicU64::new(0);
|
||||
|
||||
/// A test's dir and the folder beside it (`-outside`),
|
||||
/// removed when the test ends, pass or panic.
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
let _ = std::fs::remove_dir_all(format!("{}-outside", self.0.display()));
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed);
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"ledger-mcp-spec-vault-{}-{name}-{n}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
/// A directory beside the books dir `dir`, outside it: the vault key,
|
||||
/// files the tests hand the server, files it writes, its stderr.
|
||||
fn outside(dir: &Path) -> PathBuf {
|
||||
let d = PathBuf::from(format!("{}-outside", dir.display()));
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
/// `bytes` as a tool takes them inline: base64.
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
use base64::Engine as _;
|
||||
@@ -126,17 +71,22 @@ fn sha(bytes: &[u8]) -> String {
|
||||
ledger_store::journal::sha256_hex(bytes)
|
||||
}
|
||||
|
||||
/// A test's place (common.rs): its home, its server, its books.
|
||||
type TestDir = Place;
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
Place::new(name)
|
||||
}
|
||||
|
||||
/// `wanderbooks.toml` with a company name, the receipt threshold, and
|
||||
/// extra `[books]` TOML; the vault key file is outside the books dir.
|
||||
/// extra `[books]` TOML.
|
||||
fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf {
|
||||
let path = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
outside(dir).join("vault.key").display()
|
||||
[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n"
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
@@ -144,59 +94,13 @@ fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
fn start(dir: &Path, toml: &Path) -> Self {
|
||||
let run = dir.join("run");
|
||||
std::fs::create_dir_all(&run).unwrap();
|
||||
let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(toml)
|
||||
// A settings file naming the usual key outside the books: the
|
||||
// command line names it too, as books that move their settings
|
||||
// in must (a test naming another key keeps only the file's).
|
||||
.args({
|
||||
let key = outside(dir).join("vault.key");
|
||||
std::fs::read_to_string(toml)
|
||||
.is_ok_and(|t| t.contains(&format!("key_file = \"{}\"", key.display())))
|
||||
.then(|| {
|
||||
[
|
||||
std::ffi::OsString::from("--vault-key-file"),
|
||||
key.into_os_string(),
|
||||
]
|
||||
})
|
||||
.into_iter()
|
||||
.flatten()
|
||||
})
|
||||
.env("XDG_RUNTIME_DIR", &run)
|
||||
// Never the user's real vault key (the default key file is
|
||||
// under XDG_CONFIG_HOME); the tests name theirs anyway.
|
||||
.env("XDG_CONFIG_HOME", outside(dir).join("config"))
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::from(
|
||||
std::fs::File::create(outside(dir).join("stderr.log")).unwrap(),
|
||||
))
|
||||
.spawn()
|
||||
.expect("spawn ledger-mcp");
|
||||
let stdin = child.stdin.take().unwrap();
|
||||
let stdout = BufReader::new(child.stdout.take().unwrap());
|
||||
let (tx, rx) = channel();
|
||||
std::thread::spawn(move || {
|
||||
for line in stdout.lines() {
|
||||
let Ok(line) = line else { break };
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
fn start(dir: &TestDir, toml: &Path) -> Self {
|
||||
let wire = dir.start(Some(&std::fs::read_to_string(toml).unwrap()));
|
||||
let mut mcp = Self {
|
||||
books_dir: None,
|
||||
stdin,
|
||||
rx,
|
||||
child,
|
||||
wire,
|
||||
next_id: 1,
|
||||
data: dir.join("books-data"),
|
||||
data: dir.books_data(),
|
||||
validators: HashMap::new(),
|
||||
transcript: Vec::new(),
|
||||
};
|
||||
@@ -244,24 +148,11 @@ impl Mcp {
|
||||
}
|
||||
|
||||
fn send_line(&mut self, line: &str) {
|
||||
writeln!(self.stdin, "{line}").unwrap();
|
||||
self.stdin.flush().unwrap();
|
||||
self.wire.send_line(line);
|
||||
}
|
||||
|
||||
fn recv(&mut self) -> Option<Value> {
|
||||
loop {
|
||||
match self.rx.recv_timeout(TIMEOUT) {
|
||||
Ok(line) => {
|
||||
let v: Value = serde_json::from_str(&line)
|
||||
.unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}"));
|
||||
if v.get("id").is_none() && v.get("method").is_some() {
|
||||
continue;
|
||||
}
|
||||
return Some(v);
|
||||
}
|
||||
Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None,
|
||||
}
|
||||
}
|
||||
self.wire.recv(TIMEOUT)
|
||||
}
|
||||
|
||||
fn rpc(&mut self, method: &str, params: Value) -> Value {
|
||||
@@ -456,57 +347,36 @@ fn unzip(bytes: &[u8]) -> HashMap<String, Vec<u8>> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`, D-29): over
|
||||
/// stdio they come inline, with no download command, and match the size
|
||||
/// and SHA-256 it states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine as _;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
assert!(file.get("expires_at").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}");
|
||||
bytes
|
||||
}
|
||||
|
||||
/// `v` with every file's inline bytes (`content_base64`) taken out, each
|
||||
/// pushed to `files` decoded, with its `file_name`: what a tool said, apart
|
||||
/// from the files it handed over.
|
||||
fn take_files(v: &Value, files: &mut Vec<(String, Vec<u8>)>) -> Value {
|
||||
use base64::Engine as _;
|
||||
/// Every file a tool handed over in `v` (a `HandedFile`: an object with
|
||||
/// its `download`), with its `file_name`, fetched by its download.
|
||||
fn take_files(m: &mut Mcp, v: &Value, files: &mut Vec<(String, Vec<u8>)>) {
|
||||
match v {
|
||||
Value::Object(o) => {
|
||||
if let Some(b) = o.get("content_base64").and_then(Value::as_str) {
|
||||
if o.get("download").is_some_and(Value::is_string) {
|
||||
let name = o["file_name"].as_str().unwrap_or_default().to_string();
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(b)
|
||||
.unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
files.push((name, bytes));
|
||||
files.push((name, m.handed(v)));
|
||||
}
|
||||
for v in o.values() {
|
||||
take_files(m, v, files);
|
||||
}
|
||||
Value::Object(
|
||||
o.iter()
|
||||
.filter(|(k, _)| k.as_str() != "content_base64")
|
||||
.map(|(k, v)| (k.clone(), take_files(v, files)))
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
Value::Array(a) => Value::Array(a.iter().map(|v| take_files(v, files)).collect()),
|
||||
other => other.clone(),
|
||||
Value::Array(a) => {
|
||||
for v in a {
|
||||
take_files(m, v, files);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// `v` without the bytes of the files it hands over.
|
||||
fn said(v: &Value) -> String {
|
||||
take_files(v, &mut Vec::new()).to_string()
|
||||
}
|
||||
|
||||
/// The paths of every file under `dir` but the vault's: what a tool
|
||||
/// wrote, compared before and after.
|
||||
/// The paths of every file under `dir` but the vault's, and but what
|
||||
/// every call writes by design (common.rs `book_files`: the access log,
|
||||
/// the viewer's side channel, the books' own backup, the files handed over
|
||||
/// for their download): what a tool wrote, compared before and after.
|
||||
fn paths_outside_vault(m: &Mcp, dir: &Path) -> std::collections::BTreeSet<PathBuf> {
|
||||
files_under(dir)
|
||||
.into_iter()
|
||||
common::book_files(dir)
|
||||
.into_keys()
|
||||
.map(|f| dir.join(f))
|
||||
.filter(|f| !f.starts_with(m.vault()))
|
||||
.collect()
|
||||
}
|
||||
@@ -544,17 +414,43 @@ fn csv_rows(bytes: &[u8]) -> Vec<HashMap<String, String>> {
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The books' folder in the data root.
|
||||
fn books_dir(&self) -> PathBuf {
|
||||
self.data.parent().unwrap().to_path_buf()
|
||||
self.data.clone()
|
||||
}
|
||||
fn outside(&self) -> PathBuf {
|
||||
outside(&self.books_dir())
|
||||
/// Every file in the test's home outside the data root but the
|
||||
/// server's log: what the server must never write.
|
||||
fn outside_files(&self) -> Vec<PathBuf> {
|
||||
let home = self.books_dir.as_ref().expect("the place");
|
||||
let root = home.home.root();
|
||||
let log = home.join("server.log");
|
||||
files_under(home)
|
||||
.into_iter()
|
||||
.filter(|f| !f.starts_with(&root) && *f != log)
|
||||
.collect()
|
||||
}
|
||||
/// The server's log (its stderr).
|
||||
fn server_log(&self) -> Vec<u8> {
|
||||
std::fs::read(
|
||||
self.books_dir
|
||||
.as_ref()
|
||||
.expect("the place")
|
||||
.join("server.log"),
|
||||
)
|
||||
.unwrap_or_default()
|
||||
}
|
||||
fn vault(&self) -> PathBuf {
|
||||
self.books_dir().join("vault")
|
||||
}
|
||||
/// The vault's key: in the data root, the book's own.
|
||||
fn key(&self) -> PathBuf {
|
||||
self.outside().join("vault.key")
|
||||
self.books_dir().join("keys").join("legacy.key")
|
||||
}
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`, D-29):
|
||||
/// fetched by its download, matching the size and SHA-256 it states.
|
||||
fn handed(&mut self, file: &Value) -> Vec<u8> {
|
||||
assert!(file["expires_at"].is_string(), "{file}");
|
||||
self.wire.fetch(file)
|
||||
}
|
||||
fn party_id(&mut self, name: &str) -> u64 {
|
||||
self.party(name, 2026)["party"]["party_id"]
|
||||
@@ -625,6 +521,41 @@ fn files_under(dir: &Path) -> Vec<PathBuf> {
|
||||
out
|
||||
}
|
||||
|
||||
/// The files a test was handed, in the books' `exports/` (D-29): every
|
||||
/// export id the session's answers named (`ex_` and 32 letters `a` to
|
||||
/// `p`, in each handed file's `download`). Checks that nothing else sits
|
||||
/// there but each one's bytes and its record (`.bin`, `.json`), and
|
||||
/// returns the bytes' paths: what a scan of the books' folder may skip,
|
||||
/// their contents having been checked through their downloads. The
|
||||
/// records are scanned like any other file.
|
||||
fn handed_exports(m: &Mcp) -> Vec<PathBuf> {
|
||||
let mut ids = std::collections::BTreeSet::new();
|
||||
for (_, resp) in &m.transcript {
|
||||
let mut rest = resp.as_str();
|
||||
while let Some(at) = rest.find("ex_") {
|
||||
let id: String = rest[at..].chars().take(35).collect();
|
||||
if ledger_store::exports::is_id(&id) {
|
||||
ids.insert(id);
|
||||
}
|
||||
rest = &rest[at + 3..];
|
||||
}
|
||||
}
|
||||
assert!(!ids.is_empty(), "the test was handed no file");
|
||||
let dir = m.books_dir().join(ledger_store::exports::EXPORTS_DIR);
|
||||
let mut bins = Vec::new();
|
||||
for f in files_under(&dir) {
|
||||
let name = f.file_name().unwrap().to_string_lossy().into_owned();
|
||||
let handed = ids
|
||||
.iter()
|
||||
.find(|id| name == format!("{id}.bin") || name == format!("{id}.json"));
|
||||
assert!(handed.is_some(), "{name} in exports/ wasn't handed over");
|
||||
if name.ends_with(".bin") {
|
||||
bins.push(f);
|
||||
}
|
||||
}
|
||||
bins
|
||||
}
|
||||
|
||||
// ------------------------------------------------- books.set_w9 with a TIN
|
||||
|
||||
/// The TIN goes to the vault (the key created on first use, owner-only),
|
||||
@@ -748,7 +679,7 @@ fn set_w9_refusals_write_nothing_anywhere() {
|
||||
json!({"w9_content_base64": long, "w9_sha256": sha(&big)}),
|
||||
"inline is at most",
|
||||
),
|
||||
(json!({"w9_upload_id": "u1"}), "takes files inline only"),
|
||||
(json!({"w9_upload_id": "u1"}), "there is no upload \"u1\""),
|
||||
(
|
||||
json!({"w9_upload_id": "u1", "w9_content_base64": b64(&pdf), "w9_sha256": sha(&pdf)}),
|
||||
"not both",
|
||||
@@ -839,21 +770,19 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() {
|
||||
docs["receipts"].as_array().unwrap().is_empty(),
|
||||
"not a document: {docs}"
|
||||
);
|
||||
// Read back: the bytes, handed over (inline over stdio), named for
|
||||
// Read back: the bytes, handed over by their download, named for
|
||||
// their type; nothing written anywhere, and the output shows no TIN
|
||||
// but in the file itself.
|
||||
let (books_before, outside_before) = (
|
||||
paths_outside_vault(&m, &m.books_dir()),
|
||||
files_under(&m.outside()),
|
||||
);
|
||||
let (books_before, outside_before) =
|
||||
(paths_outside_vault(&m, &m.books_dir()), m.outside_files());
|
||||
let out = m.ok("books.get_w9", json!({"party": "Acme Design"}));
|
||||
assert_eq!(out["party_id"], json!(acme));
|
||||
assert_eq!(out["file"]["file_name"], "w9.pdf");
|
||||
assert_eq!(out["file"]["mime_type"], "application/pdf");
|
||||
assert_eq!(handed(&out["file"]), src);
|
||||
assert!(!holds_tin(said(&out).as_bytes(), SSN_DIGITS), "{out}");
|
||||
assert_eq!(m.handed(&out["file"]), src);
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS), "{out}");
|
||||
assert_eq!(paths_outside_vault(&m, &m.books_dir()), books_before);
|
||||
assert_eq!(files_under(&m.outside()), outside_before);
|
||||
assert_eq!(m.outside_files(), outside_before);
|
||||
// A party with no W-9 file in the vault.
|
||||
m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"}));
|
||||
let why = m.refuse("books.get_w9", json!({"party": "Rent Co"}));
|
||||
@@ -896,7 +825,7 @@ fn every_w9_file_type_is_taken() {
|
||||
assert_eq!(p["w9"]["in_vault"], true, "{name}");
|
||||
let out = m.ok("books.get_w9", json!({"party": party}));
|
||||
assert_eq!(out["file"]["file_name"], *name);
|
||||
assert_eq!(handed(&out["file"]), *bytes);
|
||||
assert_eq!(m.handed(&out["file"]), *bytes);
|
||||
}
|
||||
// Its own name, when given; given alone, a name is refused.
|
||||
m.add_party("Payee N", &["contractor"]);
|
||||
@@ -910,7 +839,7 @@ fn every_w9_file_type_is_taken() {
|
||||
};
|
||||
let out = m.ok("books.get_w9", json!({"party": "Payee N"}));
|
||||
assert_eq!(out["file"]["file_name"], "payee-n-w9.pdf");
|
||||
assert_eq!(handed(&out["file"]), bytes);
|
||||
assert_eq!(m.handed(&out["file"]), bytes);
|
||||
let why = m
|
||||
.w9(
|
||||
"Payee N",
|
||||
@@ -985,30 +914,6 @@ fn a_locked_vault_says_so_and_never_guesses() {
|
||||
assert_eq!(v["vendors"][0]["tin"], SSN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_key_file_inside_the_books_is_refused() {
|
||||
let dir = test_dir("key-inside");
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!(
|
||||
"[company]\nname = \"Spec Co\"\n[books]\nreceipt_threshold = \"1000000.00\"\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
dir.join("vault.key").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(CHART);
|
||||
m.add_party("Acme Design", &["contractor"]);
|
||||
let why = m
|
||||
.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"}))
|
||||
.err_text();
|
||||
assert!(why.contains("inside the books' folder"), "{why}");
|
||||
assert!(!dir.join("vault.key").exists());
|
||||
assert!(!dir.join("vault").exists());
|
||||
}
|
||||
|
||||
// ------------------------------------------------- books.vendor_1099
|
||||
|
||||
#[test]
|
||||
@@ -1070,7 +975,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
);
|
||||
assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv");
|
||||
assert_eq!(out["csv"]["mime_type"], "text/csv");
|
||||
let text = String::from_utf8(handed(&out["csv"])).unwrap();
|
||||
let text = String::from_utf8(m.handed(&out["csv"])).unwrap();
|
||||
assert!(text.contains(SSN), "{text}");
|
||||
assert!(text.starts_with(
|
||||
"vendor_id,name,decision_1099,form_1099,reason_kind,reason,decided_by,decided_on,effective,decisions_in_year,paid,excluded_card,undetermined,not_reportable,status,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n"
|
||||
@@ -1085,7 +990,7 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() {
|
||||
// Without TINs: last fours only, and no TIN columns.
|
||||
let out = m.read_only("books.vendor_1099", json!({"year": 2026, "csv": true}));
|
||||
assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026.csv");
|
||||
let bytes = handed(&out["csv"]);
|
||||
let bytes = m.handed(&out["csv"]);
|
||||
assert!(!holds_tin(&bytes, SSN_DIGITS));
|
||||
let text = String::from_utf8(bytes).unwrap();
|
||||
assert!(
|
||||
@@ -1293,7 +1198,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
"books.vendor_1099",
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
);
|
||||
let csv = handed(&csv["csv"]);
|
||||
let csv = m.handed(&csv["csv"]);
|
||||
assert!(holds_tin(&csv, SSN_DIGITS) && holds_tin(&csv, EIN_DIGITS));
|
||||
// The package, TINs included (the default).
|
||||
let pkg = m.read_only(
|
||||
@@ -1301,7 +1206,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
assert_eq!(pkg["tins_included"], true);
|
||||
let pkg = unzip(&handed(&pkg["file"]));
|
||||
let pkg = unzip(&m.handed(&pkg["file"]));
|
||||
let holds_any = |b: &[u8]| {
|
||||
holds_tin(b, SSN_DIGITS) || holds_tin(b, EIN_DIGITS) || holds_tin(b, MEMBER_SSN_DIGITS)
|
||||
};
|
||||
@@ -1310,9 +1215,11 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
// (D-29): the W-9 file, the worksheet CSV with TINs, the package with
|
||||
// them (a zip's files read out of it).
|
||||
let (mut leaks, mut scanned): (Vec<String>, Vec<String>) = (Vec::new(), Vec::new());
|
||||
for (tool, resp) in &m.transcript {
|
||||
for (tool, resp) in m.transcript.clone() {
|
||||
let mut files = Vec::new();
|
||||
let rest = take_files(&serde_json::from_str(resp).unwrap(), &mut files).to_string();
|
||||
let resp: Value = serde_json::from_str(&resp).unwrap();
|
||||
take_files(&mut m, &resp, &mut files);
|
||||
let rest = resp.to_string();
|
||||
let worksheet =
|
||||
tool == "books.vendor_1099" && rest.contains("\"warning\":\"This worksheet");
|
||||
if !worksheet && holds_any(rest.as_bytes()) {
|
||||
@@ -1342,7 +1249,7 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
assert_eq!(scanned.len(), 4, "{scanned:?}");
|
||||
assert!(m.transcript.len() > 40, "the session used the books");
|
||||
// The server's log.
|
||||
let log = std::fs::read(m.outside().join("stderr.log")).unwrap();
|
||||
let log = m.server_log();
|
||||
assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS));
|
||||
assert!(!holds_tin(&log, MEMBER_SSN_DIGITS));
|
||||
// The package carries them all: the member's in the K-1 worksheet,
|
||||
@@ -1360,12 +1267,16 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
);
|
||||
}
|
||||
}
|
||||
// Every byte in the books dir outside the vault: the journal, the
|
||||
// database (and its WAL), the stored files, the settings.
|
||||
// Every byte in the books' folder outside the vault: the journal, the
|
||||
// database (and its WAL), the stored files, the access log, the books'
|
||||
// own backup, the handed files' records; but the handed files' bytes,
|
||||
// kept for their download (D-29: the package and worksheet with TINs,
|
||||
// for the CPA, by design) and scanned above through it.
|
||||
let books = m.books_dir();
|
||||
let handed = handed_exports(&m);
|
||||
let mut seen = Vec::new();
|
||||
for f in files_under(&books) {
|
||||
if f.starts_with(m.vault()) {
|
||||
if f.starts_with(m.vault()) || handed.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let bytes = std::fs::read(&f).unwrap();
|
||||
@@ -1396,32 +1307,6 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Books kept in git must ignore the vault before a TIN is stored.
|
||||
#[test]
|
||||
fn books_in_git_must_ignore_the_vault() {
|
||||
let mut m = books("git");
|
||||
let books = m.books_dir();
|
||||
std::fs::create_dir_all(books.join(".git")).unwrap();
|
||||
std::fs::write(books.join(".gitignore"), "books-data/wanderbooks.db\n").unwrap();
|
||||
let why = m
|
||||
.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"}))
|
||||
.err_text();
|
||||
assert!(
|
||||
why.contains("version history") && why.contains("Ask the human"),
|
||||
"{why}"
|
||||
);
|
||||
assert!(!m.vault().exists() && !m.key().exists());
|
||||
std::fs::write(
|
||||
books.join(".gitignore"),
|
||||
"books-data/wanderbooks.db\nvault/\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"}))
|
||||
.refused()
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------- books.set_tin, any role
|
||||
|
||||
/// The TIN facts a party's last `party.tin_set` journaled.
|
||||
@@ -1722,9 +1607,9 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
.collect();
|
||||
assert_eq!(gaps.len(), 1, "{gaps:?}");
|
||||
assert!(gaps[0].contains("Mo Example") && gaps[0].contains("TIN not in the vault"));
|
||||
assert!(!holds_tin(said(&out).as_bytes(), MEMBER_SSN_DIGITS));
|
||||
assert!(!holds_tin(said(&out).as_bytes(), EIN_DIGITS));
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS));
|
||||
assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS));
|
||||
let pkg = unzip(&m.handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
assert_eq!(k1.len(), 3, "two members and their total");
|
||||
let (tobin, mo) = (&k1[0], &k1[1]);
|
||||
@@ -1815,9 +1700,12 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
"{readme}"
|
||||
);
|
||||
assert!(!holds_tin(readme.as_bytes(), MEMBER_SSN_DIGITS));
|
||||
// Nothing of it in the books dir.
|
||||
for f in files_under(&m.books_dir()) {
|
||||
if f.starts_with(m.vault()) {
|
||||
// Nothing of it in the books' folder, but the file handed over, kept
|
||||
// for its download (D-29), and nothing in exports/ but what was handed.
|
||||
let books = m.books_dir();
|
||||
let handed = handed_exports(&m);
|
||||
for f in files_under(&books) {
|
||||
if f.starts_with(m.vault()) || handed.contains(&f) {
|
||||
continue;
|
||||
}
|
||||
let b = std::fs::read(&f).unwrap();
|
||||
@@ -1837,12 +1725,12 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
assert_eq!(out["tins_included"], false);
|
||||
assert_eq!(out["note"], "The package, with last fours only.");
|
||||
assert!(out["tin_gaps"].as_array().unwrap().is_empty(), "{out}");
|
||||
let plain = unzip(&handed(&out["file"]));
|
||||
let plain = unzip(&m.handed(&out["file"]));
|
||||
let k1 = csv_rows(&plain["k1-members.csv"]);
|
||||
assert_eq!(k1[0]["tin_last4"], "4915");
|
||||
assert!(!k1[0].contains_key("tin"));
|
||||
assert_eq!(k1[0]["ending_capital"], "950.00");
|
||||
// "50%" in the settings file prints by its value, at two places, as
|
||||
// "50%" in the settings prints by its value, at two places, as
|
||||
// it would had the books kept it as "0.5".
|
||||
assert_eq!(k1[0]["share"], "0.50");
|
||||
assert_eq!(k1[1]["share"], "0.50");
|
||||
@@ -1852,15 +1740,15 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() {
|
||||
}
|
||||
}
|
||||
|
||||
/// The package, with TINs or without, is handed over (inline over stdio,
|
||||
/// D-29) and never written: no file appears in the books dir outside the
|
||||
/// vault, or anywhere else.
|
||||
/// The package, with TINs or without, is handed over (by its download,
|
||||
/// D-29) and never written: no file appears in the books' folder outside
|
||||
/// the vault (but the file handed over, kept for its download), or
|
||||
/// anywhere else.
|
||||
#[test]
|
||||
fn a_package_is_handed_over_and_never_written() {
|
||||
let mut m = k1_books("k1-handed");
|
||||
let books = m.books_dir();
|
||||
let (books_before, outside_before) =
|
||||
(paths_outside_vault(&m, &books), files_under(&m.outside()));
|
||||
let (books_before, outside_before) = (paths_outside_vault(&m, &books), m.outside_files());
|
||||
for tins in [true, false] {
|
||||
let out = m.read_only(
|
||||
"books.export_cpa_package",
|
||||
@@ -1868,7 +1756,7 @@ fn a_package_is_handed_over_and_never_written() {
|
||||
);
|
||||
assert_eq!(out["tins_included"], tins);
|
||||
assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip");
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let pkg = unzip(&m.handed(&out["file"]));
|
||||
assert!(pkg.contains_key("README.txt"), "include_tins {tins}");
|
||||
assert_eq!(
|
||||
holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS),
|
||||
@@ -1877,7 +1765,7 @@ fn a_package_is_handed_over_and_never_written() {
|
||||
);
|
||||
}
|
||||
assert_eq!(paths_outside_vault(&m, &books), books_before);
|
||||
assert_eq!(files_under(&m.outside()), outside_before);
|
||||
assert_eq!(m.outside_files(), outside_before);
|
||||
}
|
||||
|
||||
/// A locked vault never fails the package: every TIN row says why, and
|
||||
@@ -1898,7 +1786,7 @@ fn a_locked_vault_still_builds_the_package() {
|
||||
"{gaps:?}"
|
||||
);
|
||||
assert!(!m.key().exists(), "reading never makes a key");
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let pkg = unzip(&m.handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
let members: Vec<_> = k1
|
||||
.iter()
|
||||
@@ -2011,7 +1899,7 @@ fn a_k1_row_never_carries_another_partys_tin() {
|
||||
);
|
||||
let gaps = out["tin_gaps"].to_string();
|
||||
assert!(gaps.contains("member Mo Example"), "{gaps}");
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let pkg = unzip(&m.handed(&out["file"]));
|
||||
let k1 = csv_rows(&pkg["k1-members.csv"]);
|
||||
let mo = k1.iter().find(|r| r["member"] == "Mo Example").unwrap();
|
||||
assert_eq!(mo["tin"], "", "{mo:?}");
|
||||
@@ -2035,7 +1923,7 @@ fn joint_filers_are_noted_in_the_package() {
|
||||
"books.export_cpa_package",
|
||||
json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}),
|
||||
);
|
||||
let pkg = unzip(&handed(&out["file"]));
|
||||
let pkg = unzip(&m.handed(&out["file"]));
|
||||
let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap();
|
||||
let note = "Tobin Example and Mo Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).";
|
||||
assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}");
|
||||
@@ -2101,18 +1989,13 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
.as_str()
|
||||
.unwrap()
|
||||
.to_string();
|
||||
let settings_file =
|
||||
std::fs::read_to_string(m.data.parent().unwrap().join("wanderbooks.toml")).unwrap();
|
||||
m.ok(
|
||||
"books.add_instrument",
|
||||
json!({"kind": "member_card", "label": "Riley's Visa", "account": "1000"}),
|
||||
);
|
||||
let calls: Vec<(&str, Value)> = vec![
|
||||
("books.verify", json!({"blobs": true})),
|
||||
(
|
||||
"books.import_settings",
|
||||
json!({"toml_text": settings_file, "decided_by": "Owner", "confirmed": true}),
|
||||
),
|
||||
("books.back_up_now", json!({})),
|
||||
(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
|
||||
@@ -0,0 +1,118 @@
|
||||
//! Starting the books server on books it can't serve: books another
|
||||
//! program holds, and books a newer wanderbooks wrote. Oracle: ledger-mcp
|
||||
//! local.rs INVARIANT 3 (every book is tried before the server says it is
|
||||
//! ready; one that won't open is reported with why, on its route and in
|
||||
//! the list, and the others are served) and rules.md B-27 (newer books
|
||||
//! are refused as newer, never as corrupt, and left as they are).
|
||||
|
||||
use common::{Home, Server, Wire};
|
||||
use ledger_mcp::admin::{Request, Response};
|
||||
use serde_json::json;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
mod common;
|
||||
|
||||
/// New books `id` written in `home`'s root by the store itself, before a
|
||||
/// server opens them; the store, still open.
|
||||
fn written(home: &Home, id: &str) -> ledger_store::Store {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = home.book_dir(id);
|
||||
let store = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
||||
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700)).unwrap();
|
||||
store
|
||||
}
|
||||
|
||||
/// What the server's list says of `id`: whether it is open, and why not.
|
||||
fn listed(home: &Home, id: &str) -> (bool, Option<String>) {
|
||||
match home.admin(Request::List) {
|
||||
Response::Books(list) => {
|
||||
let b = list
|
||||
.into_iter()
|
||||
.find(|b| b.id == id)
|
||||
.unwrap_or_else(|| panic!("{id} isn't listed"));
|
||||
(b.open, b.problem)
|
||||
}
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// A request to `id`'s route is refused before it runs, with why.
|
||||
fn refused_at_the_door(home: &Home, id: &str, why: &str) {
|
||||
let mut wire = Wire::with_token(home, id, None);
|
||||
wire.send(
|
||||
&json!({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {
|
||||
"protocolVersion": common::PROTOCOL, "capabilities": {},
|
||||
"clientInfo": {"name": "t", "version": "0"}}}),
|
||||
);
|
||||
let refusal = wire.refused.clone().expect("refused");
|
||||
assert!(refusal.not_run, "{refusal:?}");
|
||||
assert!(refusal.body.contains(why), "{refusal:?}");
|
||||
home.assert_hidden(&refusal.body);
|
||||
}
|
||||
|
||||
/// A writer that keeps the books is a conflict: the server reports those
|
||||
/// books not open, with why, serves the others, and never hangs on them.
|
||||
#[test]
|
||||
fn a_held_lock_is_refused_not_waited_on_forever() {
|
||||
let home = Home::new("start-locked");
|
||||
let _holder = written(&home, "acme");
|
||||
drop(written(&home, "beta"));
|
||||
let start = Instant::now();
|
||||
let server = Server::start(&home);
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(60),
|
||||
"the server waited on a held lock"
|
||||
);
|
||||
let (open, problem) = listed(&home, "acme");
|
||||
assert!(!open);
|
||||
let problem = problem.expect("why");
|
||||
assert!(
|
||||
problem.contains("another program has these books open"),
|
||||
"{problem}"
|
||||
);
|
||||
refused_at_the_door(&home, "acme", "another program has these books open");
|
||||
// The others are served.
|
||||
assert_eq!(listed(&home, "beta"), (true, None));
|
||||
let mut beta = Wire::new(&home, "beta");
|
||||
beta.initialize("t");
|
||||
let chart = beta.call("books.chart", json!({}));
|
||||
assert_eq!(chart["result"]["isError"], json!(false), "{chart}");
|
||||
assert!(server.close().success());
|
||||
}
|
||||
|
||||
/// Books written by a newer wanderbooks: the server won't open them, and
|
||||
/// says to update it, not that the books are corrupt (rules.md B-27); it
|
||||
/// leaves them as they are.
|
||||
#[test]
|
||||
fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
let home = Home::new("start-newer");
|
||||
drop(written(&home, "acme"));
|
||||
let dir = home.book_dir("acme");
|
||||
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
w.append("journal.format_set", json!({"format": 3, "features": []}))
|
||||
.unwrap();
|
||||
// The next line chains onto it, so its bytes are checked (B-27).
|
||||
w.append(
|
||||
"fx.rate_set",
|
||||
json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(w);
|
||||
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let server = Server::start(&home);
|
||||
let (open, problem) = listed(&home, "acme");
|
||||
assert!(!open);
|
||||
let err = problem.expect("why");
|
||||
assert!(
|
||||
err.contains("written by a newer version of wanderbooks"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("Update wanderbooks"), "{err}");
|
||||
assert!(!err.contains("corrupt"), "{err}");
|
||||
refused_at_the_door(&home, "acme", "Update wanderbooks");
|
||||
assert!(server.close().success());
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal
|
||||
);
|
||||
}
|
||||
@@ -27,17 +27,12 @@
|
||||
//! 2026-10-16: bank exports are filed for reconciliation).
|
||||
//! ```
|
||||
|
||||
use rmcp::ServiceExt;
|
||||
use rmcp::model::{CallToolRequestParams, ClientConfig};
|
||||
use rmcp::service::{RoleClient, RunningService};
|
||||
use rmcp::transport::StreamableHttpClientTransport;
|
||||
use common::{Client, call};
|
||||
use serde_json::{Value, json};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Child, Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
type Client = RunningService<RoleClient, ClientConfig>;
|
||||
mod common;
|
||||
|
||||
const SSN: &str = "291-12-2846";
|
||||
const MEMBER_SSN: &str = "830-83-5531";
|
||||
@@ -51,123 +46,49 @@ const TIN_PROBE: &str = "more than two numbers shaped like a TIN";
|
||||
/// The refusal for text holding a number printed as an SSN or EIN is.
|
||||
const TIN_PRINTED: &str = "printed the way an SSN or EIN is";
|
||||
|
||||
struct Server(Child);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
|
||||
/// The books dir, a folder for files on their way in, a folder outside
|
||||
/// both, and the settings (a member, so the CPA package has a K-1
|
||||
/// worksheet).
|
||||
/// The test's books (common.rs: a home, its server, the books in its
|
||||
/// root), their folder, a folder for files on their way in, and one
|
||||
/// outside the root for files saved; the settings: a member, so the CPA
|
||||
/// package has a K-1 worksheet.
|
||||
struct Books {
|
||||
served: common::Books,
|
||||
dir: PathBuf,
|
||||
inbox: PathBuf,
|
||||
out: PathBuf,
|
||||
toml: PathBuf,
|
||||
}
|
||||
|
||||
/// The books dir and the folders beside it go when the test ends, pass or
|
||||
/// panic.
|
||||
impl Drop for Books {
|
||||
fn drop(&mut self) {
|
||||
if let Some(base) = self.dir.parent() {
|
||||
let _ = std::fs::remove_dir_all(base);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn books(tag: &str) -> Books {
|
||||
let base =
|
||||
std::env::temp_dir().join(format!("ledger-mcp-tin-files-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&base);
|
||||
let dir = base.join("books");
|
||||
let inbox = base.join("inbox");
|
||||
let out = base.join("out");
|
||||
for d in [&dir, &inbox, &out, &base.join("config")] {
|
||||
let tag = tag.to_string();
|
||||
// Made on a thread of its own: the settings go in through a session
|
||||
// with a runtime of its own (common.rs), never inside the test's.
|
||||
let served = std::thread::spawn(move || {
|
||||
common::Books::with(
|
||||
&format!("tin-files-{tag}"),
|
||||
"USD",
|
||||
Some(
|
||||
"[company]\nname = \"Tin Co\"\n[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n\
|
||||
[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n",
|
||||
),
|
||||
)
|
||||
})
|
||||
.join()
|
||||
.unwrap();
|
||||
let base = served.home.dir.path().to_path_buf();
|
||||
let (inbox, out) = (base.join("inbox"), base.join("out"));
|
||||
for d in [&inbox, &out] {
|
||||
std::fs::create_dir_all(d).unwrap();
|
||||
}
|
||||
let toml = dir.join("wanderbooks.toml");
|
||||
std::fs::write(
|
||||
&toml,
|
||||
format!(
|
||||
"[company]\nname = \"Tin Co\"\n[books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\n\
|
||||
[[company.members]]\nname = \"Mo\"\nshare = \"100%\"\ncapital_account = \"3000\"\n\
|
||||
[vault]\nkey_file = \"{}\"\n",
|
||||
base.join("config/vault.key").display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
Books {
|
||||
dir,
|
||||
dir: served.dir(),
|
||||
served,
|
||||
inbox,
|
||||
out,
|
||||
toml,
|
||||
}
|
||||
}
|
||||
|
||||
fn free_port() -> u16 {
|
||||
std::net::TcpListener::bind("127.0.0.1:0")
|
||||
.unwrap()
|
||||
.local_addr()
|
||||
.unwrap()
|
||||
.port()
|
||||
}
|
||||
|
||||
/// Start the server over HTTP on these books; every client is a session
|
||||
/// of its own.
|
||||
fn serve(b: &Books) -> (Server, String) {
|
||||
let addr = format!("127.0.0.1:{}", free_port());
|
||||
let base = b.dir.parent().unwrap();
|
||||
let child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp"))
|
||||
.arg("--data-dir")
|
||||
.arg(b.dir.join("books-data"))
|
||||
.arg("--books-toml")
|
||||
.arg(&b.toml)
|
||||
.arg("--http")
|
||||
.arg(&addr)
|
||||
.env("XDG_CONFIG_HOME", base.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", base.join("config"))
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
(Server(child), format!("http://{addr}/mcp"))
|
||||
}
|
||||
|
||||
async fn connect(url: &str) -> Client {
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
loop {
|
||||
let transport = StreamableHttpClientTransport::from_uri(url.to_string());
|
||||
match ClientConfig::default().serve(transport).await {
|
||||
Ok(c) => return c,
|
||||
Err(e) if Instant::now() > deadline => panic!("connect: {e}"),
|
||||
Err(_) => tokio::time::sleep(Duration::from_millis(100)).await,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a tool: Ok(structured result) or Err(the tool's error text).
|
||||
async fn call(c: &Client, tool: &str, args: Value) -> Result<Value, String> {
|
||||
let args = args.as_object().cloned().unwrap_or_default();
|
||||
let r = c
|
||||
.call_tool(CallToolRequestParams::new(tool.to_string()).with_arguments(args))
|
||||
.await
|
||||
.map_err(|e| format!("protocol: {e}"))?;
|
||||
let text = r
|
||||
.content
|
||||
.iter()
|
||||
.find_map(|c| c.as_text().map(|t| t.text.clone()))
|
||||
.unwrap_or_default();
|
||||
if r.is_error == Some(true) {
|
||||
Err(text)
|
||||
} else {
|
||||
Ok(r.structured_content
|
||||
.unwrap_or_else(|| serde_json::from_str(&text).unwrap()))
|
||||
}
|
||||
/// A new session with the books.
|
||||
async fn connect(b: &Books) -> Client {
|
||||
common::connect(&b.served.home, common::BOOK).await.unwrap()
|
||||
}
|
||||
|
||||
async fn ok(c: &Client, tool: &str, args: Value) -> Value {
|
||||
@@ -185,22 +106,11 @@ fn inline(bytes: &[u8]) -> (String, String) {
|
||||
)
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): inline, as on
|
||||
/// books served from a --data-dir, matching the size and SHA-256 it
|
||||
/// states.
|
||||
fn handed(file: &Value) -> Vec<u8> {
|
||||
use base64::Engine;
|
||||
assert!(file.get("download").is_none(), "{file}");
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(file["content_base64"].as_str().expect("content_base64"))
|
||||
.unwrap();
|
||||
assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}");
|
||||
assert_eq!(
|
||||
file["sha256"],
|
||||
json!(ledger_store::journal::sha256_hex(&bytes)),
|
||||
"{file}"
|
||||
);
|
||||
bytes
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`): fetched by its
|
||||
/// download, matching the size and SHA-256 it states.
|
||||
async fn handed(b: &Books, file: &Value) -> Vec<u8> {
|
||||
let token = b.served.home.token(common::BOOK);
|
||||
common::fetch_export(&b.served.home, common::BOOK, &token, file).await
|
||||
}
|
||||
|
||||
/// Every way the file at `path` (named `name`) could enter the books is
|
||||
@@ -214,7 +124,7 @@ async fn refused_everywhere(
|
||||
) -> Vec<String> {
|
||||
let bytes = std::fs::read(path).unwrap();
|
||||
let (content, sha) = inline(&bytes);
|
||||
let journal = b.dir.join("books-data").join(ledger_store::JOURNAL_FILE);
|
||||
let journal = b.dir.join(ledger_store::JOURNAL_FILE);
|
||||
let before = std::fs::read(&journal).unwrap();
|
||||
let mut refusals = Vec::new();
|
||||
for (tool, args) in [
|
||||
@@ -257,8 +167,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
)
|
||||
.unwrap();
|
||||
let (w9_content, w9_sha) = inline(&std::fs::read(&w9).unwrap());
|
||||
let (server, url) = serve(&b);
|
||||
let one = connect(&url).await;
|
||||
let one = connect(&b).await;
|
||||
for (code, name, ty) in [
|
||||
("1000", "Cash", "asset"),
|
||||
("3000", "Capital", "equity"),
|
||||
@@ -303,7 +212,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
// outside the books as the human would save it.
|
||||
let copy = b.out.join("w9-copy.pdf");
|
||||
let got = ok(&one, "books.get_w9", json!({"party": "Acme"})).await;
|
||||
std::fs::write(©, handed(&got["file"])).unwrap();
|
||||
std::fs::write(©, handed(&b, &got["file"]).await).unwrap();
|
||||
let csv = b.out.join("1099.csv");
|
||||
let got = ok(
|
||||
&one,
|
||||
@@ -311,7 +220,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
json!({"year": 2026, "include_tins": true, "csv": true}),
|
||||
)
|
||||
.await;
|
||||
std::fs::write(&csv, handed(&got["csv"])).unwrap();
|
||||
std::fs::write(&csv, handed(&b, &got["csv"]).await).unwrap();
|
||||
let zip = b.out.join("cpa.zip");
|
||||
let got = ok(
|
||||
&one,
|
||||
@@ -320,7 +229,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
)
|
||||
.await;
|
||||
assert_eq!(got["tins_included"], true, "{got}");
|
||||
std::fs::write(&zip, handed(&got["file"])).unwrap();
|
||||
std::fs::write(&zip, handed(&b, &got["file"]).await).unwrap();
|
||||
let k1 = b.out.join("k1-members.csv");
|
||||
{
|
||||
let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap();
|
||||
@@ -341,7 +250,7 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
(k1.clone(), "k1-members.csv"),
|
||||
];
|
||||
// A second session refuses each.
|
||||
let two = connect(&url).await;
|
||||
let two = connect(&b).await;
|
||||
for (path, name) in &files {
|
||||
refused_everywhere(&two, &b, path, name, TIN_FILE).await;
|
||||
}
|
||||
@@ -372,11 +281,10 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart()
|
||||
}
|
||||
accepted_everywhere(&two, "statement-again.csv", BANK_CSV).await;
|
||||
drop((one, two));
|
||||
drop(server);
|
||||
b.served.restart();
|
||||
|
||||
// A server started afresh on the same books refuses them all too.
|
||||
let (_server, url) = serve(&b);
|
||||
let three = connect(&url).await;
|
||||
let three = connect(&b).await;
|
||||
for (path, name) in &files {
|
||||
refused_everywhere(&three, &b, path, name, TIN_FILE).await;
|
||||
}
|
||||
@@ -404,8 +312,7 @@ async fn a_refused_set_w9_never_marks_its_file() {
|
||||
std::fs::write(&w9, &w9_bytes).unwrap();
|
||||
let notes = b.inbox.join("notes.txt");
|
||||
std::fs::write(¬es, b"Acme sent their W-9 on the 3rd.\n").unwrap();
|
||||
let (_server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
let c = connect(&b).await;
|
||||
for (code, name, ty) in [("1000", "Cash", "asset"), ("3000", "Capital", "equity")] {
|
||||
ok(
|
||||
&c,
|
||||
@@ -512,8 +419,7 @@ Date,Description,Amount,Balance,Reference
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not() {
|
||||
let b = books("text");
|
||||
let (server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
let c = connect(&b).await;
|
||||
ok(
|
||||
&c,
|
||||
"books.add_party",
|
||||
@@ -549,14 +455,14 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
|
||||
}
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
// The vault locked (its key gone): the printed shapes are still
|
||||
// refused, and the bank export still goes in; the vault's TIN written
|
||||
// bare is no longer recognized.
|
||||
std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap();
|
||||
let (_server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
b.served.stop();
|
||||
std::fs::remove_file(b.dir.join("keys/legacy.key")).unwrap();
|
||||
b.served.start();
|
||||
let c = connect(&b).await;
|
||||
let path = b.out.join("payees-0.csv");
|
||||
refused_everywhere(&c, &b, &path, "payees-0.csv", TIN_PRINTED).await;
|
||||
let path = b.out.join("members-1.csv");
|
||||
@@ -577,18 +483,15 @@ async fn text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn the_company_s_own_ein_goes_in_and_no_other() {
|
||||
let b = books("own-ein");
|
||||
let toml = std::fs::read_to_string(&b.toml).unwrap();
|
||||
std::fs::write(
|
||||
&b.toml,
|
||||
toml.replacen(
|
||||
"name = \"Tin Co\"\n",
|
||||
"name = \"Tin Co\"\nein = \"12-3456789\"\n",
|
||||
1,
|
||||
),
|
||||
let c = connect(&b).await;
|
||||
call(
|
||||
&c,
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}],
|
||||
"decided_by": "Test Owner"}),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let (_server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
accepted_everywhere(
|
||||
&c,
|
||||
"plan.md",
|
||||
@@ -616,8 +519,7 @@ async fn the_company_s_own_ein_goes_in_and_no_other() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn utf16_text_without_a_byte_order_mark_is_scanned_either_way_round() {
|
||||
let b = books("utf16");
|
||||
let (_server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
let c = connect(&b).await;
|
||||
ok(
|
||||
&c,
|
||||
"books.add_party",
|
||||
@@ -662,8 +564,7 @@ async fn utf16_text_without_a_byte_order_mark_is_scanned_either_way_round() {
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
|
||||
let b = books("probe");
|
||||
let (server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
let c = connect(&b).await;
|
||||
ok(
|
||||
&c,
|
||||
"books.add_party",
|
||||
@@ -719,12 +620,12 @@ async fn text_probing_for_a_tin_is_refused_alike_by_every_filing_tool() {
|
||||
accepted_everywhere(&c, "one.csv", &std::fs::read_to_string(&one).unwrap()).await;
|
||||
accepted_everywhere(&c, "bank.csv", BANK_CSV).await;
|
||||
drop(c);
|
||||
drop(server);
|
||||
|
||||
// The vault locked: the same refusals, word for word.
|
||||
std::fs::remove_file(b.dir.parent().unwrap().join("config/vault.key")).unwrap();
|
||||
let (_server, url) = serve(&b);
|
||||
let c = connect(&url).await;
|
||||
b.served.stop();
|
||||
std::fs::remove_file(b.dir.join("keys/legacy.key")).unwrap();
|
||||
b.served.start();
|
||||
let c = connect(&b).await;
|
||||
assert_eq!(
|
||||
probes(&c).await,
|
||||
open,
|
||||
@@ -4,7 +4,7 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Company settings for wanderbooks: the books dir's wanderbooks.toml."
|
||||
description = "Company settings for wanderbooks: an older books dir's wanderbooks.toml, read at adoption."
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
@@ -16,7 +16,6 @@ toml = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -44,8 +44,9 @@ pub struct Import {
|
||||
pub notes: Vec<String>,
|
||||
/// Entries the books don't know: not imported (INVARIANT 3).
|
||||
pub unknown: Vec<String>,
|
||||
/// `[vault] key_file`, if the file sets one: the server's to use
|
||||
/// (`--vault-key-file`), not a books setting.
|
||||
/// `[vault] key_file`, if the file sets one: not a books setting (a
|
||||
/// book in the data root keeps its own key: `books adopt --vault-key`
|
||||
/// brings it in).
|
||||
pub vault_key_file: Option<String>,
|
||||
/// The file's comments, in order: a settings file's notes (where a
|
||||
/// value came from, what it waits on) are not settings, so they are
|
||||
@@ -151,7 +152,7 @@ pub fn import(
|
||||
("vault", "key_file") => {
|
||||
out.vault_key_file = v.as_str().map(str::to_string);
|
||||
out.notes.push(
|
||||
"[vault] key_file: the server's to know (start it with --vault-key-file), not a books setting; dropped".into(),
|
||||
"[vault] key_file: not a books setting (a book in the data root keeps its own key: adopt with --vault-key naming that file); dropped".into(),
|
||||
);
|
||||
}
|
||||
("viewer", n) => out.notes.push(format!(
|
||||
@@ -332,7 +333,6 @@ pub fn books_settings(s: &Settings, base_currency: &str) -> BooksSettings {
|
||||
investing_accounts: s.investing_accounts(),
|
||||
asset_tag_prefix: s.asset_tag_prefix().map(str::to_string),
|
||||
parties: s.parties(),
|
||||
vault_key_file: None,
|
||||
timezone: s.timezone().map(str::to_string),
|
||||
}
|
||||
}
|
||||
@@ -395,7 +395,7 @@ pub fn export(s: &Settings, base_currency: &str) -> String {
|
||||
.unwrap_or_default()
|
||||
);
|
||||
out.push_str(
|
||||
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing until it is imported (books.import_settings).\n",
|
||||
"# The books keep their own settings: this file is a copy, and editing it\n# changes nothing (books.set_setting changes them).\n",
|
||||
);
|
||||
for table in ["company", "books"] {
|
||||
let _ = write!(out, "\n[{table}]\n");
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
//! Company settings from the books dir's `wanderbooks.toml`.
|
||||
//! Company settings as an older books dir's `wanderbooks.toml` holds them.
|
||||
//!
|
||||
//! An older books dir keeps that file next to `books-data/` until
|
||||
//! `books.import_settings` moves it into the journal (books of the data
|
||||
//! root never read one). The server reads the company name (report headers),
|
||||
//! the fiscal year start (fiscal-year-to-date reports), the members of a
|
||||
//! multi-member LLC (`[[company.members]]`: name, share, capital account —
|
||||
//! for the statement of members' equity), `[books] accounting_basis`
|
||||
//! (report footers), and `[books] receipt_threshold` (the receipt policy,
|
||||
//! unless `config.json` sets one). The fiscal year *end* is never stored: it is the day before
|
||||
//! the next start, so the two can't disagree.
|
||||
//! Nothing that serves or shows books reads that file (D-44): the books
|
||||
//! keep their own settings in their journal. The file is read once, when
|
||||
//! `wanderbooks books adopt` brings an older books dir in whole (`codec`
|
||||
//! moves its settings, and its `config.json`'s receipt policy, into the
|
||||
//! journal), and `books.export_settings` writes the books' settings out as
|
||||
//! one. It holds the company name (report headers), the fiscal year start
|
||||
//! (fiscal-year-to-date reports), the members of a multi-member LLC
|
||||
//! (`[[company.members]]`: name, share, capital account — for the
|
||||
//! statement of members' equity), `[books] accounting_basis` (report
|
||||
//! footers), and `[books] receipt_threshold` (the receipt policy, unless
|
||||
//! the books dir's `config.json` sets one). The fiscal year *end* is never
|
||||
//! stored: it is the day before the next start, so the two can't disagree.
|
||||
|
||||
pub mod codec;
|
||||
pub mod receipt_policy;
|
||||
@@ -17,9 +20,9 @@ pub use receipt_policy::ReceiptPolicy;
|
||||
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
use std::path::Path;
|
||||
|
||||
/// What the server takes from `wanderbooks.toml`.
|
||||
/// What a `wanderbooks.toml` says, read (`parse`); and the books' own
|
||||
/// settings in the same shape (`codec::books_settings`).
|
||||
#[derive(Clone, Debug, Default, PartialEq)]
|
||||
pub struct BooksSettings {
|
||||
/// `[company]`.
|
||||
@@ -52,9 +55,6 @@ pub struct BooksSettings {
|
||||
/// `[books] parties`: how hard the agent asks for each entry's
|
||||
/// counterparty. Unset: [`PartyPolicy::Suggest`].
|
||||
pub parties: PartyPolicy,
|
||||
/// `[vault] key_file`: the TIN vault's key file. Unset: the vault's
|
||||
/// default (`~/.config/wanderbooks/vault.key`).
|
||||
pub vault_key_file: Option<std::path::PathBuf>,
|
||||
/// `[company] timezone`: the company's time zone (an IANA name), what
|
||||
/// "today" means for the books. Unset: the computer's.
|
||||
pub timezone: Option<String>,
|
||||
@@ -210,32 +210,10 @@ fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The receipt policy in force: `config_json`'s `receipt_policy` when the
|
||||
/// file sets one, else `[books] receipt_threshold`, else the default
|
||||
/// ($25.00); validated (crate::receipt_policy INVARIANT 4). The server and
|
||||
/// the viewer both call this, so they enforce and show one rule.
|
||||
pub fn effective_receipt_policy(
|
||||
config_json: &Path,
|
||||
settings: &BooksSettings,
|
||||
) -> Result<ReceiptPolicy, String> {
|
||||
let raw = if config_json.exists() {
|
||||
Some(
|
||||
std::fs::read_to_string(config_json)
|
||||
.map_err(|e| format!("{}: {e}", config_json.display()))?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
effective_receipt_policy_text(raw.as_deref(), settings).map_err(|e| {
|
||||
e.replace(
|
||||
"bad config",
|
||||
&format!("bad config {}", config_json.display()),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// [`effective_receipt_policy`] from `config.json`'s text (`None`: no
|
||||
/// such file).
|
||||
/// The receipt policy in force: `config_json`'s `receipt_policy` (the
|
||||
/// text of an older books dir's `config.json`; `None`: no such file) when
|
||||
/// it sets one, else `[books] receipt_threshold`, else the default
|
||||
/// ($25.00); validated (crate::receipt_policy INVARIANT 4).
|
||||
pub fn effective_receipt_policy_text(
|
||||
config_json: Option<&str>,
|
||||
settings: &BooksSettings,
|
||||
@@ -448,6 +426,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
})
|
||||
.collect();
|
||||
}
|
||||
// The key file is no books setting (`codec` notes it), but an empty
|
||||
// one is still a broken file.
|
||||
if raw.vault.key_file.is_some_and(|p| p.trim().is_empty()) {
|
||||
return Err("[vault] key_file is empty".into());
|
||||
}
|
||||
let timezone = match raw.company.timezone {
|
||||
Some(z) => {
|
||||
let key = ledger_core::settings::key("company.timezone").expect("a known key");
|
||||
@@ -471,16 +454,6 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
|
||||
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
|
||||
investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?,
|
||||
vault_key_file: match raw.vault.key_file {
|
||||
Some(p) if p.trim().is_empty() => {
|
||||
return Err("[vault] key_file is empty".into());
|
||||
}
|
||||
Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") {
|
||||
Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()),
|
||||
None => p,
|
||||
})),
|
||||
None => None,
|
||||
},
|
||||
asset_tag_prefix: match raw.books.asset_tag_prefix {
|
||||
Some(p)
|
||||
if !p.is_empty()
|
||||
@@ -513,12 +486,6 @@ fn toml_json(s: &str) -> serde_json::Value {
|
||||
serde_json::Value::String(s.to_string())
|
||||
}
|
||||
|
||||
/// Load `wanderbooks.toml` from a path.
|
||||
pub fn load(path: &Path) -> Result<BooksSettings, String> {
|
||||
let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?;
|
||||
parse(&text).map_err(|e| format!("{}: {e}", path.display()))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -1,56 +1,28 @@
|
||||
//! The receipt policy in force when the settings come from files:
|
||||
//! config.json's when it sets one, else the settings file's threshold.
|
||||
//! The receipt policy an older books dir's files set, as adoption moves it
|
||||
//! in: config.json's when it sets one, else the settings file's threshold.
|
||||
|
||||
use ledger_config::{BooksSettings, effective_receipt_policy};
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(tag: &str) -> TestDir {
|
||||
let d = std::env::temp_dir().join(format!("ledger-config-policy-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TestDir(d)
|
||||
}
|
||||
use ledger_config::{BooksSettings, effective_receipt_policy_text};
|
||||
|
||||
#[test]
|
||||
fn config_json_wins_else_the_settings_file() {
|
||||
let d = dir("files");
|
||||
let json = d.join("config.json");
|
||||
let settings = BooksSettings {
|
||||
receipt_threshold: Some("0.00".into()),
|
||||
..BooksSettings::default()
|
||||
};
|
||||
// No config.json: the settings file's threshold.
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
let p = effective_receipt_policy_text(None, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "0.00");
|
||||
// config.json's policy, whole.
|
||||
std::fs::write(
|
||||
&json,
|
||||
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
|
||||
let p = effective_receipt_policy_text(
|
||||
Some(
|
||||
r#"{"receipt_policy": {"require_receipts_above": "10.00", "receipt_exempt_accounts": ["6999"]}}"#,
|
||||
),
|
||||
&settings,
|
||||
)
|
||||
.unwrap();
|
||||
let p = effective_receipt_policy(&json, &settings).unwrap();
|
||||
assert_eq!(p.require_receipts_above, "10.00");
|
||||
assert_eq!(p.receipt_exempt_accounts, ["6999"]);
|
||||
// A broken one is refused, by name.
|
||||
std::fs::write(&json, "{ not json").unwrap();
|
||||
let err = effective_receipt_policy(&json, &settings).unwrap_err();
|
||||
assert!(
|
||||
err.starts_with(&format!("bad config {}", json.display())),
|
||||
"{err}"
|
||||
);
|
||||
// A broken one is refused.
|
||||
let err = effective_receipt_policy_text(Some("{ not json"), &settings).unwrap_err();
|
||||
assert!(err.starts_with("bad config"), "{err}");
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
//! silent, nothing is asserted.
|
||||
|
||||
use chrono::{Datelike, Months, NaiveDate};
|
||||
use ledger_config::{BooksSettings, load, parse};
|
||||
use ledger_config::{BooksSettings, parse};
|
||||
use ledger_core::TaxForm;
|
||||
use proptest::prelude::*;
|
||||
use rust_decimal::Decimal;
|
||||
@@ -717,31 +717,6 @@ fn real_demo_file() {
|
||||
assert_eq!(dec(s.receipt_threshold.as_deref().unwrap()), dec("25"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// load.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// `load` on a missing path is an error.
|
||||
#[test]
|
||||
fn load_missing_path_errors() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
assert!(load(&dir.path().join("nope/wanderbooks.toml")).is_err());
|
||||
}
|
||||
|
||||
/// `load` reads a file the same as `parse` reads its text, refusals
|
||||
/// included.
|
||||
#[test]
|
||||
fn load_matches_parse() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let p = dir.path().join("wanderbooks.toml");
|
||||
for text in [FERNHILL, DEMO, ""] {
|
||||
std::fs::write(&p, text).unwrap();
|
||||
assert_eq!(load(&p), parse(text));
|
||||
}
|
||||
std::fs::write(&p, two_members("50%", "49%")).unwrap();
|
||||
assert!(load(&p).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Properties.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -16,7 +16,7 @@ use ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement,
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
use std::collections::{BTreeMap, HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::nav::Route;
|
||||
|
||||
@@ -597,18 +597,8 @@ impl Books {
|
||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||
let journal = snap.journal().to_vec();
|
||||
// The server's own rule, so the viewer flags exactly what the
|
||||
// server would refuse: the books' settings; for an older books dir
|
||||
// that keeps none, its config.json, else its settings file.
|
||||
let policy = if ledger.settings().is_journaled()
|
||||
|| ledger_store::root::inside_a_root(snap.data_dir())
|
||||
{
|
||||
ledger_config::codec::receipt_policy(ledger.settings())
|
||||
} else {
|
||||
ledger_config::effective_receipt_policy(
|
||||
&snap.data_dir().join("config.json"),
|
||||
&settings,
|
||||
)?
|
||||
};
|
||||
// server would refuse: the books' settings.
|
||||
let policy = ledger_config::codec::receipt_policy(ledger.settings());
|
||||
let receipt_threshold = policy.threshold();
|
||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||
let members = settings.company.party_members(&ledger);
|
||||
@@ -1658,15 +1648,6 @@ impl Books {
|
||||
ledger_core::parties::activity_with(&self.ledger, party, span, &self.money, &self.members)
|
||||
}
|
||||
|
||||
/// The books dir: the folder holding `books-data/` (and `vault/`).
|
||||
fn books_dir(&self) -> PathBuf {
|
||||
let d = self
|
||||
.data_dir
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| self.data_dir.clone());
|
||||
d.parent().map(Path::to_path_buf).unwrap_or(d)
|
||||
}
|
||||
|
||||
/// A party's full TIN from the encrypted vault, formatted
|
||||
/// ("123-45-6789" for an SSN or ITIN, "12-3456789" for an EIN), for the
|
||||
/// clipboard only: never drawn on screen.
|
||||
@@ -1675,12 +1656,8 @@ impl Books {
|
||||
/// INVARIANT 1: read only on demand (a click), never on load or paint.
|
||||
/// INVARIANT 2: never creates a key file; a missing key is a locked
|
||||
/// vault ("restore it from your password manager").
|
||||
/// INVARIANT 3: the vault and key are the server's: for a book in the
|
||||
/// data root, its own `vault/` and `keys/legacy.key`; for older
|
||||
/// books, `vault/` beside `books-data/` and the key file the one
|
||||
/// given on the command line (--vault-key-file), else the settings'
|
||||
/// [vault] key_file, else ~/.config/wanderbooks/vault.key, one
|
||||
/// inside the books dir refused, as the server refuses it.
|
||||
/// INVARIANT 3: the vault and key are the server's: the book's own
|
||||
/// `vault/` and `keys/legacy.key` (a book of the data root, D-44).
|
||||
/// INVARIANT 4: an error never carries the digits (the vault's errors
|
||||
/// are redacted); a party with no record is "not in the vault".
|
||||
/// ```
|
||||
@@ -1694,26 +1671,13 @@ impl Books {
|
||||
}
|
||||
|
||||
/// Where the vault and its key are (INVARIANT 3 of [`Self::tin_from_vault`]).
|
||||
fn vault_place(&self) -> Result<(PathBuf, PathBuf), String> {
|
||||
if ledger_store::root::inside_a_root(&self.data_dir) {
|
||||
return Ok((
|
||||
self.data_dir.join(ledger_store::root::VAULT_DIR),
|
||||
self.data_dir
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
));
|
||||
}
|
||||
let key = crate::vault_key_file()
|
||||
.or_else(|| self.settings.vault_key_file.clone())
|
||||
.unwrap_or_else(ledger_vault::default_key_file);
|
||||
let books = self.books_dir();
|
||||
if resolved(&key).starts_with(resolved(&books)) {
|
||||
return Err(format!(
|
||||
"The vault key file {} is inside the books folder: move it out, and name its new place to the server and the viewer (--vault-key-file).",
|
||||
key.display()
|
||||
));
|
||||
}
|
||||
Ok((books.join("vault"), key))
|
||||
fn vault_place(&self) -> (PathBuf, PathBuf) {
|
||||
(
|
||||
self.data_dir.join(ledger_store::root::VAULT_DIR),
|
||||
self.data_dir
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE),
|
||||
)
|
||||
}
|
||||
|
||||
/// A party's signed W-9 file from the vault, on demand (a click).
|
||||
@@ -1727,7 +1691,7 @@ impl Books {
|
||||
/// The vault, opened read-only, never making a key (a missing one is a
|
||||
/// locked vault).
|
||||
pub fn open_vault(&self) -> Result<ledger_vault::Vault, String> {
|
||||
let (dir, key) = self.vault_place()?;
|
||||
let (dir, key) = self.vault_place();
|
||||
ledger_vault::Vault::open(&dir, &key, false).map_err(
|
||||
|e| match e {
|
||||
ledger_vault::Error::NoKey(k) => format!(
|
||||
@@ -2287,28 +2251,3 @@ fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static
|
||||
fn cash_accounts(ledger: &Ledger, settings: &BooksSettings) -> Vec<AccountId> {
|
||||
ledger_store::reconcilable::cash_accounts(ledger, &settings.cash_accounts)
|
||||
}
|
||||
|
||||
/// `path` made absolute through its nearest existing ancestor (symlinks
|
||||
/// resolved), so a path that doesn't exist yet can still be placed: the
|
||||
/// server's rule for where the vault key may live.
|
||||
fn resolved(path: &Path) -> PathBuf {
|
||||
let abs = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir().unwrap_or_default().join(path)
|
||||
};
|
||||
let mut base = abs.as_path();
|
||||
let mut rest = Vec::new();
|
||||
while !base.exists() {
|
||||
match (base.file_name(), base.parent()) {
|
||||
(Some(name), Some(up)) => {
|
||||
rest.push(name.to_os_string());
|
||||
base = up;
|
||||
}
|
||||
_ => return abs,
|
||||
}
|
||||
}
|
||||
let mut out = base.canonicalize().unwrap_or_else(|_| base.to_path_buf());
|
||||
out.extend(rest.into_iter().rev());
|
||||
out
|
||||
}
|
||||
@@ -16,11 +16,9 @@ use std::sync::Arc;
|
||||
use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel};
|
||||
use std::time::Duration;
|
||||
|
||||
/// Where the books come from: their folder (a book of the data root, or
|
||||
/// an older books dir's `books-data`). Their settings are their own, in
|
||||
/// their journal (plan 3.6); only an older books dir whose journal keeps
|
||||
/// none is read as its server reads it, from its settings file (see
|
||||
/// `settings`).
|
||||
/// Where the books come from: their folder, a book of the data root
|
||||
/// (`wanderbooks gui ID`; D-44). Their settings are their own, in their
|
||||
/// journal (plan 3.6), read as the server reads them.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
/// The books' folder (journal and blobs).
|
||||
@@ -29,34 +27,6 @@ pub struct Source {
|
||||
pub today: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// The settings file an older books dir's server reads when the books
|
||||
/// keep no settings of their own: `wanderbooks.toml` beside `books-data`,
|
||||
/// or the one named with `--books-toml`. Never for a book of the data
|
||||
/// root.
|
||||
pub fn legacy_toml(data_dir: &std::path::Path) -> Option<PathBuf> {
|
||||
legacy_toml_path(data_dir).filter(|p| p.is_file())
|
||||
}
|
||||
|
||||
/// Where an older books dir's settings file is, whether or not it is
|
||||
/// there yet (a file made later is followed, as the server follows it).
|
||||
fn legacy_toml_path(data_dir: &std::path::Path) -> Option<PathBuf> {
|
||||
if ledger_store::root::inside_a_root(data_dir) {
|
||||
return None;
|
||||
}
|
||||
if let Some(named) = LEGACY_TOML.get() {
|
||||
return Some(named.clone());
|
||||
}
|
||||
Some(data_dir.parent()?.join("wanderbooks.toml"))
|
||||
}
|
||||
|
||||
/// `--books-toml`, for an older books dir (once, at start).
|
||||
static LEGACY_TOML: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Name an older books dir's settings file (`--books-toml`).
|
||||
pub fn set_legacy_toml(path: PathBuf) {
|
||||
let _ = LEGACY_TOML.set(path);
|
||||
}
|
||||
|
||||
pub enum Update {
|
||||
Books(Arc<Books>),
|
||||
Error(String),
|
||||
@@ -109,7 +79,7 @@ pub fn load_once(source: &Source) -> Result<Books, String> {
|
||||
})
|
||||
})?;
|
||||
let integrity = Integrity::check(&snap, &mut HashSet::new());
|
||||
let settings = settings(source, &snap)?;
|
||||
let settings = settings(&snap);
|
||||
let today = today(source, &settings);
|
||||
Books::build(&snap, settings, today, None, integrity)
|
||||
}
|
||||
@@ -139,26 +109,13 @@ fn today(source: &Source, settings: &BooksSettings) -> NaiveDate {
|
||||
.unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref()))
|
||||
}
|
||||
|
||||
/// The settings, as the server reads them: the books' own, from their
|
||||
/// journal; for an older books dir that keeps none, its settings file
|
||||
/// (else the defaults). A book of the data root reads no file.
|
||||
fn settings(source: &Source, snap: &Snapshot) -> Result<BooksSettings, String> {
|
||||
settings_of(&source.data_dir, snap)
|
||||
}
|
||||
|
||||
/// `settings` for the books in `data_dir`, as `snap` holds them.
|
||||
pub fn settings_of(data_dir: &std::path::Path, snap: &Snapshot) -> Result<BooksSettings, String> {
|
||||
let books = snap.ledger().settings();
|
||||
if !books.is_journaled()
|
||||
&& let Some(toml) = legacy_toml(data_dir)
|
||||
&& toml.is_file()
|
||||
{
|
||||
return ledger_config::load(&toml);
|
||||
}
|
||||
Ok(ledger_config::codec::books_settings(
|
||||
books,
|
||||
/// The settings: the books' own, from their journal, as the server reads
|
||||
/// them (the defaults until the first is set).
|
||||
pub fn settings(snap: &Snapshot) -> BooksSettings {
|
||||
ledger_config::codec::books_settings(
|
||||
snap.ledger().settings(),
|
||||
snap.ledger().base_currency().code(),
|
||||
))
|
||||
)
|
||||
}
|
||||
|
||||
fn run(
|
||||
@@ -191,30 +148,14 @@ fn run(
|
||||
let mut build = |snap: &Snapshot, prev: Option<u64>| -> (Update, ledger_store::DiskCheck) {
|
||||
let integrity = Integrity::check(snap, &mut verified);
|
||||
let disk = integrity.disk.clone();
|
||||
let update = match settings(&source, snap).and_then(|s| {
|
||||
let today = today(&source, &s);
|
||||
Books::build(snap, s, today, prev, integrity)
|
||||
}) {
|
||||
let s = settings(snap);
|
||||
let today = today(&source, &s);
|
||||
let update = match Books::build(snap, s, today, prev, integrity) {
|
||||
Ok(b) => Update::Books(Arc::new(b)),
|
||||
Err(e) => Update::Error(e),
|
||||
};
|
||||
(update, disk)
|
||||
};
|
||||
// An older books dir's settings file, followed as its server follows
|
||||
// it (only read while the books keep no settings of their own).
|
||||
// And its config.json, whose receipt rule the server reads too.
|
||||
let toml = legacy_toml_path(&source.data_dir);
|
||||
let config = (!ledger_store::root::inside_a_root(&source.data_dir))
|
||||
.then(|| source.data_dir.join("config.json"));
|
||||
let mtime = |t: &Option<PathBuf>| {
|
||||
t.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
};
|
||||
let toml_mtime = |t: &Option<PathBuf>| (mtime(t), mtime(&config));
|
||||
// Taken before the first build, so a file written while it is being
|
||||
// built is a change, never part of the starting state.
|
||||
let mut toml_seen = toml_mtime(&toml);
|
||||
let (first, disk) = build(&snap, None);
|
||||
let mut last_disk = Some(disk);
|
||||
send(first);
|
||||
@@ -267,15 +208,8 @@ fn run(
|
||||
}
|
||||
};
|
||||
look_at_ops(&mut watcher);
|
||||
let day_now = |snap: &Snapshot| {
|
||||
settings(&source, snap)
|
||||
.map(|s| today(&source, &s))
|
||||
.unwrap_or_else(|_| today(&source, &BooksSettings::default()))
|
||||
};
|
||||
let day_now = |snap: &Snapshot| today(&source, &settings(snap));
|
||||
let mut day = day_now(&snap);
|
||||
if let (Some(w), Some(dir)) = (watcher.as_mut(), toml.as_ref().and_then(|t| t.parent())) {
|
||||
let _ = w.watch(dir, RecursiveMode::NonRecursive);
|
||||
}
|
||||
loop {
|
||||
if gone() {
|
||||
return;
|
||||
@@ -302,7 +236,6 @@ fn run(
|
||||
}
|
||||
};
|
||||
look_at_ops(&mut watcher);
|
||||
let toml_now = toml_mtime(&toml);
|
||||
let new_day = day_now(&snap);
|
||||
// The writer journals first and updates its database right after:
|
||||
// a "behind" database usually catches up a moment later, so look
|
||||
@@ -311,9 +244,8 @@ fn run(
|
||||
&& last_disk
|
||||
.as_ref()
|
||||
.is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d);
|
||||
if grew || disk_changed || new_day != day || toml_now != toml_seen {
|
||||
if grew || disk_changed || new_day != day {
|
||||
day = new_day;
|
||||
toml_seen = toml_now;
|
||||
let (update, disk) = build(&snap, grew.then_some(before));
|
||||
last_disk = Some(disk);
|
||||
send(update);
|
||||
|
||||
@@ -29,21 +29,6 @@ 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.
|
||||
static VAULT_KEY_FILE: std::sync::OnceLock<std::path::PathBuf> = std::sync::OnceLock::new();
|
||||
|
||||
/// Name the vault key file (once, at start).
|
||||
pub fn set_vault_key_file(path: std::path::PathBuf) {
|
||||
let _ = VAULT_KEY_FILE.set(path);
|
||||
}
|
||||
|
||||
/// The vault key file named on the command line, if one was.
|
||||
pub fn vault_key_file() -> Option<std::path::PathBuf> {
|
||||
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
|
||||
@@ -64,14 +49,12 @@ impl std::error::Error for Exit {}
|
||||
/// `wanderbooks gui`). It opens the books read-only and returns when the
|
||||
/// window closes. An unknown argument returns [`Exit`] (code 2).
|
||||
///
|
||||
/// `ledger-gui [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]`:
|
||||
/// a book of the data root (plan 3.6): `--root`, else the root `wanderbooks
|
||||
/// setup` made, never the current directory or a settings file; `--book`
|
||||
/// names one, else the root's only one (or the first, by id).
|
||||
/// `--data-dir` opens an older books dir's `books-data` instead (its
|
||||
/// settings file `--books-toml`, read while its journal keeps none, else
|
||||
/// the `wanderbooks.toml` beside it; its vault key `--vault-key-file`);
|
||||
/// `--demo` builds a demo company in a temp directory and opens that.
|
||||
/// `ledger-gui [--root PATH] [--book ID] [--today YYYY-MM-DD] [--demo]`:
|
||||
/// a book of the data root (plan 3.6), the only books it opens (D-44):
|
||||
/// `--root`, else the root `wanderbooks setup` made, never the current
|
||||
/// directory or a settings file; `--book` names one, else the root's only
|
||||
/// one (or the first, by id). `--demo` builds a demo company in a temp
|
||||
/// directory and opens that.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
@@ -82,28 +65,11 @@ pub fn run(
|
||||
let mut today = None;
|
||||
let mut demo = false;
|
||||
let mut args = args.into_iter();
|
||||
let usage = || {
|
||||
format!(
|
||||
"usage: {prog} [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]"
|
||||
)
|
||||
};
|
||||
let usage = || format!("usage: {prog} [--root PATH] [--book ID] [--today YYYY-MM-DD] [--demo]");
|
||||
while let Some(a) = args.next() {
|
||||
match a.as_str() {
|
||||
"--root" => root = args.next().map(PathBuf::from),
|
||||
"--book" => book = args.next(),
|
||||
"--data-dir" => data_dir = args.next().map(PathBuf::from),
|
||||
// An older books dir's settings file, read only while its
|
||||
// books keep no settings of their own (data::legacy_toml).
|
||||
"--books-toml" => {
|
||||
if let Some(t) = args.next() {
|
||||
crate::data::set_legacy_toml(PathBuf::from(t));
|
||||
}
|
||||
}
|
||||
"--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" => {
|
||||
|
||||
@@ -151,9 +151,8 @@ impl Job {
|
||||
let snap = ledger_store::Snapshot::load(&books.data_dir).map_err(|e| e.to_string())?;
|
||||
let ledger = snap.ledger();
|
||||
// The settings the rest of the viewer shows, read as the server
|
||||
// reads them (data::settings: an older books dir that keeps none
|
||||
// in its journal reads its settings file).
|
||||
let st = &crate::data::settings_of(&books.data_dir, &snap)?;
|
||||
// reads them (data::settings).
|
||||
let st = &crate::data::settings(&snap);
|
||||
let settings = ledger_package::Settings {
|
||||
company: &st.company,
|
||||
cash_accounts: &st.cash_accounts,
|
||||
|
||||
@@ -21,8 +21,8 @@
|
||||
//! 2. Each runs off the UI thread; what came of it is shown, in words.
|
||||
//! 3. A restore is shown before it is done (the backup proven, what it
|
||||
//! would do), and done only for that backup, at that head.
|
||||
//! 4. Without an owner (the viewer on its own, older books dirs, the
|
||||
//! demo) the menu isn't there.
|
||||
//! 4. Without an owner (the viewer on its own, books outside a data
|
||||
//! root: the demo) the menu isn't there.
|
||||
//! 5. Books waiting for a format upgrade show a standing notice above
|
||||
//! every page, and the go-ahead is asked once each time the viewer
|
||||
//! opens them: the owner passphrase, typed here and handed to the
|
||||
@@ -109,7 +109,7 @@ fn owner() -> Option<Arc<dyn Owner>> {
|
||||
}
|
||||
|
||||
/// The id of books of the data root (their folder's name), or None for
|
||||
/// an older books dir (INVARIANT 4).
|
||||
/// books outside one, the demo's (INVARIANT 4).
|
||||
pub fn book_id(books: &Books) -> Option<String> {
|
||||
ledger_store::root::inside_a_root(&books.data_dir)
|
||||
.then(|| books.data_dir.file_name()?.to_str().map(str::to_string))
|
||||
|
||||
@@ -169,19 +169,12 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
|_| {},
|
||||
|ui| {
|
||||
let kept = b.ledger.settings();
|
||||
// Where the settings shown come from: a settings
|
||||
// file only for an older books dir that keeps none
|
||||
// in its journal and has one (data::settings).
|
||||
let from_file =
|
||||
!kept.is_journaled() && crate::data::legacy_toml(&b.data_dir).is_some();
|
||||
// The currency is always known: facts are the rest.
|
||||
if c.profile.iter().all(|(k, _)| k == "base_currency") {
|
||||
ui.label(
|
||||
RichText::new(if from_file {
|
||||
"No company facts in the settings file yet: the agent records them as you give them."
|
||||
} else {
|
||||
"No company facts set yet: the agent records them as you give them."
|
||||
})
|
||||
RichText::new(
|
||||
"No company facts set yet: the agent records them as you give them.",
|
||||
)
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
@@ -204,11 +197,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
kv(ui, "Time zone", z);
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(if from_file {
|
||||
"Read from the settings file: the agent can move it into the books."
|
||||
} else {
|
||||
"Kept in the books: the agent changes settings, with who decided and when."
|
||||
})
|
||||
RichText::new(
|
||||
"Kept in the books: the agent changes settings, with who decided and when.",
|
||||
)
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
//! The viewer's command line: an unknown argument stops the viewer with
|
||||
//! status 2, and it opens only books of the data root (D-44): no flag
|
||||
//! names a loose books folder, its settings file or a vault key.
|
||||
|
||||
use std::process::Command;
|
||||
|
||||
/// The viewer, run in a home of the test's own with an empty environment
|
||||
/// but PATH: if an argument it should refuse were ever taken, it would
|
||||
/// find no books, no display and no preferences of this account's.
|
||||
fn viewer(home: &std::path::Path) -> Command {
|
||||
let mut c = Command::new(env!("CARGO_BIN_EXE_ledger-gui"));
|
||||
c.env_clear().env("PATH", "/usr/bin:/bin").env("HOME", home);
|
||||
c
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_argument_stops_the_viewer() {
|
||||
let home = tempfile::Builder::new()
|
||||
.prefix("wb-gui-args-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let out = viewer(home.path()).arg("--no-such-flag").output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let help = viewer(home.path()).arg("--help").output().unwrap();
|
||||
assert!(help.status.success());
|
||||
let help = String::from_utf8_lossy(&help.stdout);
|
||||
assert!(help.contains("--book ID"), "{help}");
|
||||
for gone in ["--data-dir", "--books-toml", "--vault-key-file"] {
|
||||
assert!(!help.contains(gone), "{help}");
|
||||
}
|
||||
// A gone flag is an unknown argument like any other.
|
||||
for gone in ["--data-dir", "--books-toml", "--vault-key-file"] {
|
||||
let out = viewer(home.path()).args([gone, "x"]).output().unwrap();
|
||||
assert_eq!(out.status.code(), Some(2), "{gone}");
|
||||
}
|
||||
}
|
||||
@@ -219,9 +219,6 @@ fn a_book_in_the_root_reads_its_own_vault() {
|
||||
);
|
||||
let e = b.w9_file(f.member).unwrap_err();
|
||||
assert!(e.contains("no W-9 file"), "{e}");
|
||||
// Its key is the book's own: one named on the command line is never
|
||||
// used for it (none is named here, and none is needed).
|
||||
assert!(ledger_gui::vault_key_file().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -465,12 +462,13 @@ fn the_menus_1099_worksheet_carries_a_payees_full_tin() {
|
||||
}
|
||||
|
||||
/// The menu reads settings as the rest of the viewer and the server do:
|
||||
/// an older books dir that keeps none in its journal, from its file.
|
||||
/// the books' own, from their journal; a settings file beside them is
|
||||
/// never read (D-44).
|
||||
#[test]
|
||||
fn the_menu_reads_an_older_books_dirs_settings_file() {
|
||||
fn the_menu_reads_the_books_own_settings() {
|
||||
downloads();
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-older-")
|
||||
.prefix("wb-gui-kept-")
|
||||
.tempdir()
|
||||
.unwrap();
|
||||
let data = dir.path().join("books-data");
|
||||
@@ -482,14 +480,33 @@ fn the_menu_reads_an_older_books_dirs_settings_file() {
|
||||
] {
|
||||
s.add_account(code, name, ty, None, false).unwrap();
|
||||
}
|
||||
s.change_settings(
|
||||
&[
|
||||
ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Journal Kept Co")),
|
||||
},
|
||||
ledger_core::settings::Change {
|
||||
key: "books.cash_accounts".into(),
|
||||
value: Some(serde_json::json!(["1000"])),
|
||||
},
|
||||
],
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "Tobin Harlow".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
std::fs::write(
|
||||
dir.path().join("wanderbooks.toml"),
|
||||
"[company]\nname = \"File Kept Co\"\n[books]\ncash_accounts = [\"1000\"]\n",
|
||||
"[company]\nname = \"File Beside Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let b = load(&data);
|
||||
assert_eq!(b.settings.company.name, "File Kept Co");
|
||||
assert_eq!(b.settings.company.name, "Journal Kept Co");
|
||||
let (path, _) = Job::Package.run(&b).unwrap();
|
||||
let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap();
|
||||
let mut readme = String::new();
|
||||
@@ -497,7 +514,7 @@ fn the_menu_reads_an_older_books_dirs_settings_file() {
|
||||
.unwrap()
|
||||
.read_to_string(&mut readme)
|
||||
.unwrap();
|
||||
assert!(readme.starts_with("File Kept Co"), "{readme}");
|
||||
assert!(readme.starts_with("Journal Kept Co"), "{readme}");
|
||||
}
|
||||
|
||||
/// The worksheet says when a reportable payee's TIN isn't in it (the
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
//! and the go-ahead is asked as the viewer opens them, with the owner
|
||||
//! passphrase, handed to the owner the program gives the viewer (the
|
||||
//! server checks it). "Not now" changes nothing; a refusal is said in the
|
||||
//! dialog, which stays; books this version made, and older books dirs,
|
||||
//! are never asked; the viewer writes nothing of the books.
|
||||
//! dialog, which stays; books this version made, and books outside a
|
||||
//! data root (the demo's), are never asked; the viewer writes nothing of the books.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
@@ -95,7 +95,7 @@ fn asked(f: &Fake) -> Vec<String> {
|
||||
f.asked.lock().unwrap().clone()
|
||||
}
|
||||
|
||||
/// Books `acme`: in a data root (`root`) or an older books dir, in journal
|
||||
/// Books `acme`: in a data root (`root`) or outside one, in journal
|
||||
/// format 1 (as an older version wrote them) or this version's (`old`).
|
||||
fn books(root: bool, old: bool) -> (tempfile::TempDir, PathBuf, Books) {
|
||||
let dir = tempfile::Builder::new()
|
||||
@@ -320,11 +320,11 @@ fn an_upgrade_that_ends_without_an_answer_never_holds_the_window() {
|
||||
assert!(h.query_by_label(ASKS).is_none(), "and closes");
|
||||
}
|
||||
|
||||
/// Books this version made wait for nothing, and older books dirs (no
|
||||
/// owner's server for them) are never asked: no notice, no dialog, no
|
||||
/// Books this version made wait for nothing, and books outside a data
|
||||
/// root (no owner's server for them) are never asked: no notice, no dialog, no
|
||||
/// menu item.
|
||||
#[test]
|
||||
fn books_in_this_versions_format_and_older_books_dirs_are_never_asked() {
|
||||
fn books_in_this_versions_format_and_books_outside_a_root_are_never_asked() {
|
||||
let _alone = alone();
|
||||
let _ = fake();
|
||||
for (root, old) in [(true, false), (false, true), (false, false)] {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//! Live updates: a change written to the books (by the MCP server, in
|
||||
//! Live updates: a change written to the books (by the books server, in
|
||||
//! practice) reaches the GUI's view model on its own, without a restart,
|
||||
//! and the changed entry is marked fresh; a show request reaches it, and
|
||||
//! while it runs it holds the books' viewer presence.
|
||||
@@ -121,16 +121,21 @@ fn a_change_in_another_process_shows_up_live() {
|
||||
);
|
||||
}
|
||||
|
||||
/// An older books dir that keeps no settings in its journal and has no
|
||||
/// settings file: one written while the viewer runs is followed, as its
|
||||
/// server follows it.
|
||||
/// A setting the server journals while the viewer runs is followed (the
|
||||
/// books' own settings, the only ones the viewer reads: D-44); a settings
|
||||
/// file beside the books is never read.
|
||||
#[test]
|
||||
fn a_settings_file_made_later_is_followed() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id()));
|
||||
fn a_setting_changed_while_it_runs_is_followed() {
|
||||
let dir = std::env::temp_dir().join(format!("ledger-gui-live-set-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
let _cleanup = TestDir(dir.clone());
|
||||
let data = dir.join("books-data");
|
||||
drop(Store::create(&data, Currency::USD).unwrap());
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Beside Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let loader = Loader::spawn(
|
||||
Source {
|
||||
data_dir: data.clone(),
|
||||
@@ -139,23 +144,31 @@ fn a_settings_file_made_later_is_followed() {
|
||||
egui::Context::default(),
|
||||
);
|
||||
match loader.wait(Duration::from_secs(10)) {
|
||||
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"),
|
||||
Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Beside Co"),
|
||||
_ => panic!("no initial load"),
|
||||
}
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Made Later Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut store = Store::open(&data).unwrap();
|
||||
store
|
||||
.change_settings(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Made Later Co")),
|
||||
}],
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "Tobin Harlow".into(),
|
||||
decided_on: NaiveDate::from_ymd_opt(2026, 10, 12).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let deadline = Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
assert!(
|
||||
Instant::now() < deadline,
|
||||
"the new settings file was never read"
|
||||
);
|
||||
assert!(Instant::now() < deadline, "the new setting was never read");
|
||||
match loader.wait(Duration::from_millis(200)) {
|
||||
Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break,
|
||||
Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"),
|
||||
Some(Update::Error(e)) => panic!("the books didn't load: {e}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -480,62 +480,32 @@ fn books_that_keep_their_settings_show_them() {
|
||||
}
|
||||
}
|
||||
|
||||
/// Books that keep no settings in their journal are read as their server
|
||||
/// reads them (plan 3.6): an older books dir from its settings file; a
|
||||
/// book of the data root never from a file, so with the defaults.
|
||||
/// Books that keep no settings in their journal show the defaults, as
|
||||
/// their server reads them (plan 3.6, D-44): no settings file or
|
||||
/// `config.json` beside them is read, in a data root or not.
|
||||
#[test]
|
||||
fn books_that_keep_no_settings_read_their_file_only_outside_a_root() {
|
||||
fn books_that_keep_no_settings_read_no_file_beside_them() {
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books")
|
||||
.join("shares_big");
|
||||
// An older books dir: its server reads wanderbooks.toml, so the viewer
|
||||
// does too.
|
||||
let t = temp("said-file");
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
|
||||
let b = load(&t);
|
||||
assert_eq!(b.settings.company.name, "Old Books Co");
|
||||
assert!(!b.settings.company.members.is_empty());
|
||||
// The same books in a data root: no file is read, whatever lies
|
||||
// beside them; they show the defaults until their settings are in
|
||||
// their journal.
|
||||
let t = temp("said-defaults");
|
||||
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
|
||||
let b = load(&t);
|
||||
assert_eq!(
|
||||
b.settings,
|
||||
ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD")
|
||||
);
|
||||
}
|
||||
|
||||
/// An older books dir with no settings file but a `config.json`: its
|
||||
/// receipt rule is the server's, from that file (the regression round 2
|
||||
/// of slice 6's review found).
|
||||
#[test]
|
||||
fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
|
||||
let from = Path::new(env!("CARGO_MANIFEST_DIR"))
|
||||
.join("../ledger-store/tests/fixtures/old-books")
|
||||
.join("shares_big");
|
||||
let t = temp("said-config");
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(
|
||||
t.0.join("books-data/config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
// In a data root, no file: the books' own rule (the default).
|
||||
let t = temp("said-config-root");
|
||||
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(
|
||||
t.0.join("books-data/config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
let defaults =
|
||||
ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD");
|
||||
for (tag, in_root) in [("said-file", false), ("said-root", true)] {
|
||||
let t = temp(tag);
|
||||
if in_root {
|
||||
std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap();
|
||||
}
|
||||
copy_books(&from, &t.0.join("books-data"));
|
||||
std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap();
|
||||
std::fs::write(
|
||||
t.0.join("books-data/config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let b = load(&t);
|
||||
assert_eq!(b.settings, defaults, "{tag}");
|
||||
assert_ne!(b.receipt_threshold, rust_decimal::Decimal::ZERO, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
/// rules.md B-37: the classes an earlier version wrote read as projects
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
//! The viewer's Books menu (owner.rs, plan P2 "Viewer menu", D-32): each
|
||||
//! item hands its work to the owner the program gives the viewer, off the
|
||||
//! UI thread, and says what came of it; a restore is previewed first; the
|
||||
//! viewer itself writes nothing of the books; without an owner, or for an
|
||||
//! older books dir, the menu isn't there.
|
||||
//! viewer itself writes nothing of the books; without an owner, or for
|
||||
//! books outside a data root (the demo's), the menu isn't there.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui_kittest::Harness;
|
||||
@@ -102,7 +102,7 @@ fn today() -> NaiveDate {
|
||||
}
|
||||
|
||||
/// Books in a data root (`root`: a folder holding the root's marker), or
|
||||
/// an older books dir's.
|
||||
/// outside one (as the demo's are).
|
||||
fn books(root: bool) -> (tempfile::TempDir, PathBuf, Books) {
|
||||
let dir = tempfile::Builder::new()
|
||||
.prefix("wb-gui-owner-")
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
//! P3's seams in the viewer: on the seam review's books (a year of a
|
||||
//! two-member LLC, `ledger-package`'s `tests/seams/books.rs`), the
|
||||
//! viewer's figures, read through its own [`Books`] from the books' folder
|
||||
//! and settings file as it opens them, are the engine's (the server's
|
||||
//! as it opens them (their settings journaled, as the books keep them:
|
||||
//! D-44), are the engine's (the server's
|
||||
//! tools make theirs with the same calls and settings): each member's
|
||||
//! capital statement, party money, the cash flow statement, the fixed
|
||||
//! asset register and the 1099 worksheet, on several dates.
|
||||
@@ -9,7 +10,7 @@
|
||||
#[path = "../../ledger-package/tests/seams/books.rs"]
|
||||
mod books;
|
||||
|
||||
use books::{books, d};
|
||||
use books::{SETTINGS_TOML, books, d};
|
||||
use chrono::NaiveDate;
|
||||
use ledger_gui::books::Books as Viewer;
|
||||
use ledger_gui::{Source, data};
|
||||
@@ -27,7 +28,23 @@ fn viewer(dir: &std::path::Path, today: NaiveDate) -> Viewer {
|
||||
#[test]
|
||||
fn the_viewers_figures_are_the_engines_on_every_date() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = books(dir.path());
|
||||
let mut b = books(dir.path());
|
||||
// The settings in the books, where the viewer reads them (it reads no
|
||||
// file beside them).
|
||||
let import =
|
||||
ledger_config::codec::import(SETTINGS_TOML, None, b.store.ledger().settings(), "USD")
|
||||
.unwrap();
|
||||
b.store
|
||||
.change_settings(
|
||||
&import.changes,
|
||||
&ledger_core::settings::Source {
|
||||
decided_by: "the test".into(),
|
||||
decided_on: d(START),
|
||||
citation: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let l = b.store.ledger();
|
||||
let cash: Vec<_> = b
|
||||
.cash
|
||||
|
||||
@@ -21,7 +21,7 @@ use ledger_gui::nav::Route;
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::PathBuf;
|
||||
|
||||
const SSN: &str = "123-45-6789";
|
||||
|
||||
@@ -38,17 +38,19 @@ impl Drop for Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
/// Books with one member whose SSN is in the vault; the key lives outside
|
||||
/// the books dir, as the server requires.
|
||||
/// Books with one member whose SSN is in the vault, the vault and its key
|
||||
/// where a book of the data root keeps them (its own `vault/` and
|
||||
/// `keys/`).
|
||||
fn fixture(tag: &str) -> Fixture {
|
||||
let root = std::env::temp_dir().join(format!("ledger-gui-tin-{tag}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let books = root.join("books");
|
||||
let key = root.join("keys/vault.key");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
|
||||
let key = books
|
||||
.join(ledger_store::root::KEYS_DIR)
|
||||
.join(ledger_store::root::LEGACY_KEY_FILE);
|
||||
let mut s = Store::create(&books, ledger_core::Currency::USD).unwrap();
|
||||
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
|
||||
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
|
||||
let v = Vault::open(&books.join(ledger_store::root::VAULT_DIR), &key, true).unwrap();
|
||||
let hash = v
|
||||
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
@@ -70,14 +72,12 @@ fn fixture(tag: &str) -> Fixture {
|
||||
}
|
||||
}
|
||||
|
||||
fn load(f: &Fixture, key: Option<&Path>) -> Books {
|
||||
let mut b = data::load_once(&Source {
|
||||
data_dir: f.books.join("books-data"),
|
||||
fn load(f: &Fixture) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: f.books.clone(),
|
||||
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
b.settings.vault_key_file = key.map(Path::to_path_buf);
|
||||
b
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn copied(h: &Harness<'_, App>) -> Vec<String> {
|
||||
@@ -102,7 +102,7 @@ fn no_digits(s: &str) {
|
||||
#[test]
|
||||
fn reads_the_vault_with_the_key_in_force() {
|
||||
let f = fixture("read");
|
||||
let b = load(&f, Some(&f.key));
|
||||
let b = load(&f);
|
||||
assert_eq!(b.tin_from_vault(f.party).unwrap(), SSN);
|
||||
let e = b.tin_from_vault(f.party + 1).unwrap_err();
|
||||
assert!(e.contains("Not in the vault"), "{e}");
|
||||
@@ -112,22 +112,12 @@ fn reads_the_vault_with_the_key_in_force() {
|
||||
#[test]
|
||||
fn a_missing_key_is_locked_and_never_created() {
|
||||
let f = fixture("locked");
|
||||
let gone = f.root.join("elsewhere/vault.key");
|
||||
let b = load(&f, Some(&gone));
|
||||
std::fs::remove_file(&f.key).unwrap();
|
||||
let b = load(&f);
|
||||
let e = b.tin_from_vault(f.party).unwrap_err();
|
||||
assert!(e.contains("locked"), "{e}");
|
||||
no_digits(&e);
|
||||
assert!(!gone.exists(), "reading must never create a key");
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a key file inside the books dir is refused.
|
||||
#[test]
|
||||
fn a_key_inside_the_books_is_refused() {
|
||||
let f = fixture("inside");
|
||||
let inside = f.books.join("vault.key");
|
||||
std::fs::copy(&f.key, &inside).unwrap();
|
||||
let e = load(&f, Some(&inside)).tin_from_vault(f.party).unwrap_err();
|
||||
assert!(e.contains("inside the books folder"), "{e}");
|
||||
assert!(!f.key.exists(), "reading must never create a key");
|
||||
}
|
||||
|
||||
/// INVARIANT 4: a tampered record is unreadable, and says nothing of it.
|
||||
@@ -139,7 +129,7 @@ fn a_bad_record_is_an_error_without_digits() {
|
||||
let last = raw.len() - 1;
|
||||
raw[last] ^= 1;
|
||||
std::fs::write(&rec, raw).unwrap();
|
||||
let e = load(&f, Some(&f.key)).tin_from_vault(f.party).unwrap_err();
|
||||
let e = load(&f).tin_from_vault(f.party).unwrap_err();
|
||||
no_digits(&e);
|
||||
}
|
||||
|
||||
@@ -156,7 +146,7 @@ fn party_page(b: Books, party: PartyId) -> Harness<'static, App> {
|
||||
fn clicking_the_masked_tin_copies_the_full_number() {
|
||||
let f = fixture("click");
|
||||
let shown = "TIN on file ••••6789 (SSN)";
|
||||
let mut h = party_page(load(&f, Some(&f.key)), f.party);
|
||||
let mut h = party_page(load(&f), f.party);
|
||||
assert!(
|
||||
h.query_by_label_contains("12345").is_none(),
|
||||
"the full number is never drawn"
|
||||
@@ -168,8 +158,8 @@ fn clicking_the_masked_tin_copies_the_full_number() {
|
||||
assert!(h.query_by_label_contains("12345").is_none());
|
||||
|
||||
// Locked: nothing copied; the reason shows beside it.
|
||||
let gone = f.root.join("elsewhere/vault.key");
|
||||
let mut h = party_page(load(&f, Some(&gone)), f.party);
|
||||
std::fs::remove_file(&f.key).unwrap();
|
||||
let mut h = party_page(load(&f), f.party);
|
||||
h.get_by_label(shown).click();
|
||||
h.step();
|
||||
assert!(copied(&h).is_empty());
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
//! The viewer's command line: `--vault-key-file` names the vault key and
|
||||
//! wins over a settings file's `[vault] key_file`, as it does for the
|
||||
//! server; an unknown argument stops the viewer with status 2. (Its own
|
||||
//! test binary: the key file is named once per process.)
|
||||
|
||||
use ledger_core::{Role, TinFacts, TinType};
|
||||
use ledger_gui::{Source, data};
|
||||
use ledger_store::Store;
|
||||
use ledger_vault::{Tin, Vault};
|
||||
|
||||
const SSN: &str = "123-45-6789";
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_key_named_on_the_command_line_wins() {
|
||||
let root = std::env::temp_dir().join(format!("ledger-gui-key-flag-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&root);
|
||||
let _cleanup = TestDir(root.clone());
|
||||
let books = root.join("books");
|
||||
let key = root.join("keys/vault.key");
|
||||
std::fs::create_dir_all(&books).unwrap();
|
||||
let mut s = Store::create(&books.join("books-data"), ledger_core::Currency::USD).unwrap();
|
||||
let party = s.add_party("Pat Lee", &[Role::Member]).unwrap();
|
||||
let v = Vault::open(&books.join("vault"), &key, true).unwrap();
|
||||
let hash = v
|
||||
.put_tin(party, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap())
|
||||
.unwrap();
|
||||
s.set_party_tin(
|
||||
party,
|
||||
Some(TinFacts {
|
||||
tin_type: TinType::Ssn,
|
||||
last4: "6789".into(),
|
||||
vault_record: Some(hash),
|
||||
purged: None,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
drop(s);
|
||||
|
||||
// Named first, so the default key location is never consulted.
|
||||
assert_eq!(ledger_gui::vault_key_file(), None);
|
||||
ledger_gui::set_vault_key_file(key.clone());
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
// Named once: a second naming changes nothing.
|
||||
ledger_gui::set_vault_key_file(root.join("elsewhere.key"));
|
||||
assert_eq!(ledger_gui::vault_key_file(), Some(key.clone()));
|
||||
|
||||
let mut b = data::load_once(&Source {
|
||||
data_dir: books.join("books-data"),
|
||||
today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()),
|
||||
})
|
||||
.unwrap();
|
||||
// The settings name a key that isn't there; the command line wins.
|
||||
b.settings.vault_key_file = Some(root.join("missing/vault.key"));
|
||||
assert_eq!(b.tin_from_vault(party).unwrap(), SSN);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_argument_stops_the_viewer() {
|
||||
let out = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--no-such-flag")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
assert!(
|
||||
String::from_utf8_lossy(&out.stderr).contains("unknown argument --no-such-flag"),
|
||||
"{}",
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
let help = std::process::Command::new(env!("CARGO_BIN_EXE_ledger-gui"))
|
||||
.arg("--help")
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(help.status.success());
|
||||
assert!(String::from_utf8_lossy(&help.stdout).contains("--vault-key-file PATH"));
|
||||
}
|
||||
@@ -9,7 +9,10 @@
|
||||
//! no data-dir flag); the plan's tests bridge_opens_closed_books,
|
||||
//! bridge_survives_server_restart, bridge_initializes_during_cold_start,
|
||||
//! no_local_agent_tcp, close_now_ends_every_lease (in
|
||||
//! ledger-cli's open_while_held.rs, under names of their own).
|
||||
//! ledger-cli's open_while_held.rs, under names of their own);
|
||||
//! docs/spec/sources/jsonrpc-2.0.md (the agent's lines: a parse
|
||||
//! error is -32700, an invalid request -32600, with its id when
|
||||
//! it has one, null when not: INVARIANT 7).
|
||||
//! INVARIANTS:
|
||||
//! 1. It holds a lease for the whole session, and talks only to the
|
||||
//! root's lease and MCP sockets: never TCP, never the books' files.
|
||||
|
||||
@@ -4,15 +4,11 @@ version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "MCP server for wanderbooks: the AI-first bookkeeping interface over ledger-store."
|
||||
description = "The books server's MCP tools for wanderbooks: the AI-first bookkeeping interface over ledger-store."
|
||||
|
||||
[lib]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "ledger-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
ledger-core = { path = "../ledger-core" }
|
||||
ledger-store = { path = "../ledger-store" }
|
||||
@@ -25,9 +21,8 @@ ledger-package = { path = "../ledger-package" }
|
||||
base64 = "0.22"
|
||||
# A download's Repr-Digest (local.rs).
|
||||
sha2 = "0.10"
|
||||
mime_guess = "2"
|
||||
rust_decimal = "1"
|
||||
rmcp = { version = "3", features = ["server", "transport-io", "schemars", "transport-streamable-http-server"] }
|
||||
rmcp = { version = "3", features = ["server", "schemars", "transport-streamable-http-server"] }
|
||||
axum = "0.8"
|
||||
http-body-util = "0.1"
|
||||
tower = "0.5"
|
||||
@@ -36,7 +31,7 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync", "net", "signal", "time"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync", "net", "signal", "time"] }
|
||||
tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] }
|
||||
# A call that panics is recorded before the panic goes on (access.rs).
|
||||
futures-util = "0.3"
|
||||
@@ -54,12 +49,8 @@ libc = "0.2"
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
# The tests open the CPA package.
|
||||
zip = "9.0.1"
|
||||
# The unit tests drive the tools as a client would (the integration tests,
|
||||
# through a real books server, are ledger-cli's: plan D-44).
|
||||
tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] }
|
||||
rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] }
|
||||
jsonschema = { version = "0.58", default-features = false }
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
# read_only_tools_change_nothing compares the database by its rows (spec_mcp.rs).
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
@@ -4,7 +4,7 @@ You are the bookkeeping agent for these books. The engine enforces the accountin
|
||||
|
||||
- These tools are the only way to the books. Never look for the books (their journal, receipts or vault) in local files.
|
||||
- Start every session with books.start: the rules in force, this company's conventions, what is open, and next steps. Follow the company's conventions (books.conventions).
|
||||
- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). One exception, for books that still read a settings file (books.settings says source "file"): that file may be edited until books.import_settings moves it in. No other local file.
|
||||
- Settings, conventions, decisions and open items live in the books: change them only through these tools (the settings-and-conventions section). No local file is yours to edit.
|
||||
- Money is exact decimal. Always pass amounts as decimal strings ("123.45"), never floats. Amounts in every response are decimal strings: treat them as exact, and echo them to the human as written.
|
||||
- Never post directly: draft, add lines, preview, show the human the preview, then post (the entries section).
|
||||
- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# CPA handoff
|
||||
|
||||
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package.
|
||||
- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in, then tell them where it is. The viewer's "Export for CPA" makes the same package.
|
||||
- Never file the package, or a file from it, into the books.
|
||||
- tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again.
|
||||
- Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one.
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
# Files in and out
|
||||
|
||||
- A file comes in one of two ways, named in the tool that files it (books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv, books.import_chart_csv):
|
||||
- an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
|
||||
- an upload, for any file up to 25 MiB: books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it.
|
||||
- inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught.
|
||||
- If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy.
|
||||
- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder (books served by `wanderbooks mcp`; elsewhere it comes back inline). They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
|
||||
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`).
|
||||
- Files the books make (books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself.
|
||||
- Receipts and documents come back out through books.get_receipt: view bytes gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name.
|
||||
@@ -11,17 +11,6 @@ The books keep their own record of how they are kept. Every agent, anywhere, rea
|
||||
- policy.require_tax_category is warn (unset: a revenue or expense account without a tax category is created, and the result and Needs attention say so) or refuse (books.add_account, books.import_chart_csv and books.set_tax_category won't leave one without; the accounts invoices, bills and payroll create are never refused, and their results name them). Before setting refuse, categorize with the human every account books.digest lists in uncategorized_accounts.
|
||||
- books.export_settings writes the settings out as a settings file (TOML), each with who decided it. It is a copy: editing it changes nothing.
|
||||
|
||||
## Books that still read a settings file
|
||||
|
||||
If books.settings says source "file", these books predate settings in the books and still read the settings file books.settings names. Move it in once, with the human:
|
||||
|
||||
1. Read that file's text, whole and unedited, and pass it to books.import_settings as toml_text (and, if books.settings shows config_json_present, the receipt policy as config_json_text: {"receipt_policy": {"require_receipts_above", "receipt_required_accounts", "receipt_exempt_accounts"}} from books.settings). The import is refused unless the text is the file the server reads.
|
||||
2. Show the human the preview: every change, the notes (what isn't a books setting and where it goes), anything unknown, and the file's comments, which are not imported: record what matters in them (where a value came from, a discrepancy, something pending) as a decision, an open item or a convention.
|
||||
3. Confirm with confirmed: true, decided_by the human. The import is refused if any setting would read differently afterwards.
|
||||
4. If the file named the vault's key file ([vault] key_file), the server must already be started naming the same file, and the viewer too, or the vault won't open: tell the human before the import.
|
||||
|
||||
From then on no file is read; books.set_setting changes settings. Until then, that file is the one local file you may edit, and books.set_setting is refused (it would drop the rest of the file).
|
||||
|
||||
## Conventions
|
||||
|
||||
How this company keeps its books, as the owners decided, is recorded as conventions (books.set_convention, read with books.conventions; books.start lists them):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Who is calling, what their token allows, and the record of each call
|
||||
//! (plan 3.3, I-9), for books served from the data root.
|
||||
//! (plan 3.3, I-9), for every book the books server serves.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a call reaches a book only with a token for that book and for
|
||||
@@ -9,8 +9,7 @@
|
||||
//! logged, and logging fails closed"), section 7's record.
|
||||
//! INVARIANTS:
|
||||
//! 1. The HTTP door (local.rs) puts a Caller on every request it lets
|
||||
//! through; a call that reaches a book with an access log and has no
|
||||
//! Caller is refused.
|
||||
//! through; a call that reaches a book with no Caller is refused.
|
||||
//! 2. A call runs only if its Caller's token has the scope it needs
|
||||
//! (scopes.rs); otherwise it is refused, unrun.
|
||||
//! 3. Every call, run or refused, gets one record, written before its
|
||||
|
||||
@@ -19,11 +19,9 @@
|
||||
//! ReceiptPolicy::rule), never a fixed one; the connect text states
|
||||
//! the parties rule in force too.
|
||||
//! 3. No connect text or handbook section names a filesystem path, a
|
||||
//! helper command, a pull request, or where the books are; none
|
||||
//! names the settings file, which is the one books.settings names.
|
||||
//! Books that keep their own settings are told no local file is
|
||||
//! theirs to edit; books that still read a settings file, that it is
|
||||
//! the one they may edit, until it is imported.
|
||||
//! helper command, a pull request, or where the books are; the books
|
||||
//! keep their own settings, and the agent is told no local file is
|
||||
//! its to edit.
|
||||
//! 4. The connect text's first paragraph stands alone: the entry
|
||||
//! point, the local-files rule, decimal strings, and the entry flow.
|
||||
//! 5. The company's name is shown as one line of plain text (no
|
||||
@@ -246,23 +244,9 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
// (with the hint back), then the hint and the account codes the rule
|
||||
// lists (counted, with books.settings listing them), and last the
|
||||
// words that only help.
|
||||
// Where settings live: in the books (changed through the tools), or,
|
||||
// for books that still read a settings file, that one file.
|
||||
let settings_rule = |why: bool| {
|
||||
if settings.journaled {
|
||||
" Settings and conventions live in the books: change them only with these tools."
|
||||
.to_string()
|
||||
} else {
|
||||
format!(
|
||||
" The only local file you may edit is the settings file books.settings names{}.",
|
||||
if why {
|
||||
" (until books.import_settings)"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
// Where settings live: in the books, changed through the tools.
|
||||
let settings_rule =
|
||||
" Settings and conventions live in the books: change them only with these tools.";
|
||||
let rest = |why: bool, codes: usize, trace: bool, terse: bool| {
|
||||
let rule = settings.policy.rule_listing(currency, codes);
|
||||
let because = |s: &'static str| if why { s } else { "" };
|
||||
@@ -287,7 +271,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
} else {
|
||||
""
|
||||
},
|
||||
settings_rule(why),
|
||||
settings_rule,
|
||||
)
|
||||
};
|
||||
let mut text = String::new();
|
||||
@@ -358,13 +342,7 @@ mod tests {
|
||||
longest_rule(&long, PartyPolicy::Suggest),
|
||||
longest_rule(&long, PartyPolicy::Off),
|
||||
];
|
||||
// Each as books still reading a settings file, and as books that
|
||||
// keep their own settings.
|
||||
let journaled = every.clone().map(|mut s| {
|
||||
s.journaled = true;
|
||||
s
|
||||
});
|
||||
for s in every.into_iter().chain(journaled) {
|
||||
for s in every {
|
||||
let text = connect_text(&s, "USD");
|
||||
let chars = text.chars().count();
|
||||
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
|
||||
@@ -391,8 +369,8 @@ mod tests {
|
||||
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
|
||||
"{text}"
|
||||
);
|
||||
// What every entry must be, and where settings live: the one
|
||||
// file the agent may edit, or none.
|
||||
// What every entry must be, and where settings live: in the
|
||||
// books.
|
||||
for must in [
|
||||
"balances exactly",
|
||||
"real account from books.chart",
|
||||
@@ -401,14 +379,8 @@ mod tests {
|
||||
] {
|
||||
assert!(text.contains(must), "lacks {must:?}: {text}");
|
||||
}
|
||||
assert_eq!(
|
||||
text.contains("the settings file books.settings names"),
|
||||
!s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
assert!(
|
||||
text.contains("Settings and conventions live in the books"),
|
||||
s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
// The parties rule in force (INVARIANT 2), and only it.
|
||||
@@ -611,22 +583,10 @@ mod tests {
|
||||
for why in [
|
||||
"(their schedules are the evidence)",
|
||||
"(derived from the books)",
|
||||
"(until books.import_settings)",
|
||||
"(the parties setting requires it)",
|
||||
] {
|
||||
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
|
||||
}
|
||||
// The same company once its settings are in the books.
|
||||
s.journaled = true;
|
||||
let text = connect_text(&s, "USD");
|
||||
assert!(text.chars().count() <= CONNECT_MAX);
|
||||
for why in [
|
||||
"(their schedules are the evidence)",
|
||||
"(the parties setting requires it)",
|
||||
"Settings and conventions live in the books",
|
||||
] {
|
||||
assert!(text.contains(why), "{} chars: {text}", text.chars().count());
|
||||
}
|
||||
assert!(!text.contains("local file you may edit"), "{text}");
|
||||
}
|
||||
}
|
||||
+11
-265
@@ -1,19 +1,9 @@
|
||||
//! `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]`
|
||||
//!
|
||||
//! The data dir holds `journal.jsonl` (the source of truth),
|
||||
//! `wanderbooks.v{N}.db` (the disposable SQLite projection, a file for each
|
||||
//! schema: ledger_store::db_file_for), `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). Files come in only as tool input (inline, at most 256
|
||||
//! KiB): uploads need the books server's socket (`wanderbooks mcp`).
|
||||
//! `ledger-mcp`: the AI-first bookkeeping interface. It is served one way
|
||||
//! (plan D-44): by the books server, `wanderbooks server` ([`local`]),
|
||||
//! every book of the data root on the root's local sockets, which agents
|
||||
//! reach through the bridge `wanderbooks mcp --book ID`. Files come in as
|
||||
//! tool input (inline, at most 256 KiB) or as uploads, and the files the
|
||||
//! books make go out as downloads.
|
||||
|
||||
pub mod access;
|
||||
pub mod admin;
|
||||
@@ -22,59 +12,20 @@ mod handbook;
|
||||
#[cfg(unix)]
|
||||
pub mod local;
|
||||
pub mod opening;
|
||||
mod paths;
|
||||
mod policy;
|
||||
pub mod scopes;
|
||||
mod server;
|
||||
mod settings;
|
||||
mod tin_files;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
pub use server::{NOT_RUN_CLOSING, NOT_RUN_CLOSING_CODE};
|
||||
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<String>,
|
||||
// `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]"
|
||||
);
|
||||
eprintln!(" --data-dir directory holding journal.jsonl (default ~/.local/share/wanderbooks)");
|
||||
eprintln!(" --config JSON config file (default <data-dir>/config.json, optional)");
|
||||
eprintln!(
|
||||
" --books-toml company settings, for books that don't keep their own yet (default <data-dir>/../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"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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: 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.
|
||||
/// How a mode of the one executable 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). A library never ends the process
|
||||
/// itself, so the books it opened are closed properly on the way out;
|
||||
/// the executable turns this into the exit.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct Exit(pub i32);
|
||||
|
||||
@@ -86,211 +37,6 @@ impl std::fmt::Display for Exit {
|
||||
|
||||
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 or books from a newer wanderbooks return [`Exit`] after a
|
||||
/// plain message.
|
||||
pub fn run(
|
||||
prog: &str,
|
||||
args: impl IntoIterator<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut data_dir = default_data_dir();
|
||||
let mut config_path: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = None;
|
||||
let mut vault_key_file: Option<PathBuf> = None;
|
||||
let mut http_addr: Option<String> = None;
|
||||
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")?);
|
||||
}
|
||||
"--help" | "-h" => {
|
||||
print_usage(prog);
|
||||
return Ok(());
|
||||
}
|
||||
other => {
|
||||
eprintln!("unknown argument: {other}");
|
||||
print_usage(prog);
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Books in the data root are the books server's alone (plan 3.1):
|
||||
// a second writer beside it is refused before anything is opened.
|
||||
if ledger_store::root::inside_a_root(&data_dir) {
|
||||
eprintln!(
|
||||
"ledger-mcp: those books are in the books' private folder, which only the books server opens: connect to them with `wanderbooks mcp --book <id>`"
|
||||
);
|
||||
return Err(Box::new(Exit(2)));
|
||||
}
|
||||
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<Config, Box<dyn std::error::Error>> {
|
||||
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})");
|
||||
}
|
||||
// The viewer's side channel, made as the books open (ledger_store::ops
|
||||
// INVARIANT 2), so a viewer already open shows its presence before
|
||||
// the first books.show. Not the books: a failure only says so.
|
||||
if let Err(e) = ledger_store::ops::prepare(&data_dir) {
|
||||
eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})");
|
||||
}
|
||||
// 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));
|
||||
|
||||
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))?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
eprintln!("ledger-mcp: store verified, serving over stdio");
|
||||
let server = BooksServer::new(store, ReceiptPolicy::default()).with_live_settings(live);
|
||||
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<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
/// Serve MCP over Streamable HTTP (for network clients).
|
||||
async fn serve_http(
|
||||
addr: String,
|
||||
store: Store,
|
||||
live: std::sync::Arc<std::sync::Mutex<settings::Live>>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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());
|
||||
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<Store, ledger_store::StoreError> {
|
||||
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;
|
||||
|
||||
@@ -60,9 +60,7 @@
|
||||
//! ```
|
||||
|
||||
use crate::admin::{self, Request, Response};
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::server::{BooksServer, Layout, SharedStore};
|
||||
use crate::settings::Live;
|
||||
use crate::server::{BooksServer, Served, SharedStore};
|
||||
use ledger_store::Store;
|
||||
use ledger_store::access::{Access, AccessLog, Outcome, Principal};
|
||||
use ledger_store::auth::{Principals, Scope, Token};
|
||||
@@ -294,61 +292,30 @@ impl Books {
|
||||
self.hide(why.clone())
|
||||
);
|
||||
}
|
||||
let mut live = Live::own();
|
||||
live.sync(store.ledger().settings(), store.base_currency().code());
|
||||
let waiting = Arc::new(Mutex::new(store.journal_format().upgrade()));
|
||||
let database = (store.database_file(), store.other_databases());
|
||||
let layout = Layout::Root(self.root.path().to_path_buf());
|
||||
let access = Arc::new(
|
||||
AccessLog::open(store.data_dir())
|
||||
.map_err(|e| format!("its access log can't be used: {e}"))?,
|
||||
);
|
||||
let uploads =
|
||||
ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp())
|
||||
.map_err(|e| self.hide(e.to_string()))?;
|
||||
let server_uploads = uploads.clone();
|
||||
let exports =
|
||||
ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp())
|
||||
.map_err(|e| self.hide(e.to_string()))?;
|
||||
let server_exports = exports.clone();
|
||||
// The viewer's side channel, made by the server (ops.rs
|
||||
// INVARIANT 2): a viewer can show its presence from the start.
|
||||
ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?;
|
||||
let live = Arc::new(Mutex::new(live));
|
||||
let book_dir = store.data_dir().to_path_buf();
|
||||
let book_id = book_dir
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
let store = SharedStore::new(store);
|
||||
let backups = Arc::new(
|
||||
crate::backup::Backups::open(&book_id, &book_dir, store.clone())
|
||||
.map_err(|e| self.hide(e))?,
|
||||
);
|
||||
let server_backups = backups.clone();
|
||||
let log = access.clone();
|
||||
let shared = store.clone();
|
||||
let served = Served::open(store).map_err(|e| self.hide(e))?;
|
||||
let config = StreamableHttpServerConfig::default()
|
||||
.with_json_response(true)
|
||||
.with_legacy_session_mode(true)
|
||||
.with_max_request_body_bytes(MAX_BODY)
|
||||
.enforce_origin_validation()
|
||||
.with_cancellation_token(self.sessions.child_token());
|
||||
let parts = served.clone();
|
||||
// Every session's server is made the one way (server.rs Served).
|
||||
let service = StreamableHttpService::new(
|
||||
move || {
|
||||
Ok(
|
||||
BooksServer::new_shared(store.clone(), ReceiptPolicy::default())
|
||||
.with_live_settings(live.clone())
|
||||
.with_layout(layout.clone())
|
||||
.with_access(log.clone())
|
||||
.with_uploads(server_uploads.clone())
|
||||
.with_exports(server_exports.clone())
|
||||
.with_backups(server_backups.clone()),
|
||||
)
|
||||
},
|
||||
move || Ok(BooksServer::new(&parts)),
|
||||
Arc::new(sessions()),
|
||||
config,
|
||||
);
|
||||
let Served {
|
||||
store: shared,
|
||||
access,
|
||||
uploads,
|
||||
exports,
|
||||
backups,
|
||||
..
|
||||
} = served;
|
||||
Ok(Book::Open {
|
||||
service: Box::new(service),
|
||||
store: shared,
|
||||
@@ -884,9 +851,9 @@ impl Books {
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings a settings file moves in at adoption, checked as
|
||||
/// `books.import_settings` checks them: they parse, they apply, and the
|
||||
/// vault key file it names (which the books don't keep) is the one given.
|
||||
/// The settings a settings file moves in at adoption, checked: they
|
||||
/// parse, they apply, and the vault key file it names (which the books
|
||||
/// don't keep) is the one given.
|
||||
fn settings_to_move(
|
||||
toml: &str,
|
||||
config_json: Option<&str>,
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
//! `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<dyn std::error::Error>> {
|
||||
match ledger_mcp::run("ledger-mcp", std::env::args().skip(1)) {
|
||||
Err(e) => match e.downcast_ref::<ledger_mcp::Exit>() {
|
||||
Some(exit) => std::process::exit(exit.0),
|
||||
None => Err(e),
|
||||
},
|
||||
done => done,
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
//! Paths as the server judges them: where a path really leads.
|
||||
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
/// `path` made absolute and resolved as the kernel would follow it, name
|
||||
/// by name: every symlink followed (a dangling one too, to where it
|
||||
/// points), `.` and `..` folded in order, and whatever doesn't exist kept
|
||||
/// as written. For an existing path this is its canonical form; for a
|
||||
/// missing one, where it would be.
|
||||
pub fn resolved(path: &Path) -> PathBuf {
|
||||
let abs = if path.is_absolute() {
|
||||
path.to_path_buf()
|
||||
} else {
|
||||
std::env::current_dir()
|
||||
.unwrap_or_else(|_| PathBuf::from("/"))
|
||||
.join(path)
|
||||
};
|
||||
// The names still to walk, last first.
|
||||
let mut todo: Vec<std::ffi::OsString> = Vec::new();
|
||||
let mut out = PathBuf::new();
|
||||
// An absolute path starts the walk over at its root.
|
||||
let push_all = |todo: &mut Vec<std::ffi::OsString>, p: &Path, out: &mut PathBuf| {
|
||||
*out = PathBuf::new();
|
||||
let mut parts = Vec::new();
|
||||
for c in p.components() {
|
||||
match c {
|
||||
Component::Prefix(_) | Component::RootDir => out.push(c.as_os_str()),
|
||||
other => parts.push(other.as_os_str().to_os_string()),
|
||||
}
|
||||
}
|
||||
todo.extend(parts.into_iter().rev());
|
||||
};
|
||||
push_all(&mut todo, &abs, &mut out);
|
||||
// Links followed at most this many times (as the kernel's ELOOP).
|
||||
let mut hops = 0;
|
||||
while let Some(name) = todo.pop() {
|
||||
if name == "." {
|
||||
continue;
|
||||
}
|
||||
if name == ".." {
|
||||
out.pop();
|
||||
continue;
|
||||
}
|
||||
let next = out.join(&name);
|
||||
let link = std::fs::symlink_metadata(&next)
|
||||
.is_ok_and(|m| m.file_type().is_symlink())
|
||||
.then(|| std::fs::read_link(&next).ok())
|
||||
.flatten();
|
||||
match link {
|
||||
Some(target) if hops < 40 => {
|
||||
hops += 1;
|
||||
if target.is_absolute() {
|
||||
push_all(&mut todo, &target, &mut out);
|
||||
} else {
|
||||
todo.extend(
|
||||
target
|
||||
.components()
|
||||
.map(|c| c.as_os_str().to_os_string())
|
||||
.rev(),
|
||||
);
|
||||
}
|
||||
}
|
||||
_ => out = next,
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
/// `resolved` follows as many links as the kernel does (40, as
|
||||
/// `canonicalize` does), and no more.
|
||||
#[test]
|
||||
fn resolved_follows_forty_links_and_no_more() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = dir.path().canonicalize().unwrap();
|
||||
let file = b.join("receipt.pdf");
|
||||
std::fs::write(&file, "x").unwrap();
|
||||
let link = |n: usize| b.join(format!("link-{n}"));
|
||||
symlink(&file, link(1)).unwrap();
|
||||
for n in 2..=41 {
|
||||
symlink(link(n - 1), link(n)).unwrap();
|
||||
}
|
||||
assert_eq!(link(40).canonicalize().unwrap(), file);
|
||||
assert_eq!(resolved(&link(40)), file);
|
||||
assert!(link(41).canonicalize().is_err(), "the kernel stops at 40");
|
||||
assert_ne!(resolved(&link(41)), file);
|
||||
}
|
||||
|
||||
/// `resolved` places a path where the kernel would follow it: the
|
||||
/// canonical form for one that exists, and through a dangling link to
|
||||
/// where it points.
|
||||
#[test]
|
||||
fn resolved_follows_every_link_a_dangling_one_too() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let b = dir.path().canonicalize().unwrap();
|
||||
std::fs::create_dir_all(b.join("Downloads/sub")).unwrap();
|
||||
std::fs::create_dir_all(b.join("data")).unwrap();
|
||||
std::fs::write(b.join("Downloads/receipt.pdf"), "x").unwrap();
|
||||
std::fs::write(b.join("data/journal.jsonl"), "x").unwrap();
|
||||
symlink(
|
||||
b.join("Downloads/receipt.pdf"),
|
||||
b.join("Downloads/good-link"),
|
||||
)
|
||||
.unwrap();
|
||||
symlink(b.join("data"), b.join("Downloads/data-link")).unwrap();
|
||||
for p in [
|
||||
b.join("Downloads/good-link"),
|
||||
b.join("Downloads/data-link/journal.jsonl"),
|
||||
b.join("Downloads/sub/../receipt.pdf"),
|
||||
b.join("Downloads/./good-link"),
|
||||
] {
|
||||
assert_eq!(resolved(&p), p.canonicalize().unwrap(), "{}", p.display());
|
||||
}
|
||||
symlink("../elsewhere/gone.pdf", b.join("Downloads/rel-dangling")).unwrap();
|
||||
assert_eq!(
|
||||
resolved(&b.join("Downloads/rel-dangling")),
|
||||
b.join("elsewhere/gone.pdf")
|
||||
);
|
||||
symlink(b.join("Downloads/loop-b"), b.join("Downloads/loop-a")).unwrap();
|
||||
symlink(b.join("Downloads/loop-a"), b.join("Downloads/loop-b")).unwrap();
|
||||
// A loop ends (the hop limit) inside the folder it loops in.
|
||||
assert!(resolved(&b.join("Downloads/loop-a")).starts_with(b.join("Downloads")));
|
||||
assert_eq!(
|
||||
resolved(&b.join("Downloads/missing/../../elsewhere/x")),
|
||||
b.join("elsewhere/x")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -25,9 +25,8 @@
|
||||
//! the one there) needs books:post (review of slice 4).
|
||||
//! 6. Where the books' backups go is books:admin's, as backing up is
|
||||
//! (a backup is the whole book, its vault and key included):
|
||||
//! setting a `backup.*` key, or importing settings that hold one,
|
||||
//! needs books:admin; other settings books:post as before (review of
|
||||
//! slice 7).
|
||||
//! setting a `backup.*` key needs books:admin; other settings
|
||||
//! books:post as before (review of slice 7).
|
||||
//! ```
|
||||
|
||||
use ledger_store::auth::Scope;
|
||||
@@ -46,14 +45,9 @@ pub enum Needs {
|
||||
/// books:admin for a change to a `backup.*` setting, books:post for
|
||||
/// any other (INVARIANT 6).
|
||||
AdminForBackupSettings,
|
||||
/// As PostWhenConfirmed, and books:admin when the settings imported
|
||||
/// hold a `[backup]` table (INVARIANT 6).
|
||||
ImportSettings,
|
||||
}
|
||||
|
||||
use Needs::{
|
||||
AdminForBackupSettings, Always, ImportSettings, PostForApprovalRules, PostWhenConfirmed,
|
||||
};
|
||||
use Needs::{AdminForBackupSettings, Always, PostForApprovalRules, PostWhenConfirmed};
|
||||
const R: Needs = Always(Scope::Read);
|
||||
const W: Needs = Always(Scope::Write);
|
||||
const P: Needs = Always(Scope::Post);
|
||||
@@ -62,12 +56,11 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 119] = [
|
||||
pub const TABLE: [(&str, Needs); 118] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
("books.set_setting", AdminForBackupSettings),
|
||||
("books.import_settings", ImportSettings),
|
||||
("books.export_settings", R),
|
||||
("books.conventions", R),
|
||||
("books.set_convention", K),
|
||||
@@ -220,20 +213,6 @@ pub fn needs(
|
||||
});
|
||||
if backup { Scope::Admin } else { Scope::Post }
|
||||
}
|
||||
ImportSettings => {
|
||||
let backup = args
|
||||
.and_then(|a| a.get("toml_text"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.and_then(|t| t.parse::<toml::Table>().ok())
|
||||
.is_some_and(|t| t.contains_key("backup"));
|
||||
if backup {
|
||||
Scope::Admin
|
||||
} else if confirmed(args) {
|
||||
Scope::Post
|
||||
} else {
|
||||
Scope::Write
|
||||
}
|
||||
}
|
||||
PostForApprovalRules => {
|
||||
let kind = args
|
||||
.and_then(|a| a.get("kind"))
|
||||
@@ -259,7 +238,7 @@ fn confirmed(args: Option<&serde_json::Map<String, serde_json::Value>>) -> bool
|
||||
pub fn shown(tool: &str, scopes: &std::collections::BTreeSet<Scope>) -> bool {
|
||||
match TABLE.iter().find(|(name, _)| *name == tool) {
|
||||
Some((_, Always(scope))) => scopes.contains(scope),
|
||||
Some((_, PostWhenConfirmed | PostForApprovalRules | ImportSettings)) => {
|
||||
Some((_, PostWhenConfirmed | PostForApprovalRules)) => {
|
||||
scopes.contains(&Scope::Write) || scopes.contains(&Scope::Post)
|
||||
}
|
||||
Some((_, AdminForBackupSettings)) => {
|
||||
@@ -333,25 +312,6 @@ mod tests {
|
||||
);
|
||||
assert_eq!(set(&[" Backup.Folder"]), Some(Scope::Admin));
|
||||
assert_eq!(needs("books.set_setting", None), Some(Scope::Post));
|
||||
let import = |text: &str, confirmed: bool| {
|
||||
needs(
|
||||
"books.import_settings",
|
||||
args(json!({"toml_text": text, "confirmed": confirmed})).as_ref(),
|
||||
)
|
||||
};
|
||||
assert_eq!(
|
||||
import("[company]\nname = \"A\"\n", false),
|
||||
Some(Scope::Write)
|
||||
);
|
||||
assert_eq!(import("[company]\nname = \"A\"\n", true), Some(Scope::Post));
|
||||
assert_eq!(
|
||||
import("[backup]\nfolder = \"/x\"\n", false),
|
||||
Some(Scope::Admin)
|
||||
);
|
||||
assert_eq!(
|
||||
import("[backup]\nfolder = \"/x\"\n", true),
|
||||
Some(Scope::Admin)
|
||||
);
|
||||
// Shown to whoever can set some setting.
|
||||
let post: std::collections::BTreeSet<Scope> = [Scope::Post].into();
|
||||
let write: std::collections::BTreeSet<Scope> = [Scope::Write].into();
|
||||
@@ -359,7 +319,6 @@ mod tests {
|
||||
assert!(shown("books.set_setting", &post));
|
||||
assert!(shown("books.set_setting", &admin));
|
||||
assert!(!shown("books.set_setting", &write));
|
||||
assert!(shown("books.import_settings", &write));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -372,7 +331,7 @@ mod tests {
|
||||
.and_then(|p| p.as_object())
|
||||
.is_some_and(|p| p.contains_key("confirmed"));
|
||||
let (_, needs) = TABLE.iter().find(|(n, _)| *n == tool.name).unwrap();
|
||||
assert_eq!(takes, matches!(*needs, C | ImportSettings), "{}", tool.name);
|
||||
assert_eq!(takes, *needs == C, "{}", tool.name);
|
||||
let takes_kind = tool
|
||||
.input_schema
|
||||
.get("properties")
|
||||
|
||||
+377
-608
File diff suppressed because it is too large.
Load diff
@@ -1,91 +1,56 @@
|
||||
//! The server's settings: the books' own (journaled, ledger_core::
|
||||
//! settings), or, for books that hold none yet, the settings files
|
||||
//! (`wanderbooks.toml`, and the receipt policy in `books-data/config.json`)
|
||||
//! reloaded whenever they change.
|
||||
//! settings), the defaults until the first is set.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every request sees the settings in force, one version for the
|
||||
//! whole request; books that keep their own settings never read a
|
||||
//! file; a broken file edit never takes the server down.
|
||||
//! whole request, and they are only ever the books' own.
|
||||
//! SOURCE: plan/served.md I-3 (the journal holds all books state; the
|
||||
//! server never reads a settings file for books that hold their
|
||||
//! settings), 2.1 ("dev-stdio keeps legacy_files mode until a book
|
||||
//! is adopted"); DECISION (owner, 2026-10-15): file settings are
|
||||
//! always auto-reloaded.
|
||||
//! server never reads a settings file), 3.4 (a book in the data
|
||||
//! root keeps its own settings), D-44 (one way to serve books: no
|
||||
//! settings file is read by any of them).
|
||||
//! INVARIANTS:
|
||||
//! 1. Books whose journal holds settings (any settings.set) use those,
|
||||
//! and from then on no file is read: sync takes them whenever the
|
||||
//! journal's last settings.set changes.
|
||||
//! 2. Otherwise every request (tool call, resource read) sees the files'
|
||||
//! contents as of its start: a request after an edit sees the edit
|
||||
//! (compared by content, not by timestamp, so two edits in one second
|
||||
//! are both seen).
|
||||
//! 3. A file that fails to load leaves the last good settings in force,
|
||||
//! and the failure is reported (books.settings, stderr) until a later
|
||||
//! edit loads.
|
||||
//! 4. A settings file that doesn't exist and wasn't asked for (the
|
||||
//! default wanderbooks.toml beside the books) means the defaults; one
|
||||
//! named with --books-toml that goes missing is a failure (3).
|
||||
//! 5. Only the settings change while serving: the data dir, the books'
|
||||
//! base currency, and a vault key file given on the command line are
|
||||
//! fixed when the server starts (the key file given wins over any
|
||||
//! file's [vault] key_file).
|
||||
//! 6. A book in the data root (plan 3.4) has no settings file at all:
|
||||
//! its settings are its own, the defaults until the first is set,
|
||||
//! and no file is ever read for it.
|
||||
//! 1. The settings in force are the books' own: the defaults until
|
||||
//! their journal holds any settings.set, then those; sync takes them
|
||||
//! whenever the journal's last settings.set changes, and only then.
|
||||
//! 2. No settings file is ever read.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use ledger_config::Company;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
/// `[books]` account roles for the cash flow statement, used when a call
|
||||
/// doesn't pass its own: cash, financing, and investing account codes.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct CashRoles {
|
||||
/// `[books] cash_accounts`.
|
||||
/// `books.cash_accounts`.
|
||||
pub cash: Vec<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
/// `books.financing_accounts`.
|
||||
pub financing: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
/// `books.investing_accounts`.
|
||||
pub investing: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the settings files say, as last loaded.
|
||||
/// The settings in force, as the server uses them.
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct Settings {
|
||||
/// Which entries need a receipt.
|
||||
pub policy: ReceiptPolicy,
|
||||
/// `[company]`.
|
||||
/// The company (`company.*`).
|
||||
pub company: Company,
|
||||
/// `[books]` cash flow roles.
|
||||
/// The cash flow roles.
|
||||
pub roles: CashRoles,
|
||||
/// The prefix of generated asset tags (`[books] asset_tag_prefix`, or
|
||||
/// The prefix of generated asset tags (`books.asset_tag_prefix`, or
|
||||
/// the company's initials).
|
||||
pub asset_tag_prefix: String,
|
||||
/// `[vault] key_file`: the TIN vault's key file (unset: the vault's
|
||||
/// default, `ledger_vault::default_key_file`).
|
||||
pub vault_key_file: Option<PathBuf>,
|
||||
/// `[books] parties`: suggest, require, or never mention each entry's
|
||||
/// `policy.parties`: suggest, require, or never mention each entry's
|
||||
/// counterparty.
|
||||
pub parties: ledger_config::PartyPolicy,
|
||||
/// The company's time zone (an IANA name), if set: what "today" means.
|
||||
pub timezone: Option<String>,
|
||||
/// Whether these are the books' own settings (journaled) rather than a
|
||||
/// file's.
|
||||
pub journaled: bool,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// The vault key file in force: `[vault] key_file`, else the default
|
||||
/// outside the books (`~/.config/wanderbooks/vault.key`).
|
||||
pub fn key_file(&self) -> PathBuf {
|
||||
self.vault_key_file
|
||||
.clone()
|
||||
.unwrap_or_else(ledger_vault::default_key_file)
|
||||
}
|
||||
|
||||
/// The settings the books keep (`ledger_core::settings`), as the
|
||||
/// server uses them.
|
||||
pub fn from_books(books: &ledger_core::settings::Settings, currency: &str) -> Self {
|
||||
@@ -93,7 +58,6 @@ impl Settings {
|
||||
Settings {
|
||||
policy: ledger_config::codec::receipt_policy(books),
|
||||
asset_tag_prefix: b.asset_tag_prefix(),
|
||||
vault_key_file: None,
|
||||
parties: b.parties,
|
||||
timezone: b.timezone.clone(),
|
||||
roles: CashRoles {
|
||||
@@ -102,7 +66,6 @@ impl Settings {
|
||||
investing: b.investing_accounts,
|
||||
},
|
||||
company: b.company,
|
||||
journaled: true,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -116,179 +79,24 @@ impl Settings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the settings come from.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Source {
|
||||
/// The books' wanderbooks.toml.
|
||||
pub books_toml: PathBuf,
|
||||
/// Whether it must exist (named with --books-toml) or may be absent
|
||||
/// (the default beside the books: then the defaults apply).
|
||||
pub required: bool,
|
||||
/// `config.json`, for its receipt policy (optional).
|
||||
pub config_json: PathBuf,
|
||||
}
|
||||
|
||||
/// Both files' bytes (`None`: absent), to tell when either changed.
|
||||
type Seen = (Option<Vec<u8>>, Option<Vec<u8>>);
|
||||
|
||||
impl Source {
|
||||
fn read(&self) -> Seen {
|
||||
(
|
||||
std::fs::read(&self.books_toml).ok(),
|
||||
std::fs::read(&self.config_json).ok(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Load the settings now.
|
||||
pub fn load(&self) -> Result<Settings, String> {
|
||||
let books = if self.required || self.books_toml.exists() {
|
||||
ledger_config::load(&self.books_toml)?
|
||||
} else {
|
||||
ledger_config::BooksSettings::default()
|
||||
};
|
||||
// config.json sits in the data dir, which no message names.
|
||||
let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)
|
||||
.map_err(|e| e.replace(&self.config_json.display().to_string(), "config.json"))?;
|
||||
Ok(Settings {
|
||||
asset_tag_prefix: books.asset_tag_prefix(),
|
||||
vault_key_file: books.vault_key_file.clone(),
|
||||
parties: books.parties,
|
||||
timezone: books.timezone.clone(),
|
||||
journaled: false,
|
||||
policy,
|
||||
roles: CashRoles {
|
||||
cash: books.cash_accounts,
|
||||
financing: books.financing_accounts,
|
||||
investing: books.investing_accounts,
|
||||
},
|
||||
company: books.company,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// The settings in force, and how they got there.
|
||||
/// The settings in force, and the books' settings.set they came from.
|
||||
#[derive(Debug)]
|
||||
pub struct Live {
|
||||
current: Arc<Settings>,
|
||||
source: Option<Source>,
|
||||
seen: Seen,
|
||||
error: Option<String>,
|
||||
loads: u64,
|
||||
/// The seq of the books' last settings.set taken (INVARIANT 1).
|
||||
journal_seq: Option<u64>,
|
||||
/// A vault key file given on the command line (INVARIANT 5).
|
||||
vault_key_file: Option<PathBuf>,
|
||||
/// Whether the settings files may be read: false once the books keep
|
||||
/// their own (INVARIANT 1).
|
||||
read_files: bool,
|
||||
/// A book in the data root: its settings are only ever its own
|
||||
/// (INVARIANT 6).
|
||||
own: bool,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Status {
|
||||
/// The wanderbooks.toml read, if the settings come from files.
|
||||
pub books_toml: Option<PathBuf>,
|
||||
/// Whether that file exists now.
|
||||
pub books_toml_exists: bool,
|
||||
/// Whether a config.json is read (it sits in the data dir, which is
|
||||
/// never named).
|
||||
pub config_json_present: bool,
|
||||
/// Why the last change failed to load (the last good settings stay in
|
||||
/// force), or `None`.
|
||||
pub error: Option<String>,
|
||||
/// How many times the settings have loaded, the first included.
|
||||
pub loads: u64,
|
||||
/// Whether the settings are the books' own (journaled): then no file
|
||||
/// is read.
|
||||
pub journaled: bool,
|
||||
}
|
||||
|
||||
impl Live {
|
||||
/// Settings that never reload (tests, and servers built in code).
|
||||
pub fn fixed(settings: Settings) -> Self {
|
||||
Self {
|
||||
current: Arc::new(settings),
|
||||
source: None,
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// For a book in the data root (INVARIANT 6): no settings file, now
|
||||
/// or later; `sync` brings the books' settings in once they hold any.
|
||||
/// For a book (INVARIANT 1): the defaults; `sync` brings the books'
|
||||
/// settings in once they hold any.
|
||||
pub fn own() -> Self {
|
||||
Self {
|
||||
current: Arc::new(Settings {
|
||||
journaled: true,
|
||||
..Settings::default()
|
||||
}),
|
||||
source: None,
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 0,
|
||||
current: Arc::new(Settings::default()),
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Load from `source`, and reload from it whenever it changes. Fails
|
||||
/// only on this first load: a server doesn't start on broken settings.
|
||||
pub fn watching(source: Source) -> Result<Self, String> {
|
||||
let seen = source.read();
|
||||
let settings = source.load()?;
|
||||
Ok(Self {
|
||||
current: Arc::new(settings),
|
||||
source: Some(source),
|
||||
seen,
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: true,
|
||||
own: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// For books that keep their own settings: nothing is read from
|
||||
/// `source` (INVARIANT 1), not even at start, so a broken settings
|
||||
/// file beside them can't stop the server; `sync` brings the books'
|
||||
/// settings in.
|
||||
pub fn journaled(source: Source) -> Self {
|
||||
Self {
|
||||
current: Arc::new(Settings {
|
||||
journaled: true,
|
||||
..Settings::default()
|
||||
}),
|
||||
source: Some(source),
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 0,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
own: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Use this vault key file whatever the settings say (INVARIANT 5).
|
||||
pub fn with_vault_key_file(mut self, path: Option<PathBuf>) -> Self {
|
||||
self.vault_key_file = path;
|
||||
if let Some(p) = &self.vault_key_file {
|
||||
Arc::make_mut(&mut self.current).vault_key_file = Some(p.clone());
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
/// Take the books' own settings when they hold any and they changed
|
||||
/// since the last look (INVARIANT 1). The server calls this whenever
|
||||
/// it holds the books.
|
||||
@@ -296,230 +104,34 @@ impl Live {
|
||||
if !books.is_journaled() || books.last_seq() == self.journal_seq {
|
||||
return;
|
||||
}
|
||||
let mut s = Settings::from_books(books, currency);
|
||||
s.vault_key_file = self.vault_key_file.clone();
|
||||
self.current = Arc::new(s);
|
||||
self.current = Arc::new(Settings::from_books(books, currency));
|
||||
self.journal_seq = books.last_seq();
|
||||
self.read_files = false;
|
||||
self.error = None;
|
||||
self.loads += 1;
|
||||
}
|
||||
|
||||
/// The settings files' texts as the server reads them now
|
||||
/// (`wanderbooks.toml`, `config.json`; `None` for one absent), when the
|
||||
/// settings come from files; `None` when the books keep their own or
|
||||
/// the settings were built in code.
|
||||
pub fn file_texts(&self) -> Option<(Option<String>, Option<String>)> {
|
||||
let source = self.source.as_ref().filter(|_| self.read_files)?;
|
||||
Some((
|
||||
std::fs::read_to_string(&source.books_toml).ok(),
|
||||
std::fs::read_to_string(&source.config_json).ok(),
|
||||
))
|
||||
}
|
||||
|
||||
/// The vault key file given on the command line, if any.
|
||||
pub fn vault_key_override(&self) -> Option<PathBuf> {
|
||||
self.vault_key_file.clone()
|
||||
}
|
||||
|
||||
/// Whether the settings in force are the books' own.
|
||||
pub fn is_journaled(&self) -> bool {
|
||||
self.own || self.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
pub fn current(&self) -> Arc<Settings> {
|
||||
self.current.clone()
|
||||
}
|
||||
|
||||
/// Reload if a file changed since the last look: the server calls this
|
||||
/// once at the start of every request, so the whole request sees one
|
||||
/// version.
|
||||
pub fn refresh(&mut self) {
|
||||
if !self.read_files {
|
||||
// The books keep their own settings (or the settings were
|
||||
// built in code): no file is read (INVARIANT 1).
|
||||
return;
|
||||
}
|
||||
if let Some(source) = &self.source {
|
||||
let now = source.read();
|
||||
if now != self.seen {
|
||||
self.seen = now;
|
||||
match source.load() {
|
||||
Ok(mut s) => {
|
||||
if let Some(p) = &self.vault_key_file {
|
||||
s.vault_key_file = Some(p.clone());
|
||||
}
|
||||
self.current = Arc::new(s);
|
||||
self.error = None;
|
||||
self.loads += 1;
|
||||
eprintln!("ledger-mcp: reloaded {}", source.books_toml.display());
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!(
|
||||
"ledger-mcp: settings not reloaded (keeping the last good ones): {e}"
|
||||
);
|
||||
self.error = Some(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Change the settings in force (builders; never reloads them away
|
||||
/// unless a watched file changes).
|
||||
#[cfg(test)]
|
||||
pub fn edit(&mut self, f: impl FnOnce(&mut Settings)) {
|
||||
f(Arc::make_mut(&mut self.current));
|
||||
}
|
||||
|
||||
/// Where the settings come from and whether the last change loaded.
|
||||
pub fn status(&mut self) -> Status {
|
||||
self.refresh();
|
||||
let journaled = self.is_journaled();
|
||||
Status {
|
||||
books_toml: self
|
||||
.source
|
||||
.as_ref()
|
||||
.filter(|_| !journaled)
|
||||
.map(|s| s.books_toml.clone()),
|
||||
books_toml_exists: self.source.as_ref().is_some_and(|s| s.books_toml.exists()),
|
||||
config_json_present: self.source.as_ref().is_some_and(|s| s.config_json.exists()),
|
||||
error: self.error.clone(),
|
||||
loads: self.loads,
|
||||
journaled,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn dir(tag: &str) -> TestDir {
|
||||
let d =
|
||||
std::env::temp_dir().join(format!("ledger-mcp-settings-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TestDir(d)
|
||||
}
|
||||
|
||||
fn source(d: &std::path::Path, required: bool) -> Source {
|
||||
Source {
|
||||
books_toml: d.join("wanderbooks.toml"),
|
||||
required,
|
||||
config_json: d.join("config.json"),
|
||||
}
|
||||
}
|
||||
|
||||
/// INVARIANT 3: an absent default means the defaults, and the file
|
||||
/// loads once it appears; an unchanged file is not reloaded.
|
||||
/// INVARIANT 1: books that hold no settings keep the defaults; their
|
||||
/// settings come in once per change, and an unchanged journal leaves
|
||||
/// the settings in force as they are.
|
||||
#[test]
|
||||
fn an_absent_default_is_the_defaults_until_it_appears() {
|
||||
let d = dir("absent");
|
||||
let mut live = Live::watching(source(&d, false)).unwrap();
|
||||
fn books_take_their_own_settings_once_per_change() {
|
||||
let mut live = Live::own();
|
||||
assert_eq!(live.current().company.name, Company::default().name);
|
||||
live.refresh();
|
||||
assert_eq!(live.status().loads, 1, "nothing changed");
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
"[company]\nname = \"Late Co\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
live.refresh();
|
||||
assert_eq!(live.current().company.name, "Late Co");
|
||||
assert_eq!(live.status().loads, 2);
|
||||
assert!(live.status().books_toml_exists);
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a named file must exist to start; INVARIANT 2: the
|
||||
/// first load fails on broken settings, a later one keeps the last.
|
||||
#[test]
|
||||
fn a_named_file_must_load_to_start() {
|
||||
let d = dir("named");
|
||||
assert!(Live::watching(source(&d, true)).is_err());
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
|
||||
assert!(Live::watching(source(&d, true)).is_err());
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = \"One\"\n").unwrap();
|
||||
let mut live = Live::watching(source(&d, true)).unwrap();
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[company]\nname = 5\n").unwrap();
|
||||
live.refresh();
|
||||
assert_eq!(live.current().company.name, "One");
|
||||
assert!(live.status().error.is_some());
|
||||
}
|
||||
|
||||
/// config.json sits in the data dir, which no tool result names: a
|
||||
/// broken one is reported by name, never by its path.
|
||||
#[test]
|
||||
fn a_broken_config_json_never_names_the_data_dir() {
|
||||
let d = dir("json-broken");
|
||||
std::fs::write(d.join("config.json"), "{ not json").unwrap();
|
||||
let err = Live::watching(source(&d, false)).unwrap_err();
|
||||
assert!(err.contains("config.json"), "{err}");
|
||||
assert!(!err.contains(d.to_str().unwrap()), "{err}");
|
||||
let status = Live::fixed(Settings::default()).status();
|
||||
assert!(!status.config_json_present);
|
||||
}
|
||||
|
||||
/// The receipt policy in config.json is watched too.
|
||||
#[test]
|
||||
fn the_config_json_policy_reloads() {
|
||||
let d = dir("json");
|
||||
std::fs::write(
|
||||
d.join("wanderbooks.toml"),
|
||||
"[books]\nreceipt_threshold = \"25.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let mut live = Live::watching(source(&d, true)).unwrap();
|
||||
let before = live.current().policy.rule("USD");
|
||||
std::fs::write(
|
||||
d.join("config.json"),
|
||||
r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#,
|
||||
)
|
||||
.unwrap();
|
||||
live.refresh();
|
||||
assert_ne!(live.current().policy.rule("USD"), before);
|
||||
assert!(live.status().error.is_none(), "{:?}", live.status().error);
|
||||
}
|
||||
|
||||
/// Settings built in code never reload.
|
||||
#[test]
|
||||
fn fixed_settings_stay() {
|
||||
let mut live = Live::fixed(Settings::default());
|
||||
live.refresh();
|
||||
let s = live.status();
|
||||
assert_eq!((s.loads, s.books_toml), (1, None));
|
||||
}
|
||||
|
||||
/// Books that keep their own settings: no file is read, even a broken
|
||||
/// one; their settings come in once per change (INVARIANT 1).
|
||||
#[test]
|
||||
fn journaled_books_take_their_own_settings_once_per_change() {
|
||||
let d = dir("journaled");
|
||||
std::fs::write(d.join("wanderbooks.toml"), "[books\n").unwrap();
|
||||
let mut live = Live::journaled(source(&d, true));
|
||||
assert!(live.current().journaled);
|
||||
assert!(live.is_journaled());
|
||||
assert!(live.file_texts().is_none());
|
||||
let mut books = ledger_core::settings::Settings::default();
|
||||
let before = live.current();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 0, "books without settings");
|
||||
assert!(
|
||||
Arc::ptr_eq(&before, &live.current()),
|
||||
"books without settings"
|
||||
);
|
||||
let src = ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
@@ -533,54 +145,14 @@ mod tests {
|
||||
books.apply(&[change("Kept Co")], &src, 7).unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.current().company.name, "Kept Co");
|
||||
assert_eq!(live.status().loads, 1);
|
||||
let taken = live.current();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 1, "unchanged: not taken again");
|
||||
assert!(
|
||||
Arc::ptr_eq(&taken, &live.current()),
|
||||
"unchanged: not taken again"
|
||||
);
|
||||
books.apply(&[change("Kept Co 2")], &src, 9).unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.current().company.name, "Kept Co 2");
|
||||
assert_eq!(live.status().loads, 2);
|
||||
assert!(live.current().journaled && live.is_journaled());
|
||||
}
|
||||
|
||||
/// Settings built in code are the books' own once synced.
|
||||
#[test]
|
||||
fn fixed_settings_synced_are_journaled() {
|
||||
let mut live = Live::fixed(Settings::default());
|
||||
assert!(!live.is_journaled());
|
||||
let mut books = ledger_core::settings::Settings::default();
|
||||
let src = ledger_core::settings::Source {
|
||||
decided_by: "Owner".into(),
|
||||
decided_on: chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
books
|
||||
.apply(
|
||||
&[ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!("Kept Co")),
|
||||
}],
|
||||
&src,
|
||||
3,
|
||||
)
|
||||
.unwrap();
|
||||
live.sync(&books, "USD");
|
||||
assert!(live.is_journaled());
|
||||
}
|
||||
|
||||
/// Books in the data root keep their own settings from the start
|
||||
/// (INVARIANT 6): before they hold any, the settings in force say so
|
||||
/// too, so no message sends anyone to a settings file; none is read.
|
||||
#[test]
|
||||
fn books_in_the_data_root_keep_their_own_settings_before_they_hold_any() {
|
||||
let mut live = Live::own();
|
||||
assert!(live.current().journaled);
|
||||
assert!(live.is_journaled());
|
||||
assert!(live.file_texts().is_none());
|
||||
live.sync(&ledger_core::settings::Settings::default(), "USD");
|
||||
assert!(live.current().journaled);
|
||||
let s = live.status();
|
||||
assert!(s.journaled && s.books_toml.is_none(), "{s:?}");
|
||||
}
|
||||
}
|
||||
@@ -66,9 +66,6 @@ impl BooksServer {
|
||||
params: Parameters<AccessLogParams>,
|
||||
) -> Result<Json<AccessLogOut>, String> {
|
||||
let p = params.0;
|
||||
if self.access.is_none() {
|
||||
return Err("these books keep no access log: only books served from the books' private folder do".into());
|
||||
}
|
||||
let limit = p.limit.unwrap_or(DEFAULT_RECORDS);
|
||||
if limit == 0 || limit > MAX_RECORDS {
|
||||
return Err(format!("limit is 1 to {MAX_RECORDS}"));
|
||||
|
||||
@@ -7,9 +7,7 @@
|
||||
//! SOURCE: plan 3.7 ("books.backup {status|run} reports it": two tools,
|
||||
//! one read-only), D-30.
|
||||
//! INVARIANTS:
|
||||
//! 1. Only books served from the data root are backed up: over stdio
|
||||
//! both tools are hidden, and refuse.
|
||||
//! 2. A status reads the backup record only; a run makes and pushes a
|
||||
//! 1. A status reads the backup record only; a run makes and pushes a
|
||||
//! backup (backup.rs) and answers the status after it.
|
||||
//! ```
|
||||
|
||||
@@ -86,14 +84,6 @@ impl From<crate::backup::Status> for BackupOut {
|
||||
}
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
fn backups(&self) -> Result<&std::sync::Arc<crate::backup::Backups>, String> {
|
||||
self.backups.as_ref().ok_or_else(|| {
|
||||
"these books aren't backed up by this server: only books kept in the books' own private folder are".to_string()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_router(router = backup_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
@@ -103,7 +93,7 @@ impl BooksServer {
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn backup(&self, _params: Parameters<BackupParams>) -> Result<Json<BackupOut>, String> {
|
||||
Ok(Json(self.backups()?.status().await.into()))
|
||||
Ok(Json(self.backups.status().await.into()))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -121,7 +111,11 @@ impl BooksServer {
|
||||
&self,
|
||||
params: Parameters<BackUpNowParams>,
|
||||
) -> Result<Json<BackupOut>, String> {
|
||||
let b = self.backups()?;
|
||||
Ok(Json(b.run_with("asked", params.0.new_disk).await?.into()))
|
||||
Ok(Json(
|
||||
self.backups
|
||||
.run_with("asked", params.0.new_disk)
|
||||
.await?
|
||||
.into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -17,17 +17,17 @@
|
||||
//! 2. begin_upload makes nothing for a receipt or document whose bytes
|
||||
//! are stored already: it names the attachment (not withdrawn) that
|
||||
//! holds them.
|
||||
//! 3. Uploads exist only for books served from the data root (the
|
||||
//! server's socket carries the bytes); over stdio, only inline.
|
||||
//! 3. An upload is kept for the token that began it, and so is an
|
||||
//! export: a call with no token (never one the books server lets
|
||||
//! through) begins and takes none.
|
||||
//! 4. An upload is filed only by a tool of the purpose it was begun for
|
||||
//! (a receipt or document, a CSV import, a W-9); another tool's
|
||||
//! refusal leaves it for the right one.
|
||||
//! 5. Inline text longer than INLINE_MAX bytes' base64 is refused
|
||||
//! before it is decoded.
|
||||
//! 6. A file the books make for an agent (D-29) is handed over as an
|
||||
//! export the caller's token fetches with `wanderbooks download`
|
||||
//! (books served from the data root), or over stdio inline; never
|
||||
//! by a path.
|
||||
//! export the caller's token fetches with `wanderbooks download`;
|
||||
//! never inline, never by a path.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
@@ -37,8 +37,10 @@ use serde::{Deserialize, Serialize};
|
||||
/// The most bytes given inline (plan 2.1: 256 KiB).
|
||||
pub(crate) const INLINE_MAX: usize = 256 * 1024;
|
||||
|
||||
/// Why an upload is refused on a connection that has none (INVARIANT 3).
|
||||
const STDIO_ONLY_INLINE: &str = "this connection takes files inline only (content_base64 with its sha256, at most 256 KiB): a larger one needs these books served by `wanderbooks mcp`; tell the human";
|
||||
/// Why a call with no token begins, takes or is handed no file
|
||||
/// (INVARIANT 3).
|
||||
const NO_CALLER: &str =
|
||||
"this call came without a token for these books: a file is kept only for the token that asked";
|
||||
|
||||
/// What the bytes of a file a tool files came as (INVARIANT 1), and the
|
||||
/// upload purposes the tool takes (INVARIANT 4).
|
||||
@@ -146,7 +148,7 @@ fn plain_file_name(name: &str) -> String {
|
||||
|
||||
impl BooksServer {
|
||||
/// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export
|
||||
/// for the caller's token, or inline over stdio.
|
||||
/// for the caller's token.
|
||||
pub(crate) fn hand_over(
|
||||
&self,
|
||||
book: &str,
|
||||
@@ -160,23 +162,9 @@ impl BooksServer {
|
||||
None => file_name.to_string(),
|
||||
Some(_) => plain_file_name(file_name),
|
||||
};
|
||||
let mut out = HandedFile {
|
||||
file_name: file_name.clone(),
|
||||
mime_type: mime.to_string(),
|
||||
size_bytes: bytes.len() as u64,
|
||||
sha256: ledger_store::journal::sha256_hex(&bytes),
|
||||
download: None,
|
||||
expires_at: None,
|
||||
content_base64: None,
|
||||
};
|
||||
let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else {
|
||||
out.content_base64 = Some(base64::Engine::encode(
|
||||
&base64::engine::general_purpose::STANDARD,
|
||||
&bytes,
|
||||
));
|
||||
return Ok(out);
|
||||
};
|
||||
let kept = exports
|
||||
let token = self.caller_token().ok_or(NO_CALLER)?;
|
||||
let kept = self
|
||||
.exports
|
||||
.put(
|
||||
&token,
|
||||
&file_name,
|
||||
@@ -185,14 +173,22 @@ impl BooksServer {
|
||||
chrono::Utc::now().timestamp(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
out.download = Some(format!(
|
||||
"wanderbooks download --book {book}{} {} --to <a folder>",
|
||||
token_flag(),
|
||||
kept.id
|
||||
));
|
||||
out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
|
||||
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
|
||||
Ok(out)
|
||||
// Always a real time: a kept export expires EXPORT_TTL from now.
|
||||
let expires_at = chrono::DateTime::from_timestamp(kept.expires, 0)
|
||||
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||
.ok_or("the file's expiry is past the calendar")?;
|
||||
Ok(HandedFile {
|
||||
download: format!(
|
||||
"wanderbooks download --book {book}{} {} --to <a folder>",
|
||||
token_flag(),
|
||||
kept.id
|
||||
),
|
||||
expires_at,
|
||||
size_bytes: bytes.len() as u64,
|
||||
sha256: ledger_store::journal::sha256_hex(&bytes),
|
||||
file_name,
|
||||
mime_type: mime.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
/// The token of the call running on this thread (its id), if any.
|
||||
@@ -210,10 +206,9 @@ impl BooksServer {
|
||||
),
|
||||
(Some(id), None) => {
|
||||
// INVARIANT 3.
|
||||
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
|
||||
return Err(STDIO_ONLY_INLINE.into());
|
||||
};
|
||||
let got = uploads
|
||||
let token = self.caller_token().ok_or(NO_CALLER)?;
|
||||
let got = self
|
||||
.uploads
|
||||
.take(id.trim(), &token, chrono::Utc::now().timestamp())
|
||||
.map_err(|e| e.to_string())?;
|
||||
// INVARIANT 4: dropped, the claim gives it back.
|
||||
@@ -268,7 +263,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.begin_upload",
|
||||
title = "Begin an upload",
|
||||
description = "Send a file of up to 25 MiB (books served by `wanderbooks mcp`; elsewhere only inline) without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
|
||||
description = "Send a file of up to 25 MiB without its bytes passing through you: give its sha256 (sha256sum), size, type, name and purpose; run the command returned with the file's path; then name the upload_id in books.attach_receipt, books.file_document, books.set_w9, books.import_bank_csv or books.import_chart_csv. Purpose: receipt or document (those two tools), import (the CSV imports), w9. If a receipt's or document's bytes are stored already, existing_attachment_id names them: link that instead. An upload lasts 15 minutes and is used once.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -313,11 +308,10 @@ impl BooksServer {
|
||||
existing_attachment_id: Some(id),
|
||||
}));
|
||||
}
|
||||
let (Some(uploads), Some(token)) = (&self.uploads, self.caller_token()) else {
|
||||
return Err(STDIO_ONLY_INLINE.into());
|
||||
};
|
||||
let token = self.caller_token().ok_or(NO_CALLER)?;
|
||||
let now = chrono::Utc::now();
|
||||
let grant = uploads
|
||||
let grant = self
|
||||
.uploads
|
||||
.grant(
|
||||
&token,
|
||||
&sha,
|
||||
@@ -328,9 +322,10 @@ impl BooksServer {
|
||||
now.timestamp(),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
// Always a real time: a grant lapses UPLOAD_TTL from now.
|
||||
let expires = chrono::DateTime::from_timestamp(grant.expires, 0)
|
||||
.map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
|
||||
.unwrap_or_default();
|
||||
.ok_or("the upload's expiry is past the calendar")?;
|
||||
Ok(Json(BeginUploadOut {
|
||||
command: Some(format!(
|
||||
"wanderbooks upload --book {book}{} {} -- <the file's path>",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
//! How the books are kept, through the server: `books.start`, settings
|
||||
//! (`books.settings`, `books.set_setting`, `books.import_settings`,
|
||||
//! `books.export_settings`), conventions, decisions, open items, drafts
|
||||
//! from posting templates, and approvals (plan/served.md P1, 4.2, 4.9).
|
||||
//! (`books.settings`, `books.set_setting`, `books.export_settings`),
|
||||
//! conventions, decisions, open items, drafts from posting templates,
|
||||
//! and approvals (plan/served.md P1, 4.2, 4.9).
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent anywhere learns how these books are kept from the books
|
||||
@@ -12,17 +12,14 @@
|
||||
//! INVARIANTS:
|
||||
//! 1. Every change names who decided it and when (decided_by;
|
||||
//! decided_on defaults to today in the company's time zone).
|
||||
//! 2. A settings file is never half moved in: books.set_setting refuses
|
||||
//! while the books still read a settings file (import it first), and
|
||||
//! books.import_settings is refused unless, after it, the server
|
||||
//! reads every setting as it does now (company, receipt rule, cash
|
||||
//! flow roles, asset tag prefix, parties, time zone, vault key).
|
||||
//! 2. The books keep their own settings, and only books.set_setting
|
||||
//! changes them (old books' settings file is read once, by
|
||||
//! `wanderbooks books adopt`).
|
||||
//! 3. Identity values (the company's EIN) leave the server masked.
|
||||
//! 4. books.start is read-only and at most START_MAX bytes.
|
||||
//! ```
|
||||
|
||||
use super::*;
|
||||
use crate::settings::Settings as Live;
|
||||
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{self as core_settings, Change, KEYS, Sensitivity, Source};
|
||||
use schemars::JsonSchema;
|
||||
@@ -242,10 +239,6 @@ pub struct StartOut {
|
||||
pub head: HeadOut,
|
||||
/// needs_onboarding (no chart of accounts yet) or active.
|
||||
pub state: String,
|
||||
/// Where the settings come from: "books" (journaled, the books' own)
|
||||
/// or "file" (these books still read a settings file: import it with
|
||||
/// books.import_settings).
|
||||
pub settings_source: String,
|
||||
/// The rules in force.
|
||||
pub rules: RulesOut,
|
||||
/// Every setting set, with who set it.
|
||||
@@ -322,54 +315,12 @@ pub struct SetSettingOut {
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.import_settings`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct ImportSettingsParams {
|
||||
/// The settings file's text (wanderbooks.toml), as written.
|
||||
pub toml_text: String,
|
||||
/// The receipt policy file's text ({"receipt_policy": {...}}), when
|
||||
/// books.settings says the server reads one (config_json_present):
|
||||
/// rebuild it from books.settings' receipt rule and account lists.
|
||||
#[serde(default)]
|
||||
pub config_json_text: Option<String>,
|
||||
/// Without true: a preview of every change, note and unknown entry;
|
||||
/// nothing is recorded.
|
||||
#[serde(default)]
|
||||
pub confirmed: bool,
|
||||
/// Import even though the file holds entries the books don't know
|
||||
/// (listed in the preview as unknown; they are not imported).
|
||||
#[serde(default)]
|
||||
pub skip_unknown: bool,
|
||||
/// Who decided to move the settings in, and when.
|
||||
#[serde(flatten)]
|
||||
pub source: SourceIn,
|
||||
}
|
||||
|
||||
/// Result of `books.import_settings`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ImportSettingsOut {
|
||||
/// "preview" or "imported".
|
||||
pub status: String,
|
||||
/// The changes that make the books' settings what the file says.
|
||||
pub changes: Vec<ChangeOut>,
|
||||
/// What the file says that isn't a books setting, and where it goes.
|
||||
pub notes: Vec<String>,
|
||||
/// Entries the books don't know: not imported (confirm with
|
||||
/// skip_unknown, or record them as conventions first).
|
||||
pub unknown: Vec<String>,
|
||||
/// The file's comments, in order. They are not settings and are not
|
||||
/// imported: record what matters in them (a value's source, a
|
||||
/// discrepancy, something pending) as a decision, an open item, a
|
||||
/// convention, or a setting's note, before or after confirming.
|
||||
pub comments: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.export_settings`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ExportSettingsOut {
|
||||
/// The settings as a settings file (TOML), each with who decided it
|
||||
/// as a comment; identity values masked. A copy: editing it changes
|
||||
/// nothing until imported.
|
||||
/// nothing.
|
||||
pub toml_text: String,
|
||||
}
|
||||
|
||||
@@ -740,51 +691,12 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut {
|
||||
}
|
||||
}
|
||||
|
||||
/// Where two server readings of the settings differ (INVARIANT 2).
|
||||
fn reads_differently(a: &Live, b: &Live) -> Vec<&'static str> {
|
||||
let threshold = |s: &Live| ledger_config::decimal(s.policy.require_receipts_above.trim());
|
||||
[
|
||||
("company", a.company.name == b.company.name),
|
||||
(
|
||||
"fiscal year",
|
||||
a.company.fiscal_year_start == b.company.fiscal_year_start,
|
||||
),
|
||||
("members", a.company.members == b.company.members),
|
||||
("basis", a.company.basis == b.company.basis),
|
||||
("tax form", a.company.tax_form == b.company.tax_form),
|
||||
("close target", a.company.close_into == b.company.close_into),
|
||||
(
|
||||
"close sweep",
|
||||
a.company.close_sweep == b.company.close_sweep,
|
||||
),
|
||||
("receipt threshold", threshold(a) == threshold(b)),
|
||||
(
|
||||
"receipt required accounts",
|
||||
a.policy.receipt_required_accounts == b.policy.receipt_required_accounts,
|
||||
),
|
||||
(
|
||||
"receipt exempt accounts",
|
||||
a.policy.receipt_exempt_accounts == b.policy.receipt_exempt_accounts,
|
||||
),
|
||||
("cash accounts", a.roles.cash == b.roles.cash),
|
||||
("financing accounts", a.roles.financing == b.roles.financing),
|
||||
("investing accounts", a.roles.investing == b.roles.investing),
|
||||
("asset tag prefix", a.tag_prefix() == b.tag_prefix()),
|
||||
("parties", a.parties == b.parties),
|
||||
("time zone", a.timezone == b.timezone),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, same)| !same)
|
||||
.map(|(n, _)| n)
|
||||
.collect()
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The books' `books.start` (INVARIANT 4).
|
||||
fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut {
|
||||
let cfg = self.settings();
|
||||
let ledger = store.ledger();
|
||||
let kept = self.settings_as_kept(store);
|
||||
let kept = store.ledger().settings().clone();
|
||||
let books = &kept;
|
||||
let today = today();
|
||||
let (fy_start, fy_end) = cfg.company.fiscal_year_containing(today);
|
||||
@@ -843,7 +755,6 @@ impl BooksServer {
|
||||
projects.sort_by_key(|a| (a.name.to_lowercase(), a.id));
|
||||
let mut p_cut = projects.len().saturating_sub(START_PROJECTS);
|
||||
projects.truncate(START_PROJECTS);
|
||||
let journaled = cfg.journaled;
|
||||
let focus = focus.unwrap_or("daily");
|
||||
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
|
||||
tool: tool.into(),
|
||||
@@ -862,15 +773,6 @@ impl BooksServer {
|
||||
"Tell the human first: these books wait for a format upgrade, which only the owner can agree to (in the app, which asks when it opens the books, or `wanderbooks books upgrade` at a terminal, with the owner passphrase; a verified backup is made first). Until then they work on their old format and refuse what needs the new one (format_upgrade_pending). No tool can upgrade them.",
|
||||
));
|
||||
}
|
||||
// Books that read a settings file move it in before anything else
|
||||
// changes a setting (books.set_setting refuses until then).
|
||||
if !journaled {
|
||||
next_steps.push(step(
|
||||
"books.import_settings",
|
||||
None,
|
||||
"These books still read a settings file. Move it into the books (preview, show the human, confirm), so every agent sees one record and no file is read.",
|
||||
));
|
||||
}
|
||||
if onboarding || focus == "onboarding" {
|
||||
next_steps.push(step(
|
||||
"books.handbook",
|
||||
@@ -893,8 +795,8 @@ impl BooksServer {
|
||||
}
|
||||
// The books' backups (backup.rs): none set up, or one behind or
|
||||
// failing.
|
||||
if let Some(b) = &self.backups {
|
||||
let st = b.standing(store);
|
||||
{
|
||||
let st = self.backups.standing(store);
|
||||
if st.note.is_some() {
|
||||
next_steps.push(step(
|
||||
"books.backup",
|
||||
@@ -958,12 +860,10 @@ impl BooksServer {
|
||||
"active"
|
||||
}
|
||||
.into(),
|
||||
settings_source: if journaled { "books" } else { "file" }.into(),
|
||||
rules: RulesOut {
|
||||
evidence: cfg.policy.rule(self.currency()),
|
||||
parties: cfg.parties.key().into(),
|
||||
// What the server enforces: the books' own setting (a
|
||||
// settings file's is never enforced).
|
||||
// What the server enforces: the books' own setting.
|
||||
approval_rules: match ledger.settings().approval_rules() {
|
||||
core_settings::ApprovalMode::Off => "off",
|
||||
core_settings::ApprovalMode::Enforce => "enforce",
|
||||
@@ -1047,19 +947,6 @@ impl BooksServer {
|
||||
out
|
||||
}
|
||||
|
||||
/// Refuse a settings change while the books still read a settings
|
||||
/// file that sets something (INVARIANT 2): it would drop the rest.
|
||||
fn ensure_settings_in_books(&self) -> Result<(), String> {
|
||||
let status = self.live().status();
|
||||
if !status.journaled && (status.books_toml_exists || status.config_json_present) {
|
||||
return Err(
|
||||
"these books still read their settings file: move it in with books.import_settings first (preview, then confirm), so no setting is lost; then change settings here"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The settings in force after a settings change: take the books'.
|
||||
fn sync_settings(&self, store: &Store) {
|
||||
self.live().sync(store.ledger().settings(), self.currency());
|
||||
@@ -1090,7 +977,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_setting",
|
||||
title = "Change settings",
|
||||
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing. Refused while the books still read a settings file: import it first (books.import_settings).",
|
||||
description = "Change one or more settings, together or not at all, recording who decided and when. Each change is {key, value}: value as the key takes it (books.settings lists every key and what it takes), or null to clear it back to its default. Values are checked and kept in one spelling; a change to the value already set changes nothing.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
@@ -1104,7 +991,6 @@ impl BooksServer {
|
||||
) -> Result<Json<SetSettingOut>, String> {
|
||||
let p = params.0;
|
||||
let source = p.source.source()?;
|
||||
self.ensure_settings_in_books()?;
|
||||
if p.changes.is_empty() {
|
||||
return Err("changes is empty: pass at least one {key, value}".into());
|
||||
}
|
||||
@@ -1133,173 +1019,16 @@ impl BooksServer {
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.import_settings",
|
||||
title = "Move a settings file into the books",
|
||||
description = "Move a settings file (its TOML text, and the receipt policy file's text if the server reads one) into the books, after which no file is read. Without confirmed: a preview of every change, what the file says that isn't a books setting (notes), and entries the books don't know (unknown). Show it to the human; confirm with confirmed: true. Refused if afterwards any setting would read differently than now.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = true,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn import_settings(
|
||||
&self,
|
||||
params: Parameters<ImportSettingsParams>,
|
||||
) -> Result<Json<ImportSettingsOut>, String> {
|
||||
let p = params.0;
|
||||
let source = p.source.source()?;
|
||||
let mut store = self.lock()?;
|
||||
let status = self.live().status();
|
||||
if let Some(e) = &status.error {
|
||||
return Err(format!(
|
||||
"the settings file doesn't load ({e}): fix it first, so the import moves in the settings in force"
|
||||
));
|
||||
}
|
||||
let current = store.ledger().settings().clone();
|
||||
let import = ledger_config::codec::import(
|
||||
&p.toml_text,
|
||||
p.config_json_text.as_deref(),
|
||||
¤t,
|
||||
self.currency(),
|
||||
)?;
|
||||
let differing = |theirs: &[Change]| -> Vec<String> {
|
||||
let mut keys: Vec<String> = import
|
||||
.changes
|
||||
.iter()
|
||||
.filter(|c| !theirs.contains(c))
|
||||
.chain(theirs.iter().filter(|c| !import.changes.contains(c)))
|
||||
.map(|c| c.key.clone())
|
||||
.collect();
|
||||
keys.sort();
|
||||
keys.dedup();
|
||||
keys
|
||||
};
|
||||
if status.journaled {
|
||||
// The books keep their own settings: an import may only confirm
|
||||
// them (INVARIANT 2); changes go through books.set_setting.
|
||||
let differs = differing(&[]);
|
||||
if !differs.is_empty() {
|
||||
return Err(format!(
|
||||
"these books keep their own settings, and this text differs from them in {}: change settings with books.set_setting (the books record who decided each change)",
|
||||
differs.join(", ")
|
||||
));
|
||||
}
|
||||
} else if let Some((toml, json)) = self.live().file_texts() {
|
||||
// INVARIANT 2: the text is the file the server reads, exactly:
|
||||
// its import is the server's own reading of its files.
|
||||
let own = ledger_config::codec::import(
|
||||
toml.as_deref().unwrap_or(""),
|
||||
json.as_deref(),
|
||||
¤t,
|
||||
self.currency(),
|
||||
)?;
|
||||
let differs = differing(&own.changes);
|
||||
if !differs.is_empty() {
|
||||
return Err(format!(
|
||||
"this text isn't the settings the server reads now: it differs in {}. Pass the settings file books.settings names, whole, as toml_text{}",
|
||||
differs.join(", "),
|
||||
if json.is_some() {
|
||||
", and the receipt policy as config_json_text: {\"receipt_policy\": {...}} with books.settings' threshold and its required and exempt account lists"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
));
|
||||
}
|
||||
}
|
||||
// And, after it, the server reads every setting as now (the
|
||||
// company, the receipt rule, the cash flow roles, the asset tag
|
||||
// prefix, parties, the time zone, approvals).
|
||||
let mut after = current.clone();
|
||||
after
|
||||
.apply(&import.changes, &source, u64::MAX)
|
||||
.map_err(bad)?;
|
||||
let now = self.settings();
|
||||
let then = Live::from_books(&after, self.currency());
|
||||
let differs = reads_differently(&now, &then);
|
||||
if !differs.is_empty() {
|
||||
return Err(format!(
|
||||
"after this import the server would read these settings differently than it does now: {}",
|
||||
differs.join(", ")
|
||||
));
|
||||
}
|
||||
if !status.journaled && after.approval_rules() != ledger_core::settings::ApprovalMode::Off {
|
||||
return Err(
|
||||
"the server doesn't enforce approval rules for books that read a settings file: take approval_rules out of the file, import it, then set policy.approval_rules with books.set_setting"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
// The vault key the server uses now, and after: the books never
|
||||
// keep its path, so a file's [vault] key_file has to come from the
|
||||
// command line from then on (settings INVARIANT 5).
|
||||
let key_now = now.key_file();
|
||||
let key_then = self
|
||||
.live()
|
||||
.vault_key_override()
|
||||
.unwrap_or_else(ledger_vault::default_key_file);
|
||||
if key_now != key_then {
|
||||
return Err(
|
||||
"the settings file names the vault's key file ([vault] key_file), which the books don't keep: start the server with --vault-key-file naming that same file before moving the settings in, or the vault would stop opening"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let changes = |list: &[Change]| -> Vec<ChangeOut> {
|
||||
list.iter()
|
||||
.map(|c| ChangeOut {
|
||||
key: c.key.clone(),
|
||||
was: shown(&c.key, current.get(&c.key).map(|r| &r.value)),
|
||||
value: shown(&c.key, c.value.as_ref()),
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
// The preview refuses what the import would: the same checks.
|
||||
store
|
||||
.ledger()
|
||||
.check_settings_adopting(&import.changes, &source, true)
|
||||
.map_err(bad)?;
|
||||
let mut out = ImportSettingsOut {
|
||||
status: "preview".into(),
|
||||
changes: changes(&import.changes),
|
||||
notes: import.notes.clone(),
|
||||
unknown: import.unknown.clone(),
|
||||
comments: import.comments.clone(),
|
||||
};
|
||||
if !p.confirmed {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
if !import.unknown.is_empty() && !p.skip_unknown {
|
||||
return Err(format!(
|
||||
"the file holds entries the books don't know ({}): they would not be imported. Show them to the human; record any that matter as conventions (books.set_convention), then confirm with skip_unknown: true",
|
||||
import.unknown.join(", ")
|
||||
));
|
||||
}
|
||||
let made = store
|
||||
.adopt_settings(&import.changes, &source)
|
||||
.map_err(bad)?;
|
||||
self.sync_settings(&store);
|
||||
out.status = "imported".into();
|
||||
out.changes = changes(&made);
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.export_settings",
|
||||
title = "Settings as a file",
|
||||
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading or for other books: editing it changes nothing here until imported.",
|
||||
description = "The books' settings as settings-file text (TOML), each with who decided it and when as a comment, identity values masked. A copy for reading: editing it changes nothing here.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn export_settings(&self) -> Result<Json<ExportSettingsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
// The books' own: the defaults until the first is set.
|
||||
let s = store.ledger().settings();
|
||||
// Books in the data root read no file: theirs are the defaults
|
||||
// until the first is set.
|
||||
if !s.is_journaled() && !self.live().status().journaled {
|
||||
return Err(
|
||||
"these books still read their settings file, which is the copy: move it in with books.import_settings first"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
Ok(Json(ExportSettingsOut {
|
||||
toml_text: ledger_config::codec::export(s, self.currency()),
|
||||
}))
|
||||
@@ -1655,8 +1384,7 @@ pub(super) fn keys_out(books: &core_settings::Settings) -> Vec<SettingKeyOut> {
|
||||
identity: k.sensitivity == Sensitivity::Identity,
|
||||
value: r.map(|r| core_settings::display(k, &r.value, false)),
|
||||
set_by: r.map(|r| source_out(&r.source)),
|
||||
// Seq 0: read from the settings file, not journaled.
|
||||
seq: r.map(|r| r.seq).filter(|q| *q > 0),
|
||||
seq: r.map(|r| r.seq),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
|
||||
@@ -536,13 +536,13 @@ pub struct Vendor1099Params {
|
||||
#[serde(default)]
|
||||
pub csv: bool,
|
||||
/// The cash account codes payments are counted out of, e.g.
|
||||
/// `["1000"]`. Defaults to `[books] cash_accounts`, as the CPA
|
||||
/// `["1000"]`. Defaults to `books.cash_accounts`, as the CPA
|
||||
/// package's do: one or the other is required.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// The financing liability codes (what the members advanced, loans)
|
||||
/// payments are counted out of. Defaults to `[books]
|
||||
/// financing_accounts`, as the CPA package's do.
|
||||
/// payments are counted out of. Defaults to
|
||||
/// `books.financing_accounts`, as the CPA package's do.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// Count only the payments dated on or before this day of `year`
|
||||
@@ -782,13 +782,23 @@ pub struct ChartTemplateOut {
|
||||
/// Chart CSV (code,name,type,parent_code,tax_category) for
|
||||
/// `books.import_chart_csv`, after review.
|
||||
pub csv: String,
|
||||
/// The settings that go with it, as settings-file TOML for
|
||||
/// `books.import_settings`: tax form, owners, where period closes go.
|
||||
pub settings_toml: String,
|
||||
/// The settings that go with it, as changes for `books.set_setting`
|
||||
/// (after review, with who decided): tax form, owners, where period
|
||||
/// closes go.
|
||||
pub settings: Vec<TemplateSettingOut>,
|
||||
/// What to decide or know.
|
||||
pub notes: Vec<String>,
|
||||
}
|
||||
|
||||
/// One setting a chart template goes with.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct TemplateSettingOut {
|
||||
/// The key.
|
||||
pub key: String,
|
||||
/// Its value, as `books.set_setting` takes it.
|
||||
pub value: serde_json::Value,
|
||||
}
|
||||
|
||||
/// Parameters for `books.renumber_account`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RenumberAccountParams {
|
||||
@@ -807,10 +817,9 @@ pub struct RenumberAccountOut {
|
||||
pub previous_code: String,
|
||||
/// The code it has now.
|
||||
pub code: String,
|
||||
/// Settings that still name the old code (as books.set_setting keys,
|
||||
/// or the settings file's labels for books whose settings are in a
|
||||
/// file), and conventions whose template or approval rule names it:
|
||||
/// change each (books.set_setting, books.set_convention).
|
||||
/// Settings that still name the old code (as books.set_setting keys),
|
||||
/// and conventions whose template or approval rule names it: change
|
||||
/// each (books.set_setting, books.set_convention).
|
||||
pub config_mentions: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -832,10 +841,6 @@ pub struct MemberSettingOut {
|
||||
/// Result of `books.settings`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SettingsOut {
|
||||
/// Where the settings come from: "books" (the books keep them; change
|
||||
/// them with books.set_setting) or "file" (a settings file these books
|
||||
/// still read: move it in with books.import_settings).
|
||||
pub source: String,
|
||||
/// The company's time zone (IANA), or null: the computer's.
|
||||
pub timezone: Option<String>,
|
||||
/// Today, in that time zone.
|
||||
@@ -845,27 +850,14 @@ pub struct SettingsOut {
|
||||
/// Every setting the books know: what it takes, its value (null:
|
||||
/// unset, its default applies), and who set it.
|
||||
pub keys: Vec<crate::server::SettingKeyOut>,
|
||||
/// The wanderbooks.toml the server reads (null: the books keep their
|
||||
/// own settings, or settings built in).
|
||||
pub books_toml: Option<String>,
|
||||
/// Whether that file exists (absent: the defaults apply).
|
||||
pub books_toml_exists: bool,
|
||||
/// Whether the server reads a receipt policy from a configuration
|
||||
/// file of its own.
|
||||
pub config_json_present: bool,
|
||||
/// Why the last edit failed to load, or null. While set, the settings
|
||||
/// below are the last ones that loaded: fix the file.
|
||||
pub reload_error: Option<String>,
|
||||
/// How many times the settings have loaded (the first included); it
|
||||
/// goes up each time an edit loads.
|
||||
pub loads: u64,
|
||||
/// `[company] name`.
|
||||
/// `company.name`.
|
||||
pub company: String,
|
||||
/// `[company] fiscal_year_start`, "MM-DD".
|
||||
/// `company.fiscal_year_start`, "MM-DD".
|
||||
pub fiscal_year_start: String,
|
||||
/// The return filed (`tax_form`, or from `tax_classification`), or null.
|
||||
/// The return filed (`company.tax_form`, or from
|
||||
/// `company.tax_classification`), or null.
|
||||
pub tax_form: Option<String>,
|
||||
/// `[books] accounting_basis`.
|
||||
/// `books.accounting_basis`.
|
||||
pub basis: String,
|
||||
/// The receipt rule in force (it shows a few account codes of each
|
||||
/// kind, shortened; the two lists below hold them all).
|
||||
@@ -877,22 +869,22 @@ pub struct SettingsOut {
|
||||
/// every line is in one needs none): the policy's whole list, as
|
||||
/// written.
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
/// `[books] parties`: "off", "suggest", or "require".
|
||||
/// `policy.parties`: "off", "suggest", or "require".
|
||||
pub parties: String,
|
||||
/// `[[company.members]]`.
|
||||
/// `company.members`.
|
||||
pub members: Vec<MemberSettingOut>,
|
||||
/// `[books] cash_accounts`.
|
||||
/// `books.cash_accounts`.
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// `[books] financing_accounts`.
|
||||
/// `books.financing_accounts`.
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// `[books] investing_accounts`.
|
||||
/// `books.investing_accounts`.
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// `[books] close_into`.
|
||||
/// `books.close_into`.
|
||||
pub close_into: Option<String>,
|
||||
/// `[books] close_sweep`.
|
||||
/// `books.close_sweep`.
|
||||
pub close_sweep: Vec<String>,
|
||||
/// Settings that disagree with the books, each saying what to change in
|
||||
/// wanderbooks.toml (or the chart). Empty: nothing to fix.
|
||||
/// Settings that disagree with the books, each saying what to change
|
||||
/// (with books.set_setting, or in the chart). Empty: nothing to fix.
|
||||
pub problems: Vec<String>,
|
||||
}
|
||||
|
||||
@@ -1415,7 +1407,7 @@ pub struct DraftEntryParams {
|
||||
/// `vendor`, both must name the same vendor.
|
||||
pub party: Option<String>,
|
||||
/// Why the entry has no counterparty (a transfer between the company's
|
||||
/// own accounts, an accrual), when `[books] parties = "require"` and
|
||||
/// own accounts, an accrual), when `policy.parties = "require"` and
|
||||
/// none applies. Not with `party`, `parties`, or `vendor`.
|
||||
pub no_party_reason: Option<String>,
|
||||
/// Several parties, each with their share (as `books.set_entry_party`
|
||||
@@ -1605,7 +1597,7 @@ pub struct PostEntryOut {
|
||||
/// receipt threshold (same date, description, and debit accounts).
|
||||
/// Empty when no split is detected.
|
||||
pub split_detected: Vec<u64>,
|
||||
/// `[books] parties`: "off", "suggest", or "require".
|
||||
/// `policy.parties`: "off", "suggest", or "require".
|
||||
pub party_policy: String,
|
||||
/// The entry's counterparty, when it names exactly one.
|
||||
pub party_id: Option<u64>,
|
||||
@@ -2089,11 +2081,11 @@ pub struct DigestOut {
|
||||
/// lost; tell the human, and if they had asked for a change moments before,
|
||||
/// check it is there and redo it if not.
|
||||
pub journal_repairs: Vec<JournalRepair>,
|
||||
/// Settings in wanderbooks.toml that need changing: an edit that
|
||||
/// didn't load, or a setting that disagrees with the chart. Fix each
|
||||
/// with the human, then check books.settings.
|
||||
/// Settings that need changing: a setting that disagrees with the
|
||||
/// chart. Fix each with the human (books.set_setting), then check
|
||||
/// books.settings.
|
||||
pub settings_problems: Vec<String>,
|
||||
/// Cash and card accounts (`[books] cash_accounts`, and liabilities
|
||||
/// Cash and card accounts (`books.cash_accounts`, and liabilities
|
||||
/// with a recorded statement) with activity not reconciled through the
|
||||
/// end of last month: record each statement and reconcile it.
|
||||
pub unreconciled_accounts: Vec<UnreconciledAccountOut>,
|
||||
@@ -2167,17 +2159,17 @@ pub struct ExportParams {
|
||||
#[schemars(description = export_as_of_doc())]
|
||||
pub as_of: Option<String>,
|
||||
/// Cash account codes for cash_flow, and for iif its BANK accounts.
|
||||
/// Defaults to `[books] cash_accounts`; one or the other is required
|
||||
/// Defaults to `books.cash_accounts`; one or the other is required
|
||||
/// for cash_flow.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// Liability account codes treated as financing for cash_flow.
|
||||
/// Defaults to `[books] financing_accounts`, else empty.
|
||||
/// Defaults to `books.financing_accounts`, else empty.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// Asset account codes treated as investing for cash_flow: loans the
|
||||
/// company makes, investments it buys (ASC 230-10-20). Defaults to
|
||||
/// `[books] investing_accounts`.
|
||||
/// `books.investing_accounts`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
}
|
||||
@@ -2261,8 +2253,8 @@ pub struct RegisterAssetParams {
|
||||
/// Depreciation expense account, id or code.
|
||||
pub expense_account: String,
|
||||
/// The asset tag for its label (letters, digits, - _ . /; at most 32).
|
||||
/// Omit to generate the next one ("FA-0001", from `[books]
|
||||
/// asset_tag_prefix` or the company's initials).
|
||||
/// Omit to generate the next one ("FA-0001", from
|
||||
/// `books.asset_tag_prefix` or the company's initials).
|
||||
pub tag: Option<String>,
|
||||
/// The manufacturer's serial number, if known.
|
||||
pub serial: Option<String>,
|
||||
@@ -2750,7 +2742,7 @@ pub struct DisposeAssetParams {
|
||||
/// receipt policy applies.
|
||||
pub receipt_attachment_ids: Option<Vec<u64>>,
|
||||
/// The buyer (party id or name), named on the disposal entry. Under
|
||||
/// `[books] parties = "require"` a sale with proceeds needs one; a
|
||||
/// `policy.parties = "require"` a sale with proceeds needs one; a
|
||||
/// retirement with no proceeds and no buyer is marked as having no
|
||||
/// counterparty.
|
||||
#[serde(default)]
|
||||
@@ -3266,7 +3258,7 @@ pub struct RemitPayrollTaxesParams {
|
||||
/// Cash account id or code the remittance comes from.
|
||||
pub cash_account: String,
|
||||
/// The tax agency paid (party id or name, e.g. the IRS), named on the
|
||||
/// remittance entry. Required under `[books] parties = "require"`.
|
||||
/// remittance entry. Required under `policy.parties = "require"`.
|
||||
#[serde(default)]
|
||||
pub agency: Option<String>,
|
||||
}
|
||||
@@ -3640,17 +3632,17 @@ pub struct ExportCpaPackageParams {
|
||||
#[schemars(description = AS_OF_DOC)]
|
||||
pub as_of: Option<String>,
|
||||
/// Cash account codes the cash flow statement reconciles to, e.g.
|
||||
/// `["1000"]`. Defaults to `[books] cash_accounts`; one or the other
|
||||
/// `["1000"]`. Defaults to `books.cash_accounts`; one or the other
|
||||
/// is required.
|
||||
#[serde(default)]
|
||||
pub cash_accounts: Vec<String>,
|
||||
/// Liability account codes treated as financing. Defaults to `[books]
|
||||
/// financing_accounts`, else empty.
|
||||
/// Liability account codes treated as financing. Defaults to
|
||||
/// `books.financing_accounts`, else empty.
|
||||
#[serde(default)]
|
||||
pub financing_accounts: Vec<String>,
|
||||
/// Asset account codes treated as investing for cash_flow: loans the
|
||||
/// company makes, investments it buys (ASC 230-10-20). Defaults to
|
||||
/// `[books] investing_accounts`.
|
||||
/// `books.investing_accounts`.
|
||||
#[serde(default)]
|
||||
pub investing_accounts: Vec<String>,
|
||||
/// Include every receipt for the year's entries (every current file
|
||||
@@ -3895,16 +3887,11 @@ pub struct HandedFile {
|
||||
pub size_bytes: u64,
|
||||
/// Its SHA-256, hex.
|
||||
pub sha256: String,
|
||||
/// The command that saves it in a folder you name (run it; books
|
||||
/// served by `wanderbooks mcp`). Kept 15 minutes.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub download: Option<String>,
|
||||
/// When it lapses (UTC), with `download`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
/// Over stdio: its bytes, base64.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_base64: Option<String>,
|
||||
/// The command that saves it in a folder you name (run it). Kept 15
|
||||
/// minutes.
|
||||
pub download: String,
|
||||
/// When it lapses (UTC).
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
/// Result of `books.get_w9`: the W-9 file, handed over.
|
||||
@@ -4026,7 +4013,7 @@ pub struct SetEntryPartyParams {
|
||||
/// shares that don't add up to what it moved.
|
||||
pub parties: Option<Vec<PartyShareParam>>,
|
||||
/// With `party` null: mark the entry as having no counterparty, with
|
||||
/// why (journaled as `entry.no_party`). Satisfies `[books] parties =
|
||||
/// why (journaled as `entry.no_party`). Satisfies `policy.parties =
|
||||
/// "require"`.
|
||||
pub no_party_reason: Option<String>,
|
||||
}
|
||||
@@ -4159,12 +4146,11 @@ pub struct ListPartiesOut {
|
||||
/// reversal counts on its original's side and nets it to zero. (Not the
|
||||
/// books' accounting method.)
|
||||
///
|
||||
/// Money is what left or reached the `[books] cash_accounts` (every asset
|
||||
/// Money is what left or reached the `books.cash_accounts` (every asset
|
||||
/// account but receivables when none are set), the cards (the
|
||||
/// business-card payment methods' liabilities, `books.instruments`; while
|
||||
/// none is recorded, liabilities whose names say "card", not "gift
|
||||
/// card"), and the `[books]
|
||||
/// financing_accounts` (what the members
|
||||
/// card"), and the `books.financing_accounts` (what the members
|
||||
/// advanced, loans): a card charge or a member paying for the company is
|
||||
/// paid to the payee then; cash that pays one of these down is paid to
|
||||
/// its creditor (a reimbursement to the members, a card payment to the
|
||||
|
||||
@@ -1,236 +0,0 @@
|
||||
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
|
||||
//! transport doesn't do.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
|
||||
//! it gets, even one rmcp would drop.
|
||||
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
|
||||
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
|
||||
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
|
||||
//! INVARIANTS:
|
||||
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
|
||||
//! 2. JSON that isn't a request, notification, or response object (an
|
||||
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
|
||||
//! "method" or a result/error) gets {"code": -32600}, with the
|
||||
//! line's id when it has a valid one (string, number, null), else
|
||||
//! null.
|
||||
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
|
||||
//! 4. Output lines are written whole: replies made here never split a
|
||||
//! line rmcp writes.
|
||||
//! ```
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// What to do with one line from the client.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Line {
|
||||
/// Hand it to rmcp.
|
||||
Forward,
|
||||
/// Ignore it (blank).
|
||||
Skip,
|
||||
/// Answer it here with this JSON-RPC response.
|
||||
Reply(Value),
|
||||
}
|
||||
|
||||
/// Classify one line (INVARIANTS 1-3).
|
||||
pub fn classify(line: &str) -> Line {
|
||||
if line.trim().is_empty() {
|
||||
return Line::Skip;
|
||||
}
|
||||
let v: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
|
||||
};
|
||||
let Some(obj) = v.as_object() else {
|
||||
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
|
||||
};
|
||||
let id = match obj.get("id") {
|
||||
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
|
||||
let id_ok = match obj.get("id") {
|
||||
None => true,
|
||||
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
|
||||
};
|
||||
let request = obj.get("method").is_some_and(Value::is_string);
|
||||
let response =
|
||||
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
|
||||
if version_ok && id_ok && (request || response) {
|
||||
Line::Forward
|
||||
} else {
|
||||
Line::Reply(error(id, -32600, "Invalid Request"))
|
||||
}
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i64, message: &str) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
||||
}
|
||||
|
||||
/// Run the wire between the real `input`/`output` and rmcp's ends:
|
||||
/// returns (what rmcp reads, what rmcp writes) and the task that must be
|
||||
/// awaited after rmcp finishes, so the last lines are flushed.
|
||||
pub fn spawn<R, W>(
|
||||
input: R,
|
||||
mut output: W,
|
||||
) -> (
|
||||
tokio::io::DuplexStream,
|
||||
tokio::io::DuplexStream,
|
||||
tokio::task::JoinHandle<()>,
|
||||
)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
const BUF: usize = 1 << 20;
|
||||
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
|
||||
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let replies = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
match classify(&line) {
|
||||
Line::Skip => {}
|
||||
Line::Forward => {
|
||||
if feed
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line::Reply(v) => {
|
||||
let _ = replies.send(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dropping `feed` ends rmcp's input.
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(from_rmcp).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
if output
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
|| output.flush().await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
(rmcp_in, rmcp_out, writer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn code(l: Line) -> (i64, Value) {
|
||||
match l {
|
||||
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_by_the_jsonrpc_spec() {
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
|
||||
(-32700, Value::Null)
|
||||
);
|
||||
assert_eq!(code(classify("not json")), (-32700, Value::Null));
|
||||
assert_eq!(code(classify("[]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("5")), (-32600, Value::Null));
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
|
||||
(-32600, json!(98))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
|
||||
(-32600, json!("a"))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(
|
||||
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
|
||||
)),
|
||||
(-32600, json!(7))
|
||||
);
|
||||
assert_eq!(classify(" "), Line::Skip);
|
||||
}
|
||||
|
||||
/// Counts the lines written to it; every flush fails.
|
||||
struct FailingFlush(std::sync::Arc<std::sync::atomic::AtomicUsize>);
|
||||
|
||||
impl AsyncWrite for FailingFlush {
|
||||
fn poll_write(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> std::task::Poll<std::io::Result<usize>> {
|
||||
let lines = buf.iter().filter(|&&b| b == b'\n').count();
|
||||
self.0.fetch_add(lines, std::sync::atomic::Ordering::SeqCst);
|
||||
std::task::Poll::Ready(Ok(buf.len()))
|
||||
}
|
||||
fn poll_flush(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Err(std::io::Error::other("closed")))
|
||||
}
|
||||
fn poll_shutdown(
|
||||
self: std::pin::Pin<&mut Self>,
|
||||
_: &mut std::task::Context<'_>,
|
||||
) -> std::task::Poll<std::io::Result<()>> {
|
||||
std::task::Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
|
||||
/// A client that can't take a flushed reply is gone: the writer stops
|
||||
/// after the first, rather than writing on into a dead channel.
|
||||
#[tokio::test]
|
||||
async fn a_failed_flush_stops_the_writer() {
|
||||
let written = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let input = std::io::Cursor::new(b"not json\nnor this\n".to_vec());
|
||||
let (rmcp_in, rmcp_out, writer) = spawn(input, FailingFlush(written.clone()));
|
||||
drop((rmcp_in, rmcp_out));
|
||||
writer.await.unwrap();
|
||||
assert_eq!(written.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
}
|
||||
}
|
||||
@@ -1,264 +0,0 @@
|
||||
//! Starting the server: arguments, where the books and settings are found,
|
||||
//! and what happens when another writer has the books.
|
||||
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
const BIN: &str = env!("CARGO_BIN_EXE_ledger-mcp");
|
||||
|
||||
/// A test's dir, removed when the test ends, pass or panic.
|
||||
struct TestDir(std::path::PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::path::Path> for TestDir {
|
||||
fn as_ref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl AsRef<std::ffi::OsStr> for TestDir {
|
||||
fn as_ref(&self) -> &std::ffi::OsStr {
|
||||
self.0.as_os_str()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn temp(tag: &str) -> TestDir {
|
||||
let d = std::env::temp_dir().join(format!("ledger-mcp-start-{}-{tag}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
TestDir(d)
|
||||
}
|
||||
|
||||
/// Start the server, initialize, call one tool, and return its text
|
||||
/// result (or the error text), then end the session.
|
||||
fn one_call(cmd: &mut Command, tool: &str, args: serde_json::Value) -> String {
|
||||
let mut child = cmd
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
let mut out = BufReader::new(child.stdout.take().unwrap());
|
||||
let mut send = |v: serde_json::Value| writeln!(stdin, "{v}").unwrap();
|
||||
send(
|
||||
serde_json::json!({"jsonrpc":"2.0","id":1,"method":"initialize","params":{
|
||||
"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"t","version":"0"}}}),
|
||||
);
|
||||
let mut line = String::new();
|
||||
out.read_line(&mut line).unwrap();
|
||||
send(serde_json::json!({"jsonrpc":"2.0","method":"notifications/initialized","params":{}}));
|
||||
send(
|
||||
serde_json::json!({"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":tool,"arguments":args}}),
|
||||
);
|
||||
line.clear();
|
||||
out.read_line(&mut line).unwrap();
|
||||
drop(stdin);
|
||||
let _ = child.wait();
|
||||
line
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_names_the_options() {
|
||||
let out = Command::new(BIN).arg("--help").output().unwrap();
|
||||
assert!(out.status.success());
|
||||
let text = String::from_utf8_lossy(&out.stdout) + String::from_utf8_lossy(&out.stderr);
|
||||
for opt in ["--data-dir", "--books-toml", "--config", "--http"] {
|
||||
assert!(text.contains(opt), "--help lacks {opt}: {text}");
|
||||
}
|
||||
}
|
||||
|
||||
/// With no --data-dir the books live in ~/.local/share/wanderbooks.
|
||||
#[test]
|
||||
fn the_default_data_dir_is_under_home() {
|
||||
let home = temp("home");
|
||||
one_call(
|
||||
Command::new(BIN).env("HOME", &home),
|
||||
"books.chart",
|
||||
serde_json::json!({}),
|
||||
);
|
||||
assert!(
|
||||
home.join(".local/share/wanderbooks/journal.jsonl").exists(),
|
||||
"no books under {}",
|
||||
home.display()
|
||||
);
|
||||
}
|
||||
|
||||
/// wanderbooks.toml beside the data folder is read without --books-toml:
|
||||
/// its receipt threshold (0.00) refuses a 1.00 entry with no receipt,
|
||||
/// which the default ($25.00) would post.
|
||||
#[test]
|
||||
fn the_settings_beside_the_books_are_read() {
|
||||
let dir = temp("beside");
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[books]\nreceipt_threshold = \"0.00\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rent = store
|
||||
.add_account(
|
||||
"6000",
|
||||
"Rent",
|
||||
ledger_core::AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let e = store
|
||||
.create_entry(chrono::NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), "x")
|
||||
.unwrap();
|
||||
let m = ledger_core::Money::new(rust_decimal::Decimal::ONE, ledger_core::Currency::USD);
|
||||
store
|
||||
.add_line(e, rent, ledger_core::Side::Debit, m)
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(e, cash, ledger_core::Side::Credit, m)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let reply = one_call(
|
||||
Command::new(BIN).arg("--data-dir").arg(&data),
|
||||
"books.post_entry",
|
||||
serde_json::json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
assert!(
|
||||
reply.contains("receipt_required"),
|
||||
"posted without a receipt: {reply}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The cash flow roles in wanderbooks.toml beside the data folder are
|
||||
/// read without --books-toml: its cash account lets cash_flow run with no
|
||||
/// cash_accounts of its own.
|
||||
#[test]
|
||||
fn the_cash_roles_beside_the_books_are_read() {
|
||||
let dir = temp("roles");
|
||||
std::fs::write(
|
||||
dir.join("wanderbooks.toml"),
|
||||
"[books]\ncash_accounts = [\"1000\"]\n",
|
||||
)
|
||||
.unwrap();
|
||||
let data = dir.join("books-data");
|
||||
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
|
||||
store
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let reply = one_call(
|
||||
Command::new(BIN).arg("--data-dir").arg(&data),
|
||||
"books.cash_flow",
|
||||
serde_json::json!({"start": "2026-01-01", "end": "2026-03-31"}),
|
||||
);
|
||||
assert!(reply.contains("\"isError\":false"), "{reply}");
|
||||
}
|
||||
|
||||
/// A writer that keeps the books is a conflict: the server gives up with
|
||||
/// an error (after its ten-second wait), never hangs.
|
||||
#[test]
|
||||
fn a_held_lock_is_refused_not_waited_on_forever() {
|
||||
let dir = temp("locked");
|
||||
let _holder = ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap();
|
||||
let start = Instant::now();
|
||||
let mut child = Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(&dir)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.unwrap();
|
||||
let status = loop {
|
||||
if let Some(s) = child.try_wait().unwrap() {
|
||||
break s;
|
||||
}
|
||||
if start.elapsed() > Duration::from_secs(60) {
|
||||
let _ = child.kill();
|
||||
panic!("the server waited on a held lock for a minute");
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
};
|
||||
assert!(!status.success());
|
||||
assert!(
|
||||
start.elapsed() >= Duration::from_secs(9),
|
||||
"gave up before waiting"
|
||||
);
|
||||
}
|
||||
|
||||
/// Books written by a newer wanderbooks: the server won't start on them,
|
||||
/// and says to update it, not that the books are corrupt (rules.md B-27).
|
||||
#[test]
|
||||
fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
let dir = temp("newer");
|
||||
drop(ledger_store::Store::create(&dir, ledger_core::Currency::USD).unwrap());
|
||||
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
"journal.format_set",
|
||||
serde_json::json!({"format": 3, "features": []}),
|
||||
)
|
||||
.unwrap();
|
||||
// The next line chains onto it, so its bytes are checked (B-27).
|
||||
w.append(
|
||||
"fx.rate_set",
|
||||
serde_json::json!({"currency": "EUR", "date": "2026-10-01", "rate": "1.10"}),
|
||||
)
|
||||
.unwrap();
|
||||
let journal = std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
let out = Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(&dir)
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(!out.status.success());
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(
|
||||
err.contains("written by a newer version of wanderbooks"),
|
||||
"{err}"
|
||||
);
|
||||
assert!(err.contains("Update wanderbooks"), "{err}");
|
||||
assert!(!err.contains("corrupt"), "{err}");
|
||||
assert_eq!(
|
||||
std::fs::read(dir.join(ledger_store::JOURNAL_FILE)).unwrap(),
|
||||
journal
|
||||
);
|
||||
}
|
||||
|
||||
/// `--import-root` is gone (files come in as tool input, P2 slice 5): a
|
||||
/// server started with it stops at once, as for any unknown argument,
|
||||
/// and never serves as if it had read from that folder.
|
||||
#[test]
|
||||
fn the_import_root_flag_is_refused_as_unknown() {
|
||||
let dir = temp("import-root");
|
||||
let out = Command::new(BIN)
|
||||
.arg("--data-dir")
|
||||
.arg(dir.join("books-data"))
|
||||
.arg("--import-root")
|
||||
.arg(dir.join("Downloads"))
|
||||
.env("XDG_CONFIG_HOME", dir.join("config"))
|
||||
.env("XDG_RUNTIME_DIR", dir.join("config"))
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert_eq!(out.status.code(), Some(2));
|
||||
let err = String::from_utf8_lossy(&out.stderr);
|
||||
assert!(err.contains("unknown argument: --import-root"), "{err}");
|
||||
assert!(!err.contains("serving"), "{err}");
|
||||
assert!(!dir.join("books-data").exists(), "opened the books");
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
//! The P3 seam review's books (shared by `ledger-package`'s
|
||||
//! `tests/p3_seams.rs`, `ledger-gui`'s `tests/p3_seams.rs` and
|
||||
//! `ledger-mcp`'s `tests/p3_seams.rs`, which include this file): one year
|
||||
//! `ledger-cli`'s `tests/p3_seams.rs`, which include this file): one year
|
||||
//! of fictional books for a two-member LLC, Ana Example and Ben Example,
|
||||
//! 50/50, built through the real [`Store`], with the settings the server
|
||||
//! and the viewer read written beside them (`wanderbooks.toml`). Every
|
||||
@@ -124,9 +124,10 @@ fn both(s: &mut Store, e: u64, ana: PartyId, ben: PartyId, each: &str) -> u64 {
|
||||
done(s, e)
|
||||
}
|
||||
|
||||
/// The settings the server and the viewer read for these books (beside
|
||||
/// `books-data`, as an older books dir keeps them): [`company`] and the
|
||||
/// account roles, the receipt threshold high so no entry needs a receipt.
|
||||
/// The settings of these books, written beside `books-data` as an older
|
||||
/// books dir keeps them (adoption reads them once; a test that opens the
|
||||
/// books itself journals them): [`company`] and the account roles, the
|
||||
/// receipt threshold high so no entry needs a receipt.
|
||||
pub const SETTINGS_TOML: &str = "[company]
|
||||
name = \"Example Trails LLC\"
|
||||
basis = \"Cash\"
|
||||
|
||||
@@ -272,7 +272,7 @@ pub fn default_root(home: &Path) -> PathBuf {
|
||||
|
||||
/// Whether `path` is a data root or inside one (any folder above it, or
|
||||
/// it, holds the marker `setup` leaves). Books there belong to the books
|
||||
/// server alone (plan 3.1: `dev-stdio` refuses them).
|
||||
/// server alone (plan 3.1); the viewer reads no settings file beside them.
|
||||
pub fn inside_a_root(path: &Path) -> bool {
|
||||
// From here (a path that is already absolute stays as it is).
|
||||
let path = std::env::current_dir()
|
||||
|
||||
Reference in new issue
Block a user