P1: the books keep how they are kept
Settings, conventions (posting templates, approval rules, policies, close steps, calendar, mappings, notes), decisions, open items and entry approvals are journal events (journal format 2). Books that keep their settings never read wanderbooks.toml or config.json; the server and the viewer take the vault key from --vault-key-file instead. books.import_settings moves a settings file in once, exactly as the server reads it. Approval rules, when enforced, gate every posting path. Replay refuses what it can't read as newer, never as corrupt. Lean: a record changes no figure. Mutation on the whole diff: every survivor killed by a test but two equivalent (docs/testing.md). The Company page says a late open item is overdue in words, not colour alone. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
e60ecc5317
commit
c587593d78
71 files changed
+12249
-461
No files matched your search
@@ -3,7 +3,8 @@
|
||||
You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.
|
||||
|
||||
- These tools are the only way to the books. Never look for the books (their journal, receipts or vault) in local files.
|
||||
- One exception, until settings move into the books: the settings file that books.settings names (books_toml) may be edited as the settings-and-conventions section says. No other local file.
|
||||
- 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.
|
||||
- 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,8 +1,37 @@
|
||||
# Settings
|
||||
# Settings, conventions, decisions and open items
|
||||
|
||||
Until settings move into the books themselves (planned next), they live in the settings file books.settings names (books_toml), a TOML file; this section changes then.
|
||||
The books keep their own record of how they are kept. Every agent, anywhere, reads it through these tools; none of it lives in local files.
|
||||
|
||||
- That file holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target). books.settings shows what is in force and lists problems: settings that disagree with the chart.
|
||||
- Change it when the human tells you a company fact, when a tool refuses naming a setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions.
|
||||
- The server reloads it whenever it changes, with no restart: after each change, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty.
|
||||
- Never put a member's SSN or any TIN in it.
|
||||
## Settings
|
||||
|
||||
- books.settings lists every setting the books know: what it means and takes, its value (null: unset, its default applies), who set it and when, and problems (settings that disagree with the chart, each saying what to change).
|
||||
- Change settings with books.set_setting: one or more {key, value} together, with who decided (decided_by) and when (decided_on, default today). A value is checked and kept in one spelling; null clears a key back to its default. Change a setting when the human tells you a company fact or decides a policy, when a tool refuses naming a setting, when books.settings or books.digest lists a problem, and after books.renumber_account lists config_mentions.
|
||||
- The company's EIN is the one identity number the settings keep; it is shown masked. No other setting, note or convention ever holds a full TIN: those go only into the vault (the parties-and-tins section).
|
||||
- company.timezone sets what "today" means for the books.
|
||||
- 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):
|
||||
|
||||
- posting_template: how a kind of transaction posts, as lines with shares of the amount ({lines: [{account, side, share}]}, each side's shares summing to 1). An account may be a slot, a name in braces such as "{expense}", that each draft names (books.draft_from_template accounts: {"expense": "6300"}). books.draft_from_template drafts an entry from one, split to the cent; preview and post it as usual.
|
||||
- approval_rule: who approves which entries ({approver, accounts?, amount_over?}). They block posting only when policy.approval_rules is enforce (off by default): then a post's preview lists approvals still needed, books.approve_entry records each once the approver says yes, and a tool that would post a covered entry at once (an invoice, a bill, a payment, payroll, a disposal) is refused: draft the entry instead. Reversals, closes, depreciation and amortization are exempt. An account a rule names can't be renumbered until the rule is revised.
|
||||
- policy, close_step, calendar, mapping, note: in words.
|
||||
|
||||
Follow the company's conventions; when the human decides a new way of doing something, record it. A convention that no longer applies is retired (books.retire_convention), never deleted; its id is never reused.
|
||||
|
||||
## Decisions and open items
|
||||
|
||||
- books.record_decision records what the owners decided (a policy, a treatment, a cutover) with who decided, when, and the filed document behind it if there is one. That is the whole record: no signatures or memos are needed unless an outside party requires one.
|
||||
- books.raise_item records something open (waiting on someone, due later); books.open_items lists them and books.resolve_item closes one with how. Raise open items with the human when they come due.
|
||||
@@ -12,8 +12,8 @@
|
||||
//! in the handbook, by section.
|
||||
//! INVARIANTS:
|
||||
//! 1. The connect text is at most CONNECT_MAX characters, and its first
|
||||
//! paragraph is at most CONNECT_LEAD characters and names
|
||||
//! books.handbook.
|
||||
//! paragraph is at most CONNECT_LEAD characters and names books.start
|
||||
//! and books.handbook.
|
||||
//! 2. The connect text and every handbook section state the receipt
|
||||
//! rule in force (from the settings, in the books' base currency:
|
||||
//! ReceiptPolicy::rule), never a fixed one; the connect text states
|
||||
@@ -21,6 +21,9 @@
|
||||
//! 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.
|
||||
//! 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
|
||||
@@ -169,11 +172,11 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
};
|
||||
let lead = format!(
|
||||
"wanderbooks: the double-entry books of {name}. You are their bookkeeping agent. \
|
||||
Read books.handbook (or books://handbook) first: it holds every procedure. These \
|
||||
tools are the only way to the books; never look for them in local files. Amounts \
|
||||
are decimal strings (\"123.45\"), never floats. Entries: books.draft_entry, \
|
||||
books.add_line, a preview from books.post_entry without confirmed, shown to the \
|
||||
human, then books.post_entry with confirmed: true."
|
||||
Call books.start first each session: rules, conventions, open items. \
|
||||
books.handbook has every procedure. These tools are the only way to the books; \
|
||||
never look for them in local files. Amounts are decimal strings (\"123.45\"), \
|
||||
never floats. Entries: books.draft_entry, books.add_line, books.post_entry without \
|
||||
confirmed for a preview shown to the human, then with confirmed: true."
|
||||
);
|
||||
// Closes, depreciation and amortization post with no party: the rule
|
||||
// is for the entries the agent posts.
|
||||
@@ -197,6 +200,23 @@ 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 {
|
||||
""
|
||||
}
|
||||
)
|
||||
}
|
||||
};
|
||||
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 { "" };
|
||||
@@ -209,8 +229,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
depreciation, amortization{} and closes{} need none. Invoice, bill, payment and \
|
||||
payroll tools post at once{}: attach their evidence right after. A full TIN goes \
|
||||
only into the vault{}: never repeat one. After each change, show the affected \
|
||||
balances{}; after posting, check books.digest and offer it to the human.{} The \
|
||||
only local file you may edit is the settings file books.settings names{}.",
|
||||
balances{}; after posting, check books.digest and offer it to the human.{}{}",
|
||||
unless_terse(", previewed the same way"),
|
||||
because(" (their schedules are the evidence)"),
|
||||
because(" (derived from the books)"),
|
||||
@@ -222,7 +241,7 @@ pub fn connect_text(settings: &Settings, currency: &str) -> String {
|
||||
} else {
|
||||
""
|
||||
},
|
||||
because(" (handbook: settings-and-conventions)"),
|
||||
settings_rule(why),
|
||||
)
|
||||
};
|
||||
let mut text = String::new();
|
||||
@@ -283,7 +302,7 @@ mod tests {
|
||||
fn instructions_budget() {
|
||||
use ledger_config::PartyPolicy;
|
||||
let long = "A Very Long Legal Name ".repeat(20);
|
||||
for s in [
|
||||
let every = [
|
||||
settings("", Decimal::ZERO),
|
||||
settings("Example Labs LLC", Decimal::new(2500, 2)),
|
||||
settings("Tiny", Decimal::new(1, 3)),
|
||||
@@ -292,7 +311,14 @@ mod tests {
|
||||
longest_rule(&long, PartyPolicy::Require),
|
||||
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) {
|
||||
let text = connect_text(&s, "USD");
|
||||
let chars = text.chars().count();
|
||||
assert!(chars <= CONNECT_MAX, "{chars} chars: {text}");
|
||||
@@ -303,10 +329,12 @@ mod tests {
|
||||
lead.chars().count()
|
||||
);
|
||||
for must in [
|
||||
"books.start",
|
||||
"books.handbook",
|
||||
"never look for them in local files",
|
||||
"decimal strings",
|
||||
"books.post_entry with confirmed: true",
|
||||
"books.post_entry without confirmed for a preview",
|
||||
"then with confirmed: true",
|
||||
] {
|
||||
assert!(lead.contains(must), "the lead lacks {must:?}: {lead}");
|
||||
}
|
||||
@@ -317,16 +345,26 @@ mod tests {
|
||||
.any(|&n| text.contains(&s.policy.rule_listing("USD", n))),
|
||||
"{text}"
|
||||
);
|
||||
// What every entry must be, and the one file the agent edits.
|
||||
// What every entry must be, and where settings live: the one
|
||||
// file the agent may edit, or none.
|
||||
for must in [
|
||||
"balances exactly",
|
||||
"real account from books.chart",
|
||||
"the settings file books.settings names",
|
||||
"post at once",
|
||||
"after posting, check books.digest and offer it to the human.",
|
||||
] {
|
||||
assert!(text.contains(must), "lacks {must:?}: {text}");
|
||||
}
|
||||
assert_eq!(
|
||||
text.contains("the settings file books.settings names"),
|
||||
!s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
assert_eq!(
|
||||
text.contains("Settings and conventions live in the books"),
|
||||
s.journaled,
|
||||
"{text}"
|
||||
);
|
||||
// The parties rule in force (INVARIANT 2), and only it.
|
||||
let party = [
|
||||
(
|
||||
@@ -476,10 +514,22 @@ mod tests {
|
||||
for why in [
|
||||
"(their schedules are the evidence)",
|
||||
"(derived from the books)",
|
||||
"(handbook: settings-and-conventions)",
|
||||
"(until books.import_settings)",
|
||||
"(the parties setting requires it)",
|
||||
] {
|
||||
assert!(text.contains(why), "{text}");
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -49,12 +49,15 @@ fn default_data_dir() -> PathBuf {
|
||||
|
||||
fn print_usage() {
|
||||
eprintln!(
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--http ADDR] [--import-root DIR]..."
|
||||
"usage: ledger-mcp [--data-dir PATH] [--config PATH] [--books-toml PATH] [--vault-key-file PATH] [--http ADDR] [--import-root DIR]..."
|
||||
);
|
||||
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 (default <data-dir>/../wanderbooks.toml, optional)"
|
||||
" --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"
|
||||
@@ -68,6 +71,7 @@ fn main() -> 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 import_roots: Vec<PathBuf> = Vec::new();
|
||||
let mut args = std::env::args().skip(1);
|
||||
@@ -84,6 +88,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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")?);
|
||||
}
|
||||
@@ -109,12 +118,17 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
ImportRoots::new(import_roots)
|
||||
};
|
||||
let config_path = config_path.unwrap_or_else(|| data_dir.join("config.json"));
|
||||
let config: Config = if config_path.exists() {
|
||||
let raw = std::fs::read_to_string(&config_path)?;
|
||||
serde_json::from_str(&raw)
|
||||
.map_err(|e| format!("bad config {}: {e}", config_path.display()))?
|
||||
} else {
|
||||
Config::default()
|
||||
// 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());
|
||||
@@ -130,9 +144,6 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
}),
|
||||
config_json: config_path.clone(),
|
||||
};
|
||||
let live = settings::Live::watching(source)?;
|
||||
eprintln!("ledger-mcp: company {:?}", live.current().company.name);
|
||||
let live = std::sync::Arc::new(std::sync::Mutex::new(live));
|
||||
|
||||
let mut store = if data_dir.join(ledger_store::JOURNAL_FILE).exists() {
|
||||
match open_waiting_for_lock(&data_dir) {
|
||||
@@ -146,6 +157,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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})");
|
||||
@@ -160,6 +172,25 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
if let Some(why) = &report.healed {
|
||||
eprintln!("ledger-mcp: rebuilt the database from the journal ({why})");
|
||||
}
|
||||
// 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));
|
||||
// No import root may be `/`, the home folder, or hold the books, the
|
||||
// vault or a key (local_files.rs INVARIANT 0): a named one stops the
|
||||
// server here, a default one is dropped.
|
||||
|
||||
+396
-45
@@ -332,36 +332,57 @@ fn vendor_1099_report(
|
||||
}
|
||||
|
||||
impl BooksServer {
|
||||
/// The wanderbooks.toml settings that name account `code`.
|
||||
/// The settings that name account `code`, by key (or, for books
|
||||
/// that still read a settings file, as the file spells them).
|
||||
fn config_mentions(&self, code: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for (key, codes) in [
|
||||
("[books] cash_accounts", &self.settings().roles.cash),
|
||||
let settings = self.settings();
|
||||
for (key, in_file, codes) in [
|
||||
(
|
||||
"[books] financing_accounts",
|
||||
&self.settings().roles.financing,
|
||||
"books.cash_accounts",
|
||||
"[books] cash_accounts",
|
||||
&settings.roles.cash,
|
||||
),
|
||||
(
|
||||
"books.financing_accounts",
|
||||
"[books] financing_accounts",
|
||||
&settings.roles.financing,
|
||||
),
|
||||
(
|
||||
"books.investing_accounts",
|
||||
"[books] investing_accounts",
|
||||
&self.settings().roles.investing,
|
||||
&settings.roles.investing,
|
||||
),
|
||||
(
|
||||
"books.close_sweep",
|
||||
"[books] close_sweep",
|
||||
&settings.company.close_sweep,
|
||||
),
|
||||
(
|
||||
"policy.evidence.required_accounts",
|
||||
"receipt_required_accounts",
|
||||
&settings.policy.receipt_required_accounts,
|
||||
),
|
||||
(
|
||||
"policy.evidence.exempt_accounts",
|
||||
"receipt_exempt_accounts",
|
||||
&settings.policy.receipt_exempt_accounts,
|
||||
),
|
||||
] {
|
||||
if codes.iter().any(|c| c == code) {
|
||||
out.push(key.to_string());
|
||||
out.push(self.setting_label(key, in_file));
|
||||
}
|
||||
}
|
||||
if self.settings().company.close_into.as_deref() == Some(code) {
|
||||
out.push("[company] close_into".to_string());
|
||||
if settings.company.close_into.as_deref() == Some(code) {
|
||||
out.push(self.setting_label("books.close_into", "[books] close_into"));
|
||||
}
|
||||
for m in &self.settings().company.members {
|
||||
let members = self.setting_label("company.members", "[[company.members]]");
|
||||
for m in &settings.company.members {
|
||||
if m.capital_account == code {
|
||||
out.push(format!("[[company.members]] {} capital_account", m.name));
|
||||
out.push(format!("{members} {} capital_account", m.name));
|
||||
}
|
||||
if m.distribution_account.as_deref() == Some(code) {
|
||||
out.push(format!(
|
||||
"[[company.members]] {} distribution_account",
|
||||
m.name
|
||||
));
|
||||
out.push(format!("{members} {} distribution_account", m.name));
|
||||
}
|
||||
}
|
||||
out
|
||||
@@ -418,6 +439,56 @@ impl BooksServer {
|
||||
self.settings.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
/// The settings in force as the books would keep them: the books' own,
|
||||
/// or, for books that still read a settings file, that file's reading
|
||||
/// (no source: the file is the source).
|
||||
fn settings_as_kept(&self, store: &Store) -> ledger_core::settings::Settings {
|
||||
let books = store.ledger().settings();
|
||||
if books.is_journaled() {
|
||||
return books.clone();
|
||||
}
|
||||
let Some((toml, json)) = self.live().file_texts() else {
|
||||
return books.clone();
|
||||
};
|
||||
let empty = ledger_core::settings::Settings::default();
|
||||
match ledger_config::codec::import(
|
||||
toml.as_deref().unwrap_or(""),
|
||||
json.as_deref(),
|
||||
&empty,
|
||||
self.currency(),
|
||||
) {
|
||||
Ok(i) => {
|
||||
let mut s = empty;
|
||||
let file = ledger_core::settings::Source {
|
||||
decided_by: "the settings file".into(),
|
||||
decided_on: today(),
|
||||
citation: None,
|
||||
note: None,
|
||||
};
|
||||
match s.apply(&i.changes, &file, 0) {
|
||||
Ok(()) => s,
|
||||
Err(_) => books.clone(),
|
||||
}
|
||||
}
|
||||
Err(_) => books.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
/// How a message names a setting: by its key when the books keep
|
||||
/// their own settings, else as the settings file spells it.
|
||||
fn setting_label(&self, key: &str, in_file: &str) -> String {
|
||||
if self.settings().journaled {
|
||||
key.to_string()
|
||||
} else {
|
||||
in_file.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every tool the server serves.
|
||||
pub(crate) fn tool_router() -> rmcp::handler::server::router::tool::ToolRouter<Self> {
|
||||
Self::core_router() + Self::kept_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request (reloaded at its start, in
|
||||
/// `call_tool` and `read_resource`, if their files changed).
|
||||
fn settings(&self) -> Arc<Settings> {
|
||||
@@ -485,10 +556,10 @@ impl BooksServer {
|
||||
/// statement of members' equity.
|
||||
fn member_capitals(&self, ledger: &Ledger) -> Result<Vec<ledger_core::MemberCapital>, String> {
|
||||
if self.settings().company.members.is_empty() {
|
||||
return Err(
|
||||
"no members configured: add [[company.members]] (name, share, capital_account) to the settings"
|
||||
.to_string(),
|
||||
);
|
||||
return Err(format!(
|
||||
"no members configured: set {} (name, share, capital_account for each)",
|
||||
self.setting_label("company.members", "[[company.members]] in the settings")
|
||||
));
|
||||
}
|
||||
self.settings()
|
||||
.company
|
||||
@@ -820,6 +891,16 @@ impl BooksServer {
|
||||
if self.store.stopped() {
|
||||
return Err(STOPPED.to_string());
|
||||
}
|
||||
// Books that keep their own settings: take any change (crate::
|
||||
// settings INVARIANT 1).
|
||||
let timezone = {
|
||||
let mut live = self.live();
|
||||
live.sync(guard.ledger().settings(), self.store.currency.code());
|
||||
live.current().timezone.clone()
|
||||
};
|
||||
// "Today" from here on is the zone in force now (a change another
|
||||
// session made while this request waited included).
|
||||
TIMEZONE.with(|z| *z.borrow_mut() = timezone);
|
||||
Ok(StoreGuard {
|
||||
guard,
|
||||
shared: &self.store,
|
||||
@@ -921,15 +1002,59 @@ fn parse_side(s: &str) -> Result<Side, String> {
|
||||
/// what to change. A code must name an account in the chart, of the kind
|
||||
/// the setting needs (cash and investing: assets; financing: liabilities;
|
||||
/// capital, distributions, close_into, close_sweep: equity).
|
||||
/// The live conventions that name account `code` (a posting template's
|
||||
/// line, an approval rule's accounts): after a renumber, each still names
|
||||
/// the old code until revised.
|
||||
fn convention_mentions(ledger: &Ledger, code: &str) -> Vec<String> {
|
||||
convention_codes(ledger)
|
||||
.into_iter()
|
||||
.filter(|(_, c)| c == code)
|
||||
.map(|(id, _)| format!("convention {id} (revise it with books.set_convention)"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Every account code a live convention names: (convention id, code).
|
||||
fn convention_codes(ledger: &Ledger) -> Vec<(String, String)> {
|
||||
let mut out = Vec::new();
|
||||
for c in ledger
|
||||
.conventions()
|
||||
.conventions()
|
||||
.filter(|c| c.retired.is_none())
|
||||
{
|
||||
if let Some(t) = c.template() {
|
||||
for l in t.lines {
|
||||
if ledger_core::conventions::slot(&l.account).is_none() {
|
||||
out.push((c.id.clone(), l.account));
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(r) = c.approval_rule() {
|
||||
for a in r.accounts {
|
||||
out.push((c.id.clone(), a));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
// Named as the books keep them, or as the settings file spells them.
|
||||
let label =
|
||||
|key: &'static str, in_file: &'static str| if cfg.journaled { key } else { in_file };
|
||||
let how = if cfg.journaled {
|
||||
" with books.set_setting"
|
||||
} else {
|
||||
" in the settings"
|
||||
};
|
||||
let mut check = |setting: &str, code: &str, want: &[AccountType]| {
|
||||
match ledger.chart().find_by_code(code) {
|
||||
None => out.push(format!(
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting} in the settings"
|
||||
"{setting} names {code}, which isn't in the chart: add that account, or change {setting}{how}"
|
||||
)),
|
||||
Some(a) if !want.contains(&a.account_type) => out.push(format!(
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting} in the settings",
|
||||
"{setting} names {code} \"{}\", a {:?} account, but needs {}: change {setting}{how}",
|
||||
a.name,
|
||||
a.account_type,
|
||||
want.iter().map(|t| format!("{t:?}")).collect::<Vec<_>>().join(" or "),
|
||||
@@ -939,34 +1064,71 @@ fn settings_problems(ledger: &Ledger, cfg: &Settings) -> Vec<String> {
|
||||
};
|
||||
use AccountType::*;
|
||||
for code in &cfg.roles.cash {
|
||||
check("[books] cash_accounts", code, &[Asset]);
|
||||
check(
|
||||
label("books.cash_accounts", "[books] cash_accounts"),
|
||||
code,
|
||||
&[Asset],
|
||||
);
|
||||
}
|
||||
for code in &cfg.roles.financing {
|
||||
check("[books] financing_accounts", code, &[Liability, Equity]);
|
||||
check(
|
||||
label("books.financing_accounts", "[books] financing_accounts"),
|
||||
code,
|
||||
&[Liability, Equity],
|
||||
);
|
||||
}
|
||||
for code in &cfg.roles.investing {
|
||||
check("[books] investing_accounts", code, &[Asset]);
|
||||
check(
|
||||
label("books.investing_accounts", "[books] investing_accounts"),
|
||||
code,
|
||||
&[Asset],
|
||||
);
|
||||
}
|
||||
let c = &cfg.company;
|
||||
for m in &c.members {
|
||||
check(
|
||||
&format!("[[company.members]] {} capital_account", m.name),
|
||||
&format!(
|
||||
"{} {} capital_account",
|
||||
label("company.members", "[[company.members]]"),
|
||||
m.name
|
||||
),
|
||||
&m.capital_account,
|
||||
&[Equity],
|
||||
);
|
||||
if let Some(d) = &m.distribution_account {
|
||||
check(
|
||||
&format!("[[company.members]] {} distribution_account", m.name),
|
||||
&format!(
|
||||
"{} {} distribution_account",
|
||||
label("company.members", "[[company.members]]"),
|
||||
m.name
|
||||
),
|
||||
d,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
}
|
||||
if let Some(code) = &c.close_into {
|
||||
check("[books] close_into", code, &[Equity]);
|
||||
check(
|
||||
label("books.close_into", "[books] close_into"),
|
||||
code,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
for code in &c.close_sweep {
|
||||
check("[books] close_sweep", code, &[Equity]);
|
||||
check(
|
||||
label("books.close_sweep", "[books] close_sweep"),
|
||||
code,
|
||||
&[Equity],
|
||||
);
|
||||
}
|
||||
// Conventions naming an account no longer in the chart (renumbered or
|
||||
// never there): a template can't draft, a rule covers nothing.
|
||||
for (id, code) in convention_codes(ledger) {
|
||||
if ledger.chart().find_by_code(&code).is_none() {
|
||||
out.push(format!(
|
||||
"convention {id} names account {code}, which isn't in the chart: revise it with books.set_convention"
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1218,6 +1380,24 @@ fn resource_list() -> Vec<Resource> {
|
||||
r
|
||||
};
|
||||
let resources = vec![
|
||||
mk(
|
||||
"books://start",
|
||||
"start",
|
||||
"Start here",
|
||||
"The books in brief: rules in force, settings, conventions, open items, next steps (books.start).",
|
||||
),
|
||||
mk(
|
||||
"books://settings",
|
||||
"settings",
|
||||
"Settings",
|
||||
"Every setting the books know, its value and who set it (books.settings).",
|
||||
),
|
||||
mk(
|
||||
"books://conventions",
|
||||
"conventions",
|
||||
"Conventions",
|
||||
"How this company keeps its books: templates, rules, policies, close steps (books.conventions).",
|
||||
),
|
||||
mk(
|
||||
"books://chart",
|
||||
"chart",
|
||||
@@ -1925,7 +2105,7 @@ fn party_required(what: &str) -> String {
|
||||
serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"these books require a counterparty on every entry ([books] parties = \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
"these books require a counterparty on every entry (their parties setting is \"require\"): {what} (add the party with books.add_party first if it's new)"
|
||||
),
|
||||
})
|
||||
.to_string()
|
||||
@@ -2274,7 +2454,7 @@ fn cash_flow_statement(
|
||||
let cash_accounts = resolve(cash_codes, "cash")?;
|
||||
if cash_accounts.is_empty() {
|
||||
return Err(
|
||||
"cash_accounts is required for the cash flow statement (pass it, or set [books] cash_accounts in the settings)"
|
||||
"cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
@@ -2313,10 +2493,38 @@ fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDa
|
||||
}
|
||||
}
|
||||
|
||||
/// Today in the books' local time zone. Not UTC: in the evening (US
|
||||
/// time) UTC is already tomorrow, and "through today" must not include it.
|
||||
thread_local! {
|
||||
/// The company's time zone for the request running on this thread
|
||||
/// (set at its start from the settings in force: one version per
|
||||
/// request), so every "today" in it agrees.
|
||||
static TIMEZONE: std::cell::RefCell<Option<String>> = const { std::cell::RefCell::new(None) };
|
||||
}
|
||||
|
||||
/// Poll `fut` with "today" in the request's zone on this thread, each
|
||||
/// time it runs: the request is driven on its own thread (run_blocking),
|
||||
/// so the zone is the request's alone. It starts as `timezone`; when the
|
||||
/// request locks the books the zone in force then replaces it (lock), and
|
||||
/// that carries over to the request's later polls.
|
||||
async fn in_timezone_async<T>(
|
||||
timezone: Option<String>,
|
||||
fut: impl std::future::Future<Output = T>,
|
||||
) -> T {
|
||||
let mut fut = std::pin::pin!(fut);
|
||||
let mut zone = timezone;
|
||||
std::future::poll_fn(move |cx| {
|
||||
let before = TIMEZONE.with(|z| z.replace(zone.take()));
|
||||
let polled = fut.as_mut().poll(cx);
|
||||
zone = TIMEZONE.with(|z| z.replace(before));
|
||||
polled
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// Today in the company's time zone (`company.timezone`, for the request
|
||||
/// running), else the computer's. Not UTC: in the evening (US time) UTC
|
||||
/// is already tomorrow, and "through today" must not include it.
|
||||
fn today() -> NaiveDate {
|
||||
chrono::Local::now().date_naive()
|
||||
TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref()))
|
||||
}
|
||||
|
||||
/// The `since` of a `books://digest` read: the query's `since=`, or, bare
|
||||
@@ -2454,7 +2662,11 @@ fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec<StatementLineOut> {
|
||||
// Tools
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tool_router]
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
pub use kept::SettingKeyOut;
|
||||
|
||||
#[tool_router(router = core_router)]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.handbook",
|
||||
@@ -2949,10 +3161,35 @@ impl BooksServer {
|
||||
no_party_reason,
|
||||
party_missing,
|
||||
party_suggestions,
|
||||
approvals_missing: store
|
||||
.ledger()
|
||||
.approvals_missing(p.entry_id)
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|(rule, approver)| ApprovalNeededOut { rule, approver })
|
||||
.collect(),
|
||||
};
|
||||
if !p.confirmed.unwrap_or(false) {
|
||||
return Ok(Json(out));
|
||||
}
|
||||
if !out.approvals_missing.is_empty() {
|
||||
let missing: Vec<String> = out
|
||||
.approvals_missing
|
||||
.iter()
|
||||
.map(|a| format!("{} (rule {})", a.approver, a.rule))
|
||||
.collect();
|
||||
return Err(serde_json::json!({
|
||||
"code": "approval_required",
|
||||
"message": format!(
|
||||
"entry {} needs approval before it posts (policy.approval_rules is enforce): {}. Ask each approver; when they approve, record it with books.approve_entry, then post.",
|
||||
p.entry_id,
|
||||
missing.join("; ")
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
"remedy_tool": "books.approve_entry",
|
||||
})
|
||||
.to_string());
|
||||
}
|
||||
if let Some(why) = &out.parties_error {
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_shares",
|
||||
@@ -2974,8 +3211,9 @@ impl BooksServer {
|
||||
return Err(serde_json::json!({
|
||||
"code": "party_required",
|
||||
"message": format!(
|
||||
"entry {} names no counterparty, and these books require one ([books] parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
|
||||
"entry {} names no counterparty, and these books require one ({} = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}",
|
||||
p.entry_id,
|
||||
self.setting_label("policy.parties", "[books] parties"),
|
||||
if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) }
|
||||
),
|
||||
"entry_id": p.entry_id,
|
||||
@@ -4747,7 +4985,11 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(RenumberAccountOut {
|
||||
id,
|
||||
config_mentions: self.config_mentions(&previous_code),
|
||||
config_mentions: self
|
||||
.config_mentions(&previous_code)
|
||||
.into_iter()
|
||||
.chain(convention_mentions(store.ledger(), &previous_code))
|
||||
.collect(),
|
||||
previous_code,
|
||||
code: p.code,
|
||||
}))
|
||||
@@ -4972,15 +5214,26 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.settings",
|
||||
title = "Settings in force",
|
||||
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target), where they come from, and problems: settings that disagree with the chart, each saying what to change. The server reloads them whenever they change, with no restart; after a change, call this to confirm it loaded (reload_error says why one didn't, and the last good settings stay in force).",
|
||||
description = "The settings in force (company, fiscal year, tax form, members, basis, receipt rule with every account it requires or exempts, cash flow roles, close target, time zone, approval rules), every key the books know with its value and who set it, where they come from (source: \"books\", changed with books.set_setting; or \"file\", a settings file still read, reloaded on change: reload_error says why an edit didn't load), and problems: settings or conventions that disagree with the chart, each saying what to change.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn settings_tool(&self) -> Result<Json<SettingsOut>, String> {
|
||||
// The books first: their settings sync as they lock, so the
|
||||
// status and the settings read after are one version.
|
||||
let store = self.lock()?;
|
||||
let status = self.live().status();
|
||||
let cfg = self.settings();
|
||||
let store = self.lock()?;
|
||||
let c = &cfg.company;
|
||||
Ok(Json(SettingsOut {
|
||||
source: if status.journaled { "books" } else { "file" }.to_string(),
|
||||
timezone: cfg.timezone.clone(),
|
||||
today: today().to_string(),
|
||||
approval_rules: match store.ledger().settings().approval_rules() {
|
||||
ledger_core::settings::ApprovalMode::Off => "off",
|
||||
ledger_core::settings::ApprovalMode::Enforce => "enforce",
|
||||
}
|
||||
.to_string(),
|
||||
keys: kept::keys_out(&self.settings_as_kept(&store)),
|
||||
books_toml: status.books_toml.map(|p| p.display().to_string()),
|
||||
books_toml_exists: status.books_toml_exists,
|
||||
config_json_present: status.config_json_present,
|
||||
@@ -9032,6 +9285,20 @@ impl BooksServer {
|
||||
None => (uri, ""),
|
||||
};
|
||||
match path {
|
||||
"books://start" => {
|
||||
let Json(out) = self.start(Parameters(kept::StartParams::default())).await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://settings" => {
|
||||
let Json(out) = self.settings_tool().await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://conventions" => {
|
||||
let Json(out) = self
|
||||
.conventions(Parameters(kept::ConventionsParams::default()))
|
||||
.await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
}
|
||||
"books://chart" => {
|
||||
let Json(out) = self.chart().await?;
|
||||
serde_json::to_string_pretty(&out).map_err(|e| e.to_string())
|
||||
@@ -9097,12 +9364,14 @@ impl ServerHandler for BooksServer {
|
||||
) -> Result<rmcp::model::CallToolResponse, rmcp::ErrorData> {
|
||||
// Pick up an edited settings file before the tool runs.
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
let server = self.clone();
|
||||
let result = run_blocking(&self.store, move || {
|
||||
Box::pin(async move {
|
||||
let tcc =
|
||||
rmcp::handler::server::tool::ToolCallContext::new(&server, request, context);
|
||||
Self::tool_router().call(tcc).await
|
||||
let router = Self::tool_router();
|
||||
in_timezone_async(timezone, router.call(tcc)).await
|
||||
})
|
||||
})
|
||||
.await;
|
||||
@@ -9120,7 +9389,14 @@ impl ServerHandler for BooksServer {
|
||||
}
|
||||
|
||||
fn get_info(&self) -> ServerConfig {
|
||||
// A client connecting after a settings change sees the change.
|
||||
// A client connecting after a settings change sees the change:
|
||||
// the books' own when they keep them (never waiting for the books
|
||||
// here; a request holding them syncs them when it locks), else the
|
||||
// files'.
|
||||
if let Ok(store) = self.store.store.try_lock() {
|
||||
self.live()
|
||||
.sync(store.ledger().settings(), self.store.currency.code());
|
||||
}
|
||||
self.live().refresh();
|
||||
ServerConfig::new(
|
||||
ServerCapabilities::builder()
|
||||
@@ -9151,11 +9427,12 @@ impl ServerHandler for BooksServer {
|
||||
_context: RequestContext<RoleServer>,
|
||||
) -> Result<ReadResourceResponse, rmcp::ErrorData> {
|
||||
self.live().refresh();
|
||||
let timezone = self.settings().timezone.clone();
|
||||
let uri = request.uri.clone();
|
||||
let server = self.clone();
|
||||
let at = uri.clone();
|
||||
let text = run_blocking(&self.store, move || {
|
||||
Box::pin(async move { server.resource_text(&at).await })
|
||||
Box::pin(async move { in_timezone_async(timezone, server.resource_text(&at)).await })
|
||||
})
|
||||
.await?;
|
||||
// A refusal quotes the URI it was given: masked like a tool's.
|
||||
@@ -9393,7 +9670,7 @@ mod tests {
|
||||
..Company::default()
|
||||
},
|
||||
);
|
||||
assert_eq!(solo.config_mentions("3000"), ["[company] close_into"]);
|
||||
assert_eq!(solo.config_mentions("3000"), ["[books] close_into"]);
|
||||
}
|
||||
|
||||
/// Reports name each member with their accounts, in order.
|
||||
@@ -9570,8 +9847,22 @@ mod tests {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 94] = [
|
||||
let table: [(&str, Hints); 108] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
("books.import_settings", DI),
|
||||
("books.export_settings", R),
|
||||
("books.conventions", R),
|
||||
("books.set_convention", W),
|
||||
("books.retire_convention", D),
|
||||
("books.record_decision", W),
|
||||
("books.decisions", R),
|
||||
("books.raise_item", W),
|
||||
("books.resolve_item", W),
|
||||
("books.open_items", R),
|
||||
("books.draft_from_template", W),
|
||||
("books.approve_entry", W),
|
||||
("books.chart", R),
|
||||
("books.add_account", W),
|
||||
("books.trial_balance", R),
|
||||
@@ -9703,8 +9994,8 @@ mod tests {
|
||||
"{name} has no openWorldHint false"
|
||||
);
|
||||
}
|
||||
// docs/mcp-interface.md names the 25 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 25);
|
||||
// docs/mcp-interface.md names the 28 destructive tools.
|
||||
assert_eq!(table.iter().filter(|(_, h)| h.1 == Some(true)).count(), 28);
|
||||
}
|
||||
|
||||
/// Every refusal an agent can be given whose text is fixed here, or
|
||||
@@ -9801,6 +10092,66 @@ mod tests {
|
||||
] {
|
||||
out.push((what.to_string(), text.to_string()));
|
||||
}
|
||||
// Refusals and notes built inline, where the books' own record is
|
||||
// kept: every string literal of these sources outside their tests.
|
||||
for (what, src) in [
|
||||
("tools/kept.rs", include_str!("tools/kept.rs")),
|
||||
(
|
||||
"ledger-config codec",
|
||||
include_str!("../../ledger-config/src/codec.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core settings",
|
||||
include_str!("../../ledger-core/src/settings.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core conventions",
|
||||
include_str!("../../ledger-core/src/conventions.rs"),
|
||||
),
|
||||
(
|
||||
"ledger-core ledger",
|
||||
include_str!("../../ledger-core/src/ledger.rs"),
|
||||
),
|
||||
("settings.rs", include_str!("settings.rs")),
|
||||
] {
|
||||
let code = src.split("#[cfg(test)]").next().unwrap_or(src);
|
||||
let literals = string_literals(code);
|
||||
assert!(!literals.is_empty(), "{what}: no literals found");
|
||||
out.extend(literals.into_iter().map(|l| (format!("{what} literal"), l)));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The text of every plain string literal ("...") in Rust source,
|
||||
/// skipping comments (a rough scanner: enough for the lint).
|
||||
fn string_literals(src: &str) -> Vec<String> {
|
||||
let mut out = Vec::new();
|
||||
for line in src.lines() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
let mut chars = line.chars().peekable();
|
||||
let mut cur: Option<String> = None;
|
||||
while let Some(c) = chars.next() {
|
||||
match (&mut cur, c) {
|
||||
(None, '"') => cur = Some(String::new()),
|
||||
(None, '/') if chars.peek() == Some(&'/') => break,
|
||||
(Some(s), '\\') => {
|
||||
if let Some(n) = chars.next() {
|
||||
s.push('\\');
|
||||
s.push(n);
|
||||
}
|
||||
}
|
||||
(Some(s), '"') => {
|
||||
out.push(std::mem::take(s));
|
||||
cur = None;
|
||||
}
|
||||
(Some(s), c) => s.push(c),
|
||||
(None, _) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
//! The server's settings (`wanderbooks.toml`, and the receipt policy in
|
||||
//! `books-data/config.json`), reloaded whenever their files change.
|
||||
//! 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.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an edit to wanderbooks.toml takes effect on the next tool call,
|
||||
//! with no restart, and a broken edit never takes the server down.
|
||||
//! SOURCE: DECISION (owner, 2026-10-15): the configuration is always
|
||||
//! auto-reloaded.
|
||||
//! 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.
|
||||
//! 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.
|
||||
//! INVARIANTS:
|
||||
//! 1. Every request (tool call, resource read) sees the files' contents
|
||||
//! as of its start, one version for the whole request: a request after
|
||||
//! an edit sees the edit (compared by content, not by timestamp, so two
|
||||
//! edits in one second are both seen).
|
||||
//! 2. A file that fails to load leaves the last good settings in force,
|
||||
//! 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.
|
||||
//! 3. A settings file that doesn't exist and wasn't asked for (the
|
||||
//! 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 (2).
|
||||
//! 4. Only the settings reload: the data dir and the books' base currency
|
||||
//! are fixed when the server starts.
|
||||
//! 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).
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
@@ -56,6 +67,11 @@ pub struct Settings {
|
||||
/// `[books] 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 {
|
||||
@@ -67,6 +83,26 @@ impl Settings {
|
||||
.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 {
|
||||
let b = ledger_config::codec::books_settings(books, currency);
|
||||
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 {
|
||||
cash: b.cash_accounts,
|
||||
financing: b.financing_accounts,
|
||||
investing: b.investing_accounts,
|
||||
},
|
||||
company: b.company,
|
||||
journaled: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// The prefix for generated asset tags ("FA" when none is set).
|
||||
pub fn tag_prefix(&self) -> &str {
|
||||
if self.asset_tag_prefix.is_empty() {
|
||||
@@ -114,6 +150,8 @@ impl Source {
|
||||
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,
|
||||
@@ -133,6 +171,13 @@ pub struct Live {
|
||||
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,
|
||||
}
|
||||
|
||||
/// What books.settings reports about the files.
|
||||
@@ -150,6 +195,9 @@ pub struct Status {
|
||||
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 {
|
||||
@@ -161,6 +209,9 @@ impl Live {
|
||||
seen: (None, None),
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -175,9 +226,79 @@ impl Live {
|
||||
seen,
|
||||
error: None,
|
||||
loads: 1,
|
||||
journal_seq: None,
|
||||
vault_key_file: None,
|
||||
read_files: true,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
pub fn sync(&mut self, books: &ledger_core::settings::Settings, currency: &str) {
|
||||
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.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.journal_seq.is_some() || (self.source.is_some() && !self.read_files)
|
||||
}
|
||||
|
||||
/// The settings in force.
|
||||
pub fn current(&self) -> Arc<Settings> {
|
||||
self.current.clone()
|
||||
@@ -187,12 +308,20 @@ impl Live {
|
||||
/// 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(s) => {
|
||||
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;
|
||||
@@ -219,12 +348,18 @@ impl Live {
|
||||
/// 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().map(|s| s.books_toml.clone()),
|
||||
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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -327,4 +462,67 @@ mod tests {
|
||||
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();
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 0, "books without settings");
|
||||
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,
|
||||
};
|
||||
let change = |name: &str| ledger_core::settings::Change {
|
||||
key: "company.name".into(),
|
||||
value: Some(serde_json::json!(name)),
|
||||
};
|
||||
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);
|
||||
live.sync(&books, "USD");
|
||||
assert_eq!(live.status().loads, 1, "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());
|
||||
std::fs::remove_dir_all(&d).unwrap();
|
||||
}
|
||||
|
||||
/// 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());
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -562,8 +562,21 @@ pub struct MemberSettingOut {
|
||||
/// Result of `books.settings`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SettingsOut {
|
||||
/// The wanderbooks.toml the server reads (null: settings built in, not
|
||||
/// from a file).
|
||||
/// 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.
|
||||
pub today: String,
|
||||
/// Whether approval rules block posting: "off" or "enforce".
|
||||
pub approval_rules: String,
|
||||
/// 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,
|
||||
@@ -1192,6 +1205,21 @@ pub struct PostEntryOut {
|
||||
/// with `books.set_entry_party`, or add the party with
|
||||
/// `books.add_party` first.
|
||||
pub party_suggestions: Vec<PartySuggestionOut>,
|
||||
/// Approvals the entry still needs before it posts, when the books
|
||||
/// enforce approval rules (`policy.approval_rules = enforce`): each
|
||||
/// rule covering it and its approver. Empty otherwise. Record each
|
||||
/// with `books.approve_entry` once the approver says yes.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub approvals_missing: Vec<ApprovalNeededOut>,
|
||||
}
|
||||
|
||||
/// An approval an entry needs.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ApprovalNeededOut {
|
||||
/// The approval rule (a convention id).
|
||||
pub rule: String,
|
||||
/// Who approves.
|
||||
pub approver: String,
|
||||
}
|
||||
|
||||
/// A likely counterparty for an entry.
|
||||
|
||||
@@ -156,7 +156,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(), 94, "expected 94 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 108, "expected 108 tools, got {}", tools.len());
|
||||
let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap();
|
||||
assert_eq!(chart["annotations"]["readOnlyHint"], true);
|
||||
assert!(
|
||||
|
||||
@@ -0,0 +1,670 @@
|
||||
//! 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.
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::process::{Child, ChildStdin, Command, Stdio};
|
||||
|
||||
struct Mcp {
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<std::process::ChildStdout>,
|
||||
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());
|
||||
Self {
|
||||
stdin,
|
||||
stdout,
|
||||
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}");
|
||||
assert!(
|
||||
resp.get("error").is_none(),
|
||||
"JSON-RPC error for {method}: {}",
|
||||
resp["error"]
|
||||
);
|
||||
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();
|
||||
}
|
||||
|
||||
/// Call a tool; panic on tool error. Returns structuredContent.
|
||||
fn call(&mut self, tool: &str, args: Value) -> Value {
|
||||
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||
let result = &resp["result"];
|
||||
assert_ne!(
|
||||
result.get("isError"),
|
||||
Some(&json!(true)),
|
||||
"tool {tool} returned an error: {}",
|
||||
result["content"]
|
||||
);
|
||||
result.get("structuredContent").cloned().unwrap_or_else(|| {
|
||||
let text = result["content"][0]["text"].as_str().unwrap();
|
||||
serde_json::from_str(text).unwrap()
|
||||
})
|
||||
}
|
||||
|
||||
/// Call a tool expecting an error; returns the error text.
|
||||
fn call_err(&mut self, tool: &str, args: Value) -> String {
|
||||
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||
let result = &resp["result"];
|
||||
assert_eq!(
|
||||
result.get("isError"),
|
||||
Some(&json!(true)),
|
||||
"expected {tool} to fail, got {result}"
|
||||
);
|
||||
result["content"][0]["text"].as_str().unwrap().to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> std::path::PathBuf {
|
||||
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();
|
||||
dir
|
||||
}
|
||||
|
||||
fn init(m: &mut Mcp) -> String {
|
||||
let r = m.request(
|
||||
"initialize",
|
||||
json!({"protocolVersion": "2025-06-18", "capabilities": {},
|
||||
"clientInfo": {"name": "spec-kept", "version": "0"}}),
|
||||
);
|
||||
m.notify("notifications/initialized");
|
||||
r["result"]["instructions"].as_str().unwrap().to_string()
|
||||
}
|
||||
|
||||
const TOML: &str = r#"[company]
|
||||
name = "Kept Co LLC"
|
||||
tax_classification = "partnership" # Form 1065; a Schedule K-1 per member
|
||||
fiscal_year_start = "01-01"
|
||||
ein = "28-8525893"
|
||||
|
||||
[[company.members]]
|
||||
name = "A"
|
||||
share = "50%"
|
||||
capital_account = "3000"
|
||||
|
||||
[[company.members]]
|
||||
name = "B"
|
||||
share = "50%"
|
||||
capital_account = "3001"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
receipt_threshold = "0.00"
|
||||
cash_accounts = ["1000"]
|
||||
parties = "require"
|
||||
|
||||
[viewer]
|
||||
open_at_start = "ask"
|
||||
"#;
|
||||
|
||||
fn chart(m: &mut Mcp) {
|
||||
for (code, name, t) in [
|
||||
("1000", "Bank", "asset"),
|
||||
("3000", "Capital A", "equity"),
|
||||
("3001", "Capital B", "equity"),
|
||||
("6000", "Software", "expense"),
|
||||
] {
|
||||
m.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": t}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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);
|
||||
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}");
|
||||
// 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 ein = settings["keys"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|k| k["key"] == "company.ein")
|
||||
.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!(rule.as_str().unwrap().contains("every entry"), "{rule}");
|
||||
// Settings change through the tools now.
|
||||
let set = m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"},
|
||||
{"key": "company.ein", "value": "12-3456789"}],
|
||||
"decided_by": "B", "decided_on": "2026-10-17"}),
|
||||
);
|
||||
assert!(!set.to_string().contains("3456789"), "{set}");
|
||||
let export = m.call("books.export_settings", json!({}));
|
||||
let text = export["toml_text"].as_str().unwrap();
|
||||
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);
|
||||
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");
|
||||
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);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.evidence.threshold", "value": "1000000"},
|
||||
{"key": "policy.approval_rules", "value": "enforce"}],
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "big-software", "kind": "approval_rule", "title": "Big software",
|
||||
"body_md": "Software over 500 needs B.",
|
||||
"structured": {"approver": "B", "accounts": ["6000"], "amount_over": "500"},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let e = m.call(
|
||||
"books.draft_entry",
|
||||
json!({"date": "2026-10-01", "description": "Laptop", "no_party_reason": "test"}),
|
||||
)["entry_id"]
|
||||
.clone();
|
||||
for (account, side) in [("6000", "debit"), ("1000", "credit")] {
|
||||
m.call(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": account, "side": side, "amount": "900.00"}),
|
||||
);
|
||||
}
|
||||
let preview = m.call("books.post_entry", json!({"entry_id": e}));
|
||||
assert_eq!(
|
||||
preview["approvals_missing"],
|
||||
json!([{"rule": "big-software", "approver": "B"}])
|
||||
);
|
||||
let refused = m.call_err(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
assert!(refused.contains("approval_required"), "{refused}");
|
||||
assert!(refused.contains("books.approve_entry"), "{refused}");
|
||||
m.call(
|
||||
"books.approve_entry",
|
||||
json!({"entry_id": e, "approver": "B"}),
|
||||
);
|
||||
let preview = m.call("books.post_entry", json!({"entry_id": e}));
|
||||
assert!(preview.get("approvals_missing").is_none(), "{preview}");
|
||||
m.call(
|
||||
"books.post_entry",
|
||||
json!({"entry_id": e, "confirmed": true}),
|
||||
);
|
||||
m.call(
|
||||
"books.set_setting",
|
||||
json!({"changes": [{"key": "policy.approval_rules", "value": null}], "decided_by": "A"}),
|
||||
);
|
||||
assert_eq!(
|
||||
m.call("books.start", json!({}))["rules"]["approval_rules"],
|
||||
"off"
|
||||
);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "contribution", "kind": "posting_template",
|
||||
"title": "Joint contribution", "body_md": "Half each.",
|
||||
"structured": {"description": "Contribution", "lines": [
|
||||
{"account": "1000", "side": "debit", "share": "1"},
|
||||
{"account": "3000", "side": "credit", "share": "0.5"},
|
||||
{"account": "3001", "side": "credit", "share": "0.5"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let d = m.call(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.03",
|
||||
"no_party_reason": "between the members"}),
|
||||
);
|
||||
assert_eq!(d["description"], "Contribution");
|
||||
let amounts: Vec<&str> = d["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| l["amount"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(amounts, ["0.03", "0.02", "0.01"]);
|
||||
let e = m.call_err(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-10-02", "amount": "0.01"}),
|
||||
);
|
||||
assert!(e.contains("too small"), "{e}");
|
||||
// A slot takes the account each draft names.
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "bank-expense", "kind": "posting_template",
|
||||
"title": "Expense paid from the bank", "body_md": "Dr the expense / Cr 1000.",
|
||||
"structured": {"lines": [
|
||||
{"account": "{expense}", "side": "debit", "share": "1"},
|
||||
{"account": "1000", "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let e = m.call_err(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00"}),
|
||||
);
|
||||
assert!(e.contains("expense"), "{e}");
|
||||
let d = m.call(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "bank-expense", "date": "2026-10-02", "amount": "12.00",
|
||||
"accounts": {"expense": "6000"}, "no_party_reason": "test"}),
|
||||
);
|
||||
assert_eq!(d["lines"][0]["account"], "6000");
|
||||
assert_eq!(d["lines"][1]["account"], "1000");
|
||||
}
|
||||
|
||||
/// books.start stays at most 8 KB whatever the books hold, and says what
|
||||
/// 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);
|
||||
init(&mut m);
|
||||
let body =
|
||||
"A first line long enough to fill its summary to the brim, and then some more words. "
|
||||
.repeat(3);
|
||||
for i in 0..60 {
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": format!("note-{i}"), "kind": "note",
|
||||
"title": format!("A rather long convention title number {i}"),
|
||||
"body_md": body, "decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
for i in 0..40 {
|
||||
m.call(
|
||||
"books.raise_item",
|
||||
json!({"title": format!("Open item number {i}, with a title of some length"),
|
||||
"due": "2026-12-31", "decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
let resp = m.request(
|
||||
"tools/call",
|
||||
json!({"name": "books.start", "arguments": {}}),
|
||||
);
|
||||
let start = &resp["result"]["structuredContent"];
|
||||
let bytes = serde_json::to_string(start).unwrap().len();
|
||||
assert!(bytes <= 8 * 1024, "{bytes} bytes");
|
||||
let cut = start["truncated"].to_string();
|
||||
assert!(cut.contains("books.conventions"), "{cut}");
|
||||
assert!(start["conventions"].as_array().unwrap().len() >= 10);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
#[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);
|
||||
init(&mut m);
|
||||
let keys = m.call("books.settings", json!({}))["keys"].clone();
|
||||
let ein = keys
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.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!(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");
|
||||
}
|
||||
|
||||
/// A convention that names an account is listed when that account is
|
||||
/// renumbered, and as a problem while it names a code no longer in the
|
||||
/// 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);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "software", "kind": "posting_template", "title": "Software",
|
||||
"body_md": "Dr 6000 / Cr 1000.",
|
||||
"structured": {"lines": [
|
||||
{"account": "6000", "side": "debit", "share": "1"},
|
||||
{"account": "1000", "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
let out = m.call(
|
||||
"books.renumber_account",
|
||||
json!({"account": "6000", "code": "6010"}),
|
||||
);
|
||||
assert!(
|
||||
out["config_mentions"]
|
||||
.to_string()
|
||||
.contains("convention software"),
|
||||
"{out}"
|
||||
);
|
||||
let problems = m.call("books.settings", json!({}))["problems"].to_string();
|
||||
assert!(
|
||||
problems.contains("convention software names account 6000"),
|
||||
"{problems}"
|
||||
);
|
||||
}
|
||||
|
||||
/// books://start, books://settings and books://conventions read as their
|
||||
/// 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);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": "software", "kind": "note", "title": "Software",
|
||||
"body_md": "Software is 6000.", "decided_by": "A"}),
|
||||
);
|
||||
let read = |m: &mut Mcp, uri: &str| -> Value {
|
||||
let r = m.request("resources/read", json!({"uri": uri}));
|
||||
let text = r["result"]["contents"][0]["text"].as_str().unwrap();
|
||||
serde_json::from_str(text).unwrap()
|
||||
};
|
||||
let start = read(&mut m, "books://start");
|
||||
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}");
|
||||
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);
|
||||
init(&mut m);
|
||||
chart(&mut m);
|
||||
for (id, dr, cr) in [("software", "6000", "1000"), ("deposit", "1000", "3000")] {
|
||||
m.call(
|
||||
"books.set_convention",
|
||||
json!({"id": id, "kind": "posting_template", "title": id,
|
||||
"body_md": format!("Dr {dr} / Cr {cr}."),
|
||||
"structured": {"lines": [
|
||||
{"account": dr, "side": "debit", "share": "1"},
|
||||
{"account": cr, "side": "credit", "share": "1"}]},
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
}
|
||||
let out = m.call(
|
||||
"books.renumber_account",
|
||||
json!({"account": "6000", "code": "6010"}),
|
||||
);
|
||||
let mentions: Vec<String> = out["config_mentions"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap().to_string())
|
||||
.filter(|s| s.starts_with("convention "))
|
||||
.collect();
|
||||
assert_eq!(
|
||||
mentions,
|
||||
["convention software (revise it with books.set_convention)"],
|
||||
"{out}"
|
||||
);
|
||||
drop(m);
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
@@ -219,6 +219,16 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child {
|
||||
.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(),
|
||||
)
|
||||
// Local files come only from here (the default is the downloads
|
||||
// folder): never the books, never where the server writes.
|
||||
.arg("--import-root")
|
||||
@@ -1093,6 +1103,77 @@ fn happy_path(name: &str) -> Mcp {
|
||||
json!({"period_id": p, "confirmed": true}),
|
||||
);
|
||||
|
||||
// How the books are kept: start, conventions, decisions, open items,
|
||||
// a template and an approval, and the settings file moving in.
|
||||
m.ok("books.start", json!({}));
|
||||
m.ok("books.start", json!({"focus": "close"}));
|
||||
m.ok("books.conventions", json!({}));
|
||||
m.ok("books.decisions", json!({}));
|
||||
m.ok("books.open_items", json!({}));
|
||||
m.ok(
|
||||
"books.set_convention",
|
||||
json!({"id": "note-1", "kind": "note", "title": "A note",
|
||||
"body_md": "Kept for the test.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.set_convention",
|
||||
json!({"id": "contribution", "kind": "posting_template",
|
||||
"title": "Joint contribution",
|
||||
"body_md": "Cash in from both members, half each.",
|
||||
"structured": {"lines": [
|
||||
{"account": "1000", "side": "debit", "share": "1"},
|
||||
{"account": "3000", "side": "credit", "share": "0.5"},
|
||||
{"account": "3001", "side": "credit", "share": "0.5"}]},
|
||||
"decided_by": "A", "decided_on": "2026-10-16"}),
|
||||
);
|
||||
let drafted = m.ok(
|
||||
"books.draft_from_template",
|
||||
json!({"template_id": "contribution", "date": "2026-06-01",
|
||||
"amount": "100.01", "no_party_reason": "between the members"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.approve_entry",
|
||||
json!({"entry_id": drafted["entry_id"], "approver": "A"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.retire_convention",
|
||||
json!({"id": "note-1", "reason": "Done with it.", "decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.conventions", json!({"include_retired": true}));
|
||||
m.ok(
|
||||
"books.record_decision",
|
||||
json!({"subject": "Basis", "decision_md": "Accrual.",
|
||||
"decided_by": ["A", "B"], "decided_on": "2026-01-01"}),
|
||||
);
|
||||
m.ok("books.decisions", json!({"include_superseded": true}));
|
||||
let item = m.ok(
|
||||
"books.raise_item",
|
||||
json!({"title": "Mail the letter", "due": "2026-11-01", "decided_by": "A"}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
m.ok(
|
||||
"books.resolve_item",
|
||||
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"}],
|
||||
"decided_by": "A"}),
|
||||
);
|
||||
m.ok("books.export_settings", json!({}));
|
||||
m.ok("books.start", json!({}));
|
||||
|
||||
// The handbook: its index, and a section.
|
||||
m.ok("books.handbook", json!({}));
|
||||
m.ok("books.handbook", json!({"section": "contract"}));
|
||||
|
||||
@@ -122,6 +122,22 @@ impl Mcp {
|
||||
.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()
|
||||
})
|
||||
// Files handed over by path come only from here.
|
||||
.arg("--import-root")
|
||||
.arg(inbox(dir))
|
||||
@@ -2019,8 +2035,19 @@ 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();
|
||||
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.set_setting",
|
||||
json!({"changes": [{"key": "company.timezone", "value": "America/Denver"}],
|
||||
"decided_by": "Owner"}),
|
||||
),
|
||||
(
|
||||
"books.run_schedules",
|
||||
json!({"through": "2026-03-31", "confirmed": true}),
|
||||
|
||||
@@ -182,7 +182,7 @@ fn newer_books_are_refused_as_newer_not_corrupt() {
|
||||
let mut w = ledger_store::JournalWriter::open(&dir.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
w.append(
|
||||
"journal.format_set",
|
||||
serde_json::json!({"format": 2, "features": []}),
|
||||
serde_json::json!({"format": 3, "features": []}),
|
||||
)
|
||||
.unwrap();
|
||||
// The next line chains onto it, so its bytes are checked (B-27).
|
||||
|
||||
Reference in new issue
Block a user