feat(bookspec): GnuCash as the second engine, its differences settled (P13 step 5)

bookspec-gnucash implements the Engine trait over the oracle container
(oracle/gnucash/books.py) and claims the rules whose examples ask only
for balances. It passes BS-entry-3, BS-ledger-3, BS-statement-1 and
BS-statement-2 and differs from three examples, each adjudicated from the
sources and pinned by tests: GnuCash accepts an unbalanced entry and
parks the difference in an Imbalance account (BS-entry-1 refuses it); it
reads a credit of minus 100 as a debit (BS-entry-2 is a convention that
removes the ambiguity); it rounds 10.005 to 10.01 silently (BS-ledger-1
is the owner's choice not to). The example stands in each case: GnuCash
is a cross-check, not the authority.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 12:51:30 -06:00
1 parent 40eb606bf3
commit f8cd4abbce
6 files changed
+488 -1

No files matched your search

+18
View File
@@ -0,0 +1,18 @@
[package]
name = "bookspec-gnucash"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "A second engine for bookspec: GnuCash, driven through the oracle container's files."
[dependencies]
bookspec-checker = { path = "../checker" }
serde_json = "1"
rust_decimal = "1"
[dev-dependencies]
bookspec-runner = { path = "../runner" }
[lints]
workspace = true
+250
View File
@@ -0,0 +1,250 @@
//! GnuCash as a second bookspec engine (`plan/served.md` P13 step 5).
//!
//! The adapter keeps an example's chart and entries as the oracle's JSON
//! books (`oracle/gnucash/books.py`: accounts, entries, as-of days) and
//! asks GnuCash 5.10, in its pinned container, for each balance. GnuCash
//! is a cross-check, never the authority (`docs/oracle.md`, "Who is
//! right"): where it differs from an example, the difference is
//! adjudicated from the sources and recorded, not conformed to.
//!
//! Only what GnuCash itself answers is claimed: the balance queries
//! (`trial_balance`, `balances`), by the rules whose examples ask nothing
//! else. GnuCash never refuses an entry (it books the difference to an
//! `Imbalance` account), so a refused event is *accepted* here, as it
//! would be by a GnuCash user, and the runner reports the difference.
use std::collections::BTreeMap;
use std::io::Write;
use std::process::{Command, Stdio};
use std::str::FromStr;
use bookspec_checker::{
Area, ChangeSpec, Claims, Engine, Event, Query, Refusal, RuleId, Setup, fallback_refusal,
};
use rust_decimal::Decimal;
use serde_json::{Value, json};
/// The oracle container (`just oracle` builds it from `oracle/gnucash`).
pub const IMAGE: &str = "wanderbooks-gnucash-oracle";
/// The rules this adapter claims: those whose examples ask only for
/// balances, which GnuCash answers itself.
pub fn claimed() -> Vec<RuleId> {
vec![
RuleId::new(Area::Entry, 1),
RuleId::new(Area::Entry, 2),
RuleId::new(Area::Entry, 3),
RuleId::new(Area::Ledger, 1),
RuleId::new(Area::Ledger, 3),
RuleId::new(Area::Statement, 1),
RuleId::new(Area::Statement, 2),
]
}
/// One account of the book handed to GnuCash.
struct Account {
code: String,
kind: &'static str,
normal_debit: bool,
}
/// GnuCash, as bookspec's `Engine`.
#[derive(Default)]
pub struct GnuCash {
accounts: Vec<Account>,
entries: Vec<Value>,
/// Each event's entry, by event number (1-based), for reversals.
event_entries: BTreeMap<usize, usize>,
events: usize,
}
impl GnuCash {
/// An empty engine.
pub fn new() -> Self {
Self::default()
}
fn id(&self, code: &str) -> Option<usize> {
self.accounts
.iter()
.position(|a| a.code == code)
.map(|i| i + 1)
}
fn book(&self, as_of: &str) -> Value {
let accounts: Vec<Value> = self
.accounts
.iter()
.enumerate()
.map(|(i, a)| json!({"id": i + 1, "code": a.code, "name": a.code, "type": a.kind}))
.collect();
json!({"accounts": accounts, "entries": self.entries, "as_of": [as_of]})
}
/// GnuCash's own balance of every account as of a day: debits
/// positive, exact decimal strings, by account code.
fn balances(&self, as_of: &str) -> Result<BTreeMap<String, Decimal>, Refusal> {
let mut child = Command::new("docker")
.args(["run", "--rm", "-i", IMAGE])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| unavailable(format!("docker: {e}")))?;
let books = json!([self.book(as_of)]);
child
.stdin
.take()
.unwrap()
.write_all(books.to_string().as_bytes())
.map_err(|e| unavailable(format!("docker stdin: {e}")))?;
let out = child
.wait_with_output()
.map_err(|e| unavailable(format!("docker: {e}")))?;
if !out.status.success() {
return Err(unavailable(
"the GnuCash oracle failed; build it with `just oracle`".into(),
));
}
let results: Value = serde_json::from_slice(&out.stdout)
.map_err(|e| unavailable(format!("oracle output: {e}")))?;
let at = &results[0]["balances"][as_of];
let mut map = BTreeMap::new();
for (i, a) in self.accounts.iter().enumerate() {
let v = at[(i + 1).to_string()]
.as_str()
.ok_or_else(|| unavailable(format!("no balance for {}", a.code)))?;
map.insert(
a.code.clone(),
Decimal::from_str(v).map_err(|e| unavailable(e.to_string()))?,
);
}
Ok(map)
}
}
fn unavailable(why: String) -> Refusal {
fallback_refusal(
RuleId::new(Area::Ledger, 1),
format!("the GnuCash oracle: {why}"),
)
}
fn not_supported(what: &str) -> Refusal {
fallback_refusal(
RuleId::new(Area::Ledger, 1),
format!("the GnuCash adapter does not take {what}"),
)
}
impl Engine for GnuCash {
fn load(&mut self, setup: &Setup) -> Result<(), Refusal> {
for a in &setup.accounts {
let (kind, normal_debit) = match a.kind.as_str() {
"asset" => ("Asset", true),
"liability" => ("Liability", false),
"equity" => ("Equity", false),
"revenue" => ("Revenue", false),
"expense" => ("Expense", true),
other => return Err(not_supported(&format!("account type {other}"))),
};
self.accounts.push(Account {
code: a.code.clone(),
kind,
normal_debit,
});
}
if setup.currency != "USD" {
return Err(not_supported("a currency other than USD"));
}
Ok(())
}
fn apply(&mut self, event: &Event) -> Result<(), Refusal> {
self.events += 1;
let n = self.events;
let date = event
.date
.clone()
.ok_or_else(|| not_supported("an undated event"))?;
let description = event.description.clone().unwrap_or_default();
if !event.lines.is_empty() {
let mut lines = Vec::new();
for l in &event.lines {
let account = self
.id(&l.account)
.ok_or_else(|| not_supported("an unknown account"))?;
lines.push(json!({"account": account, "side": l.side, "amount": l.amount}));
}
self.entries
.push(json!({"date": date, "description": description, "lines": lines}));
self.event_entries.insert(n, self.entries.len() - 1);
return Ok(());
}
if let Some(of) = event.reverses {
// GnuCash has no reversal link; a user reverses by writing the
// opposite transaction, which is what is done here.
let idx = *self
.event_entries
.get(&of)
.ok_or_else(|| not_supported("a reversal of an event that posted nothing"))?;
let original = self.entries[idx].clone();
let flipped: Vec<Value> = original["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| {
let side = if l["side"] == "debit" {
"credit"
} else {
"debit"
};
json!({"account": l["account"], "side": side, "amount": l["amount"]})
})
.collect();
self.entries
.push(json!({"date": date, "description": description, "lines": flipped}));
self.event_entries.insert(n, self.entries.len() - 1);
return Ok(());
}
Err(not_supported("this kind of event"))
}
fn query(&self, q: &Query) -> Result<Value, Refusal> {
let as_of = q
.as_of
.clone()
.ok_or_else(|| not_supported("a query without as_of"))?;
match q.name.as_str() {
"trial_balance" | "balances" => {
let balances = self.balances(&as_of)?;
let mut out = serde_json::Map::new();
for a in &self.accounts {
let b = balances[&a.code];
let mut shown = if q.name == "balances" && !a.normal_debit {
-b
} else {
b
};
if shown.is_zero() {
shown = Decimal::ZERO;
}
if shown.scale() < 2 {
shown.rescale(2);
}
out.insert(a.code.clone(), Value::String(shown.to_string()));
}
Ok(Value::Object(out))
}
other => Err(not_supported(&format!("the {other} query"))),
}
}
fn claims(&self) -> Claims {
Claims::new(claimed())
}
fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> {
Err(not_supported("a change"))
}
}
+137
View File
@@ -0,0 +1,137 @@
//! GnuCash, run through bookspec's examples (`just oracle` builds the
//! container). Needs Docker, so the test is ignored by default:
//! `cargo test -p bookspec-gnucash -- --ignored --nocapture`.
use std::path::{Path, PathBuf};
use bookspec_checker::{Example, rules};
use bookspec_gnucash::{GnuCash, claimed};
fn examples() -> Vec<Example> {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples");
let mut files: Vec<PathBuf> = std::fs::read_dir(dir)
.unwrap()
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
files.sort();
files
.iter()
.map(|f| Example::from_toml(&std::fs::read_to_string(f).unwrap()).unwrap())
.collect()
}
/// The differences GnuCash shows, each settled from the sources
/// (`bookspec/README.md`, "GnuCash, the second engine"). A new difference,
/// or one that goes away, fails this test until it is adjudicated again.
const ADJUDICATED: [(&str, &str); 3] = [
(
"entry-an-unbalanced-entry-is-refused",
"event 2: accepted, expected refused: unbalanced",
),
(
"entry-a-line-amount-is-positive",
"event 1: accepted, expected refused: negative_amount",
),
(
"ledger-a-fraction-of-a-cent-is-refused",
"event 1: accepted, expected refused: finer_than_minor_unit",
),
];
fn run_claimed() -> bookspec_runner::Report {
let claimed = claimed();
let mut wanted = std::collections::BTreeSet::new();
for r in rules().iter().filter(|r| claimed.contains(&r.id)) {
wanted.extend(r.examples.iter().copied());
}
let examples: Vec<Example> = examples()
.into_iter()
.filter(|e| wanted.contains(e.id.as_str()))
.collect();
bookspec_runner::run(&examples, rules(), |_| GnuCash::new())
}
#[test]
#[ignore = "needs Docker and the GnuCash image: `just oracle`"]
fn gnucash_differs_only_where_it_was_adjudicated() {
let report = run_claimed();
let found: Vec<(&str, &str)> = report
.examples
.iter()
.filter_map(|e| e.failure.as_deref().map(|f| (e.id.as_str(), f)))
.collect();
let mut want: Vec<(&str, &str)> = ADJUDICATED.to_vec();
want.sort();
let mut got = found.clone();
got.sort();
assert_eq!(got, want, "GnuCash's differences changed: adjudicate again");
// The rules that every example passes are passed.
for rule in [
"BS-entry-3",
"BS-ledger-3",
"BS-statement-1",
"BS-statement-2",
] {
let id = bookspec_checker::RuleId::parse(rule).unwrap();
assert_eq!(report.rules[&id], bookspec_runner::Outcome::Pass, "{rule}");
}
}
/// The evidence the adjudications rest on, from GnuCash's own answers:
/// what it did with the entry it accepted.
#[test]
#[ignore = "needs Docker and the GnuCash image: `just oracle`"]
fn what_gnucash_did_with_the_entries_the_specification_refuses() {
use bookspec_checker::Engine;
use std::str::FromStr;
let by_id = |id: &str| examples().into_iter().find(|e| e.id == id).unwrap();
let net = |id: &str, event: usize| -> rust_decimal::Decimal {
let ex = by_id(id);
let mut g = GnuCash::new();
g.load(&ex.setup).unwrap();
for e in ex.events.iter().take(event) {
g.apply(e).expect("GnuCash accepts it");
}
let q = ex
.expect
.iter()
.find(|x| x.query == "trial_balance")
.unwrap()
.query();
let answer = g.query(&q).unwrap();
answer
.as_object()
.unwrap()
.values()
.map(|v| rust_decimal::Decimal::from_str(v.as_str().unwrap()).unwrap())
.sum()
};
// Debits 100.00 against credits 90.00: the accounts GnuCash lists no
// longer net to zero; ten dollars sit where GnuCash parked them, in an
// Imbalance account of its own making.
assert_eq!(
net("entry-an-unbalanced-entry-is-refused", 2).to_string(),
"10.00"
);
// A credit of minus 100.00 is booked as a debit of 100.00: both lines
// are debits, and 200.00 sits in the Imbalance account.
assert_eq!(
net("entry-a-line-amount-is-positive", 1).to_string(),
"200.00"
);
// 10.005 on each side is rounded to the cent, silently: GnuCash's USD
// balance reads 10.01 (the choice rules.md B-3 records the owner took
// the other way, after the first cross-check found it).
let ex = by_id("ledger-a-fraction-of-a-cent-is-refused");
let mut g = GnuCash::new();
g.load(&ex.setup).unwrap();
g.apply(&ex.events[0]).unwrap();
let q = ex
.expect
.iter()
.find(|x| x.query == "trial_balance")
.unwrap()
.query();
assert_eq!(g.query(&q).unwrap()["1000"], "10.01");
}