feat(bookspec): the checker crate, the Engine trait and the runner (P13)

bookspec is a specification first: every rule normative and sourced,
every rule demonstrated by concrete examples, and any accounting engine
checked against them through one interface (D-34). This moves the
examples' schema out of the test that happened to parse them and makes
the interface explicit.

- `bookspec/checker` (its own workspace, inside this repository until
  the harness is stable): `Example`, `Setup`, `Event`, `Expect`, `Query`,
  `Refusal` and the TOML parser, every structure `deny_unknown_fields`,
  with `Example::from_toml` checking the cross-field shape (closed lists,
  exact decimal amounts, dates, one event kind, `reverses` counting from
  1, the shares summing to 1, commands accepted or refused and not both).
- `Engine` (`load`, `apply`, `query`, `claims`) and `Refusal { rule,
  reason }`; the refusal's rule is the bookspec identifier, so a refusal
  names the rule it belongs to. `Claims` also carries the option an
  engine picks for a CHOICE rule.
- `bookspec/runner` drives any `Engine` through the examples and reports
  per rule: pass, fail, not claimed. It compares the answer itself; an
  engine is never asked whether it agrees with itself.
- `crates/ledger-core/tests/bookspec.rs` is the wanderbooks adapter over
  `Store`: the loader parses through the checker, its own checks are
  unchanged, and the new `the_runner_reports_every_claimed_rule_green`
  runs all 56 examples through the trait. The IIF appendix is answered
  through `ledger_package::export_iif`, the code its own test checks.
- `just check` runs the bookspec workspace's fmt, clippy, rustdoc and
  tests too, so an example cannot rot (D-34, Z-17).

The plan's trait has four methods and no path for an example's
`[[changes]]` (a rename, the policy, an asset correction), which post no
entry: `change` is the fifth, required so an engine cannot skip those
examples silently. This is reported to the EM as the one departure.

One money rule holds here too: an answer's figures are exact decimal
strings, never JSON numbers, so no engine can round a cent through a
float.
This commit is contained in:
swaits committed 2026-10-26 21:22:46 -06:00
1 parent b2d45905f8
commit 7350adb07a
13 files changed
+4538

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
//! The rule catalogue: identifiers, the index table, and the examples
//! behind each rule.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use bookspec_checker::{Area, Claims, RuleId, Status, covered_rules, rules};
fn repo_path(rel: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join(rel)
}
/// Every rule B-1 to B-45 has exactly one identifier, its area's number
/// counts from one without a gap, and the identifier spells the area.
#[test]
fn every_b_rule_has_one_id() {
let mut seen = BTreeSet::new();
let mut by_area: BTreeMap<Area, Vec<u32>> = BTreeMap::new();
for b in 1..=45 {
let rule = bookspec_checker::rule(b).unwrap_or_else(|| panic!("no rule B-{b}"));
assert_eq!(rule.id.area(), rule.area, "B-{b}: the id's area");
assert_eq!(
rule.id.as_str().split('-').next(),
Some("BS"),
"B-{b}: the identifier's prefix"
);
assert!(seen.insert(rule.id.clone()), "B-{b}: the id is used twice");
assert_eq!(RuleId::parse(rule.id.as_str()).unwrap(), rule.id);
let n: u32 = rule
.id
.as_str()
.rsplit_once('-')
.unwrap()
.1
.parse()
.unwrap();
by_area.entry(rule.area).or_default().push(n);
}
assert_eq!(seen.len(), 45);
for (area, numbers) in by_area {
let mut want: Vec<u32> = (1..=numbers.len() as u32).collect();
let mut got = numbers;
got.sort_unstable();
want.sort_unstable();
assert_eq!(got, want, "{}: numbers count from one", area.key());
}
}
/// The index table is the catalogue: one row per rule, the same id, area
/// and status.
#[test]
fn the_index_matches_the_catalogue() {
let text = std::fs::read_to_string(repo_path("../spec/rules-index.md"))
.expect("bookspec/spec/rules-index.md");
let mut rows = BTreeMap::new();
for line in text.lines() {
let Some(rest) = line.strip_prefix("| B-") else {
continue;
};
let fields: Vec<&str> = line.split('|').map(str::trim).collect();
// "", "B-n", "`BS-...`", area, status, rule, examples, ""
assert_eq!(fields.len(), 8, "the table has six columns: {line}");
let b: u8 = rest
.split_once(' ')
.unwrap()
.0
.parse()
.unwrap_or_else(|_| panic!("{line}"));
let id = fields[2].trim_matches('`');
let area = fields[3];
let status = fields[4];
assert!(
rows.insert(b, (id.to_string(), area.to_string(), status.to_string()))
.is_none()
);
}
for rule in rules() {
let (id, area, status) = rows
.get(&rule.b)
.unwrap_or_else(|| panic!("the index lists B-{}", rule.b));
assert_eq!(id, rule.id.as_str(), "B-{}", rule.b);
assert_eq!(area, rule.area.key(), "B-{}", rule.b);
let want = rule.status.map_or("—", |s| s.key());
assert_eq!(status, want, "B-{}", rule.b);
}
assert_eq!(rows.len(), 45, "the index lists every rule once");
}
/// Every rule either shows an example or is allocated and not yet stated;
/// no example id is invented, and every example file is listed.
#[test]
fn every_example_is_listed_under_a_rule() {
let mut files: Vec<String> = std::fs::read_dir(repo_path("../examples"))
.expect("bookspec/examples")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.map(|p| p.file_stem().unwrap().to_string_lossy().into_owned())
.collect();
files.sort();
let listed: BTreeSet<&str> = rules()
.iter()
.flat_map(|r| r.examples.iter().copied())
.collect();
for f in &files {
assert!(listed.contains(f.as_str()), "{f} is listed under no rule");
}
for id in &listed {
assert!(
files.iter().any(|f| f == id),
"the catalogue lists {id}, which is no example"
);
}
for rule in rules() {
if rule.status.is_none() {
assert!(
!rule.has_examples(),
"B-{} is not yet stated, so it has no example",
rule.b
);
}
}
}
/// A rule with evidence is claimed by an engine; one without is not.
#[test]
fn a_claimed_rule_has_an_example() {
let covered = covered_rules();
assert!(!covered.is_empty());
for rule in covered {
assert!(rule.has_examples(), "{}", rule.id);
assert!(
matches!(
rule.status,
Some(Status::Authority | Status::Convention | Status::Choice)
),
"{}",
rule.id
);
}
}
/// An identifier has one spelling: `BS-<area>-<n>`, no leading zero.
#[test]
fn an_id_has_one_spelling() {
assert_eq!(RuleId::parse("BS-entry-1").unwrap().as_str(), "BS-entry-1");
for bad in [
"entry-1",
"BS-nothing-1",
"BS-entry",
"BS-entry-0",
"BS-entry-01",
"BS-entry-x",
"BS-entry-1-2",
] {
assert!(RuleId::parse(bad).is_err(), "{bad:?}");
}
}
/// A choice rule's option is declared by the engine, not by the spec.
#[test]
fn a_choice_rule_declares_its_option() {
let rule = bookspec_checker::rule(13).unwrap();
assert_eq!(rule.status, Some(Status::Authority));
let choice = bookspec_checker::rule(31).unwrap();
assert_eq!(choice.status, Some(Status::Choice));
let claims = Claims::new([choice.id.clone()]).with_choice(choice.id.clone(), "refuse_blank");
assert_eq!(claims.choice(&choice.id), Some("refuse_blank"));
assert_eq!(claims.choice(&rule.id), None);
}
+296
View File
@@ -0,0 +1,296 @@
//! The example schema: what a well-formed bookspec example is, and what
//! the parser refuses.
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
use bookspec_checker::{
Example, Query, Refusal, TEXT_VALUED, Values, refusal_reasons, rule_for_refusal,
};
fn examples_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples")
}
/// A minimal well-formed example, and the text with one line replaced.
const BASE: &str = r#"
id = "schema.test"
title = "A test"
rule = "Every entry MUST balance."
status = "AUTHORITY"
sources = ["bookspec/README.md"]
tags = ["schema"]
[setup]
currency = "USD"
fiscal_year_start = "01-01"
basis = "accrual"
rounding = "half-even"
[[setup.accounts]]
code = "1000"
name = "Cash"
type = "asset"
[[setup.accounts]]
code = "3000"
name = "Capital"
type = "equity"
[[events]]
date = "2026-01-02"
description = "Contributed"
lines = [
{ account = "1000", side = "debit", amount = "100.00" },
{ account = "3000", side = "credit", amount = "100.00" },
]
[[expect]]
query = "trial_balance"
as_of = "2026-01-02"
[expect.values]
"1000" = "100.00"
"3000" = "-100.00"
"#;
fn base() -> Example {
Example::from_toml(BASE).expect("the base example is well formed")
}
/// Every example of the seed parses and follows the schema: its closed
/// lists, its dates, its amounts and its references.
#[test]
fn every_seed_example_parses_and_validates() {
let mut files: Vec<PathBuf> = std::fs::read_dir(examples_dir())
.expect("bookspec/examples")
.map(|e| e.unwrap().path())
.filter(|p| p.extension().is_some_and(|x| x == "toml"))
.collect();
files.sort();
assert!(files.len() >= 56, "the seed has at least 56 examples");
let mut ids = BTreeSet::new();
for f in &files {
let text = std::fs::read_to_string(f).unwrap();
let ex = Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display()));
let stem = f.file_stem().unwrap().to_string_lossy();
assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display());
assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id);
}
}
/// A key the schema does not have is refused, never ignored.
#[test]
fn an_unknown_key_is_refused() {
let text = BASE.replace("cash_accounts = ", "not_a_key = ");
let text = format!("{text}\ncash_account = []\n");
assert!(Example::from_toml(&text).is_err());
}
/// The status is one of the three the specification uses.
#[test]
fn the_status_is_authority_convention_or_choice() {
let text = BASE.replace(r#"status = "AUTHORITY""#, r#"status = "DECISION""#);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("status"), "{e}");
}
/// A line's amount is exact and positive.
#[test]
fn a_line_amount_is_exact_and_positive() {
let text = BASE.replace(r#"amount = "100.00" }"#, r#"amount = "-100.00" }"#);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("amounts are positive"), "{e}");
let text = BASE.replace(r#"amount = "100.00" }"#, r#"amount = "1e2" }"#);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("exact decimal"), "{e}");
}
/// An event is exactly one of the nine kinds.
#[test]
fn an_event_is_exactly_one_kind() {
let text = BASE.replace(
r#"lines = [
{ account = "1000", side = "debit", amount = "100.00" },
{ account = "3000", side = "credit", amount = "100.00" },
]"#,
r#"lines = [
{ account = "1000", side = "debit", amount = "100.00" },
{ account = "3000", side = "credit", amount = "100.00" },
]
reverses = 1"#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("an event is one of"), "{e}");
}
/// `reverses` counts events from 1 and never names itself or a later one.
#[test]
fn reverses_counts_events_from_one() {
let text = format!(
"{BASE}\n[[events]]\ndate = \"2026-01-03\"\ndescription = \"Reverse it\"\nreverses = 0\n"
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("events count from 1"), "{e}");
}
/// A members close needs the shares, and they sum to 1.
#[test]
fn the_members_shares_sum_to_one() {
let text = BASE.replace(
r#"[[setup.accounts]]
code = "3000""#,
r#"[[setup.members]]
name = "A"
capital_account = "3000"
share = "0.6"
[[setup.members]]
name = "B"
capital_account = "3000"
share = "0.5"
[[setup.accounts]]
code = "3000""#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("shares sum to 1"), "{e}");
}
/// A member's share is an exact decimal fraction above zero.
#[test]
fn a_share_is_a_fraction_above_zero() {
let text = BASE.replace(
r#"[[setup.accounts]]
code = "3000""#,
r#"[[setup.members]]
name = "A"
capital_account = "3000"
share = "1.5"
[[setup.accounts]]
code = "3000""#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("share"), "{e}");
}
/// Every refusal reason the specification names belongs to exactly one
/// rule, and a refusal names that rule.
#[test]
fn every_refusal_reason_names_a_rule() {
for reason in refusal_reasons() {
let rule = rule_for_refusal(reason).unwrap_or_else(|| panic!("no rule for {reason:?}"));
assert!(
!rule.title.contains("allocated"),
"{reason:?} belongs to a stated rule"
);
assert_eq!(Refusal::of_reason(reason).rule, rule.id);
assert_eq!(Refusal::of_reason(reason).reason, reason);
}
assert!(rule_for_refusal("not_a_reason").is_none());
}
/// A query no crate answers is refused.
#[test]
fn an_unknown_query_is_refused() {
let text = BASE.replace(r#"query = "trial_balance""#, r#"query = "trial_balances""#);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("no query answers"), "{e}");
}
/// A period may be refused instead of answering; another query may not.
#[test]
fn a_period_may_be_refused() {
let text = BASE.replace(
r#"query = "trial_balance"
as_of = "2026-01-02"
[expect.values]
"1000" = "100.00"
"3000" = "-100.00""#,
r#"query = "trial_balance"
as_of = "2026-01-02"
refused = "end_before_start""#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("only a period may be refused"), "{e}");
}
/// A command is accepted or refused, never both, and shows no values.
#[test]
fn a_command_shows_no_values() {
let text = BASE.replace(
r#"query = "trial_balance"
as_of = "2026-01-02"
[expect.values]
"1000" = "100.00"
"3000" = "-100.00""#,
r#"query = "rename_account"
code = "1000"
name = "Cash on hand"
accepted = true
[expect.values]
"1000" = "100.00""#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("a command is accepted"), "{e}");
}
/// A text-valued figure query takes words; a figure query takes exact
/// decimal strings.
#[test]
fn a_figure_query_takes_exact_decimals() {
assert!(TEXT_VALUED.contains(&"vendor_1099"));
let text = BASE.replace(r#""1000" = "100.00""#, r#""1000" = "100.0.0""#);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("exact decimal"), "{e}");
}
/// `Query` carries the question and not the answer, and knows which
/// queries list everything they cover.
#[test]
fn a_query_does_not_carry_its_expected_values() {
let ex = base();
let x = &ex.expect[0];
let q: Query = x.query();
assert_eq!(q.name, "trial_balance");
assert!(q.is_complete());
assert!(!q.is_command());
assert!(!q.is_format());
assert_eq!(
x.values("schema.test"),
&Values::Figures(BTreeMap::from([
("1000".to_string(), "100.00".to_string()),
("3000".to_string(), "-100.00".to_string()),
]))
);
}
/// A format query belongs to the formats appendix.
#[test]
fn a_format_query_is_of_the_formats_appendix() {
let text = BASE.replace(
r#"query = "trial_balance"
as_of = "2026-01-02""#,
r#"query = "iif_lines"
as_of = "2026-01-02""#,
);
let e = Example::from_toml(&text).unwrap_err();
assert!(e.message.contains("iif_lines"), "{e}");
}
/// The seed's index is the table the catalogue is: B-1 to B-45, one
/// identifier each. The catalogue test reads it too; this checks the ids
/// themselves.
#[test]
fn the_rule_index_lists_every_stated_rule() {
let index = std::fs::read_to_string(
Path::new(env!("CARGO_MANIFEST_DIR")).join("../spec/rules-index.md"),
)
.expect("bookspec/spec/rules-index.md");
for b in 1..=45 {
assert!(
index.contains(&format!("| B-{b} |")),
"the index lists B-{b}"
);
}
}