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

+17
View File
@@ -0,0 +1,17 @@
[package]
name = "bookspec-checker"
version = "0.1.0"
edition.workspace = true
license.workspace = true
repository.workspace = true
description = "bookspec's example schema, its rule identifiers and the Engine trait an accounting engine implements."
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "1"
rust_decimal = "1"
chrono = { version = "0.4", features = ["serde"] }
[lints]
workspace = true
+224
View File
@@ -0,0 +1,224 @@
//! The interface an accounting engine implements to be checked against
//! bookspec (`plan/served.md` P13, D-34).
//!
//! bookspec is a specification first: its examples are the concrete cases
//! (`bookspec/examples/*.toml`), its rule identifiers the normative rules
//! (`BS-<area>-<n>`), and this trait the only thing an engine has to
//! provide for the runner to check it. The engine's answers are compared
//! with the examples' expected values by the runner; an engine is never
//! asked whether it agrees with itself.
use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::str::FromStr;
use crate::rules::{Area, Rule, rule_for_id, rule_for_refusal};
use crate::schema::{ChangeSpec, Event, Query, Setup};
/// A bookspec rule identifier: `BS-<area>-<n>` (e.g. `BS-entry-1`).
///
/// The areas are the specification's subjects (`Area`), and `n` counts
/// from 1 within an area. The catalogue in [`mod@crate::rules`] is the list of
/// every identifier the specification states; an identifier that is not in
/// it is a typo, not a rule.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct RuleId(String);
/// Why a string is not a bookspec rule identifier.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuleIdError {
what: String,
}
impl fmt::Display for RuleIdError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.what)
}
}
impl std::error::Error for RuleIdError {}
impl RuleId {
/// The identifier of area `area`, rule number `n` (counting from 1).
pub fn new(area: Area, n: u32) -> Self {
assert!(n >= 1, "bookspec rule numbers count from 1");
Self(format!("BS-{}-{n}", area.key()))
}
/// Parse `BS-<area>-<n>`, refusing an unknown area, a missing or
/// non-numeric number, and a number that is zero or written with a
/// leading zero (one spelling per rule).
pub fn parse(s: &str) -> Result<Self, RuleIdError> {
let rest = s.strip_prefix("BS-").ok_or_else(|| RuleIdError {
what: format!("{s:?} is no bookspec rule id (BS-<area>-<n>)"),
})?;
let (area, n) = rest.rsplit_once('-').ok_or_else(|| RuleIdError {
what: format!("{s:?} is no bookspec rule id (BS-<area>-<n>)"),
})?;
let area = Area::parse(area).ok_or_else(|| RuleIdError {
what: format!("{s:?}: {:?} is no bookspec area", area),
})?;
if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
return Err(RuleIdError {
what: format!("{s:?}: {n:?} is no rule number"),
});
}
if n.starts_with('0') {
return Err(RuleIdError {
what: format!("{s:?}: rule numbers count from 1, without a leading zero"),
});
}
let n: u32 = n.parse().map_err(|_| RuleIdError {
what: format!("{s:?}: {n:?} is no rule number"),
})?;
if n == 0 {
return Err(RuleIdError {
what: format!("{s:?}: rule numbers count from 1"),
});
}
Ok(Self::new(area, n))
}
/// The identifier as the specification writes it.
pub fn as_str(&self) -> &str {
&self.0
}
/// The area it belongs to.
pub fn area(&self) -> Area {
// Constructed only by `new`/`parse`, both of which check the area.
Area::parse(self.0.split('-').nth(1).expect("a checked rule id"))
.expect("a checked rule id's area")
}
/// The rule's entry in the catalogue, if the specification states it.
pub fn rule(&self) -> Option<&'static Rule> {
rule_for_id(self)
}
}
impl fmt::Display for RuleId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl FromStr for RuleId {
type Err = RuleIdError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::parse(s)
}
}
/// An engine's refusal: the rule that refuses, and why in the example's
/// words (bookspec/README.md's refusal vocabulary, never an engine's own
/// message).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Refusal {
/// The bookspec rule the refusal belongs to.
pub rule: RuleId,
/// What was refused, in the specification's words.
pub reason: String,
}
impl Refusal {
/// A refusal of `reason` under `rule`.
pub fn new(rule: RuleId, reason: impl Into<String>) -> Self {
Self {
rule,
reason: reason.into(),
}
}
/// A refusal of `reason`, under the rule the specification assigns
/// that refusal (see [`rule_for_refusal`]); an unknown reason is a
/// specification defect, and the first rule of the ledger area is
/// named so the report still has an identifier to show.
pub fn of_reason(reason: impl Into<String>) -> Self {
let reason = reason.into();
let rule = rule_for_refusal(&reason)
.map(|r| r.id.clone())
.unwrap_or_else(|| RuleId::new(Area::Ledger, 1));
Self { rule, reason }
}
}
/// What an engine declares about itself: the rules it takes the
/// conformance test on, and the option it picks for each of the
/// specification's choices (`CHOICE`: the specification names the options,
/// an engine declares its pick; `plan/served.md` P13 step 3).
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct Claims {
/// The rule identifiers the engine claims.
pub rules: BTreeSet<RuleId>,
/// The choice rules whose option the engine picks, and the option.
pub choices: BTreeMap<RuleId, String>,
}
impl Claims {
/// The claims of an engine that claims `rules`, with no choices.
pub fn new(rules: impl IntoIterator<Item = RuleId>) -> Self {
Self {
rules: rules.into_iter().collect(),
choices: BTreeMap::new(),
}
}
/// True when `rule` is claimed.
pub fn claims(&self, rule: &RuleId) -> bool {
self.rules.contains(rule)
}
/// Declare the option this engine picks for `rule`.
pub fn with_choice(mut self, rule: RuleId, option: impl Into<String>) -> Self {
self.choices.insert(rule, option.into());
self
}
/// The option this engine picks for `rule`, if it declares one.
pub fn choice(&self, rule: &RuleId) -> Option<&str> {
self.choices.get(rule).map(String::as_str)
}
}
/// An accounting engine, as bookspec checks it.
///
/// The four methods are the plan's (`plan/served.md` 8.2 P13 step 2); the
/// fifth, [`Engine::change`], is the one departure: an example's
/// `[[changes]]` (a rename, the tax-category policy, an asset correction)
/// posts no entry, so it is neither a setup nor an event, and the plan's
/// four methods have no path for it. It is required, never defaulted, so
/// an engine cannot silently skip the examples that use one.
pub trait Engine {
/// Set the books up: the chart, the fixed assets, the schedules, the
/// parties, the members, the payees and their dated decisions, the
/// payment methods and the projects.
fn load(&mut self, setup: &Setup) -> Result<(), Refusal>;
/// Apply one event: an entry, a reversal, a close, a bill, an invoice,
/// a depreciation run, a disposal, a schedules run or a schedule's
/// registration. An event the engine refuses leaves the books as they
/// were.
fn apply(&mut self, event: &Event) -> Result<(), Refusal>;
/// Answer one query with the engine's own figures. A figure is an
/// exact decimal string (never a JSON number: no engine may round a
/// cent through a float), a date is `YYYY-MM-DD`, and a format
/// query's value is its lines or rows. A query the engine refuses
/// (a period the rule refuses, a command it refuses) is an
/// [`Err`].
fn query(&self, q: &Query) -> Result<serde_json::Value, Refusal>;
/// The rules and choices this engine declares.
fn claims(&self) -> Claims;
/// Apply one change that posts no entry (a `[[changes]]` entry).
fn change(&mut self, change: &ChangeSpec) -> Result<(), Refusal>;
}
/// The refusal an engine reports for an error it has no example-word
/// reason for: the reason as given, under `fallback`.
pub fn fallback_refusal(fallback: RuleId, reason: impl Into<String>) -> Refusal {
Refusal::new(fallback, reason)
}
+23
View File
@@ -0,0 +1,23 @@
//! bookspec's checker: the example schema, the rule identifiers, and the
//! [`Engine`] trait an accounting engine implements (`plan/served.md` P13,
//! D-34).
//!
//! bookspec is a specification first: every rule is stated normatively,
//! sourced, and demonstrated by concrete examples (`bookspec/examples/`).
//! This crate is the machine-readable part: [`Example`] is the schema and
//! its parser, [`mod@rules`] the catalogue (`BS-<area>-<n>`), and [`Engine`]
//! the only interface a conformance runner needs. The runner lives in
//! `bookspec-runner`; wanderbooks' adapter is
//! `crates/ledger-core/tests/bookspec.rs`.
//!
//! The crate knows nothing of any engine: no money type, no ledger, no
//! store. That is what lets a second engine (the GnuCash oracle, later) be
//! checked by the same examples.
pub mod engine;
pub mod rules;
pub mod schema;
pub use engine::{Claims, Engine, Refusal, RuleId, RuleIdError, fallback_refusal};
pub use rules::{Area, Rule, Status, covered_rules, rule, rule_for_id, rule_for_refusal, rules};
pub use schema::*;
+583
View File
@@ -0,0 +1,583 @@
//! bookspec's rule identifiers and their catalogue.
//!
//! Every rule of `docs/spec/rules.md` (B-1 to B-45) has one bookspec
//! identifier, `BS-<area>-<n>`, allocated by [`Area`] and numbered in
//! B-rule order within its area. `bookspec/spec/rules-index.md` is the
//! table, and a test keeps the two in step. The rules from B-39 on are
//! allocated in `plan/served.md` 8.0.3 but not yet stated; they carry an
//! identifier and an area and no status, so a later unit fills them in
//! without moving anyone else's number.
use std::sync::LazyLock;
use crate::engine::RuleId;
/// One of bookspec's areas: the subject a rule belongs to.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Area {
/// The mechanics of an entry: lines, sides, amounts, dates.
Entry,
/// The ledger: the chart, accounts, the journal, the format.
Ledger,
/// Reports: the trial balance, the accounting equation, statements.
Statement,
/// Periods: closes, closed periods, what an omitted date means.
Period,
/// Partners and parties: shares, members' capital, the directory.
Partner,
/// Fixed assets and capitalization.
Asset,
/// Prepaid expenses and deferred revenue.
Prepaid,
/// Receivables and payables: documents, aging, settlements.
Arap,
/// Revenue: when it is recognized and how it is presented.
Revenue,
/// Information returns: 1099s and their thresholds.
Tax1099,
/// Interchange formats: the journal format gate and the IIF.
Format,
}
impl Area {
/// Every area, in the order the specification lists them.
pub const ALL: [Area; 11] = [
Area::Entry,
Area::Ledger,
Area::Statement,
Area::Period,
Area::Partner,
Area::Asset,
Area::Prepaid,
Area::Arap,
Area::Revenue,
Area::Tax1099,
Area::Format,
];
/// The area's key in a rule identifier.
pub fn key(self) -> &'static str {
match self {
Area::Entry => "entry",
Area::Ledger => "ledger",
Area::Statement => "statement",
Area::Period => "period",
Area::Partner => "partner",
Area::Asset => "asset",
Area::Prepaid => "prepaid",
Area::Arap => "arap",
Area::Revenue => "revenue",
Area::Tax1099 => "1099",
Area::Format => "format",
}
}
/// The area `key` names.
pub fn parse(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|a| a.key() == key)
}
}
/// The status of a rule, in bookspec's vocabulary: what its statement
/// rests on. `docs/spec/rules.md`'s "DECISION" is bookspec's [`Status::Choice`]
/// (`plan/served.md` P13 step 3): the specification names the options, and
/// an engine declares its pick through [`crate::Claims`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub enum Status {
/// A cited authority states the rule.
Authority,
/// Common practice, where no authority states the rule.
Convention,
/// A human's or the house's choice among options the specification
/// names.
Choice,
}
impl Status {
/// The status as the specification writes it.
pub fn key(self) -> &'static str {
match self {
Status::Authority => "AUTHORITY",
Status::Convention => "CONVENTION",
Status::Choice => "CHOICE",
}
}
/// The status `key` names.
pub fn parse(key: &str) -> Option<Self> {
[Status::Authority, Status::Convention, Status::Choice]
.into_iter()
.find(|s| s.key() == key)
}
}
/// One rule of the specification.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Rule {
/// Its number in `docs/spec/rules.md`, 1 to 45.
pub b: u8,
/// Its bookspec identifier.
pub id: RuleId,
/// Its area.
pub area: Area,
/// Its normative title, as `docs/spec/rules.md` states it.
pub title: &'static str,
/// What its statement rests on; `None` for a rule allocated but not
/// yet stated (B-39 to B-45, `plan/served.md` 8.0.3).
pub status: Option<Status>,
/// The example ids that demonstrate it, by `Example::id`.
pub examples: &'static [&'static str],
}
impl Rule {
/// True when the rule has at least one example to check it.
pub fn has_examples(&self) -> bool {
!self.examples.is_empty()
}
}
/// The catalogue: every rule, in B-rule order.
pub fn rules() -> &'static [Rule] {
static RULES: LazyLock<Vec<Rule>> = LazyLock::new(build);
&RULES
}
/// The rule `b` (1 to 45).
pub fn rule(b: u8) -> Option<&'static Rule> {
rules().iter().find(|r| r.b == b)
}
/// The rule `id` names, if the specification states it.
pub fn rule_for_id(id: &RuleId) -> Option<&'static Rule> {
rules().iter().find(|r| &r.id == id)
}
/// The rule a refusal belongs to, by its reason in the examples' words
/// (bookspec/README.md).
pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> {
let b = match reason {
"end_before_start" | "start_after_today" => 30,
"blank" | "unchanged" | "sibling_has_it" | "holds_a_tin" | "not_one_line"
| "code_taken" => 31,
"tax_category_required" => 32,
"depreciation_past_disposal"
| "entry_irreversible"
| "already_disposed"
| "invalid_depreciation_period" => 15,
"invalid_schedule" => 16,
_ => return None,
};
rule(b)
}
/// The rules an engine can claim today: those with at least one example.
/// A rule with no example has no evidence, so an engine cannot claim it
/// (`plan/served.md` P13 step 4 fills the rest in).
pub fn covered_rules() -> Vec<&'static Rule> {
rules().iter().filter(|r| r.has_examples()).collect()
}
fn build() -> Vec<Rule> {
// The areas' ordinals are assigned here in B-rule order, one line per
// rule: an area's n is its place in `Area::ALL`'s set of rules.
let mut next = std::collections::BTreeMap::new();
let mut out = Vec::with_capacity(45);
for (b, area, title, status, examples) in SPEC {
let n: u32 = *next.entry(*area).and_modify(|n| *n += 1).or_insert(1);
out.push(Rule {
b: *b,
id: RuleId::new(*area, n),
area: *area,
title,
status: *status,
examples,
});
}
out
}
type Spec = &'static [(
u8,
Area,
&'static str,
Option<Status>,
&'static [&'static str],
)];
const SPEC: Spec = &[
(
1,
Area::Entry,
"Every entry is a double entry: debits equal credits",
Some(Status::Authority),
&[],
),
(
2,
Area::Entry,
"A line's amount is positive; its side gives the direction",
Some(Status::Convention),
&[],
),
(
3,
Area::Ledger,
"One money through the ledger; foreign amounts at the transaction-date rate",
Some(Status::Authority),
&[],
),
(
4,
Area::Ledger,
"Five elements, closed list",
Some(Status::Authority),
&[],
),
(
5,
Area::Ledger,
"Normal balances",
Some(Status::Convention),
&[],
),
(
6,
Area::Statement,
"The trial balance balances",
Some(Status::Authority),
&[],
),
(
7,
Area::Statement,
"The accounting equation",
Some(Status::Authority),
&[],
),
(
8,
Area::Ledger,
"Posted is forever: the book is written only forward",
Some(Status::Authority),
&[],
),
(
9,
Area::Ledger,
"A mistake is corrected by an opposite entry, never erased",
Some(Status::Authority),
&[],
),
(
10,
Area::Ledger,
"An entry is reversed at most once; a closing entry never",
Some(Status::Choice),
&[],
),
(
11,
Area::Period,
"Revenue and expenses close into profit and loss, and that into capital",
Some(Status::Authority),
&[],
),
(
12,
Area::Period,
"A closed period takes no new postings; periods close in order",
Some(Status::Convention),
&[],
),
(
13,
Area::Partner,
"Partners share income by the partnership agreement; capital accounts carry it",
Some(Status::Authority),
&[
"members-close.odd-cent-across-closes",
"members-close.statement-before-and-after-a-close",
"members-close.thirds-over-twelve-months",
"members-close.true-up-of-a-per-close-split",
"p3-party-members-equity-and-k1",
],
),
(
14,
Area::Statement,
"Statements: P&L for a period; cash flows by the direct method",
Some(Status::Authority),
&[
"cash-flow.debt-to-equity",
"cash-flow.government-fees",
"cash-flow.paid-on-the-companys-behalf",
"cash-flow.part-cash-purchase",
"cash-flow.reversal-of-a-reversal",
"cash-flow.reversed-property-contribution",
"cash-flow.two-member-llc-first-year",
],
),
(
15,
Area::Asset,
"Depreciation: straight line, salvage never depreciated, partial months by days",
Some(Status::Convention),
&[
"assets-acquired-before-in-service",
"assets-a-reversed-run-is-not-posted",
"assets-book-value-is-what-the-books-carry",
"depreciation-catch-up-month",
"depreciation-disposal-after-a-late-change-undone",
"depreciation-disposal-before-a-later-run",
"depreciation-disposal-in-final-month",
"depreciation-disposal-inside-a-posted-month",
"depreciation-disposal-mid-month",
"depreciation-final-month-by-days",
"depreciation-final-month-drift",
"depreciation-reversal-before-its-run-is-refused",
"depreciation-run-reversed-after-its-month",
],
),
(
16,
Area::Prepaid,
"Prepaid expenses and deferred revenue are recognized over the period benefited or earned",
Some(Status::Authority),
&[
"schedule-daily-deferred-revenue",
"schedule-daily-small-first-and-last-month",
"schedule-equal-months-start-on-the-first",
],
),
(
17,
Area::Revenue,
"Platform payouts are recorded gross when the business is the principal",
Some(Status::Authority),
&[],
),
(
18,
Area::Arap,
"Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due",
Some(Status::Convention),
&[],
),
(
19,
Area::Tax1099,
"1099 reporting thresholds, payment methods, and decisions",
Some(Status::Authority),
&[
"1099-a-dated-status-applies-by-payment-date",
"1099-a-members-check-through-financing-counts",
"1099-and-party-totals-for-a-members-payment",
"1099-card-payments-are-excluded",
"1099-money-between-own-accounts-pays-no-one",
"1099-money-from-a-payee-is-netted-only-as-a-refund",
"1099-the-threshold-is-the-years",
"1099-worksheet-through-as-of",
],
),
(
20,
Area::Ledger,
"Every transaction has supporting documents; records are kept",
Some(Status::Authority),
&[],
),
(
21,
Area::Entry,
"Exact amounts in the books; rounding only on returns",
Some(Status::Authority),
&[],
),
(
22,
Area::Asset,
"Expense or capitalize (book policy, mirrors tax)",
Some(Status::Authority),
&[],
),
(
23,
Area::Ledger,
"Control roles: one account each, of the right kind, moved only when empty",
Some(Status::Convention),
&[],
),
(
24,
Area::Asset,
"Asset tags: one label per asset, never reused",
Some(Status::Choice),
&[],
),
(
25,
Area::Asset,
"Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain",
Some(Status::Authority),
&[
"assets-723-basis-apart-from-depreciable-basis",
"assets-built-in-gain-fixed-at-contribution",
"assets-four-contributed-devices",
],
),
(
26,
Area::Partner,
"Parties: one directory, no full TIN in the books",
Some(Status::Authority),
&["p3-party-tin-last4"],
),
(
27,
Area::Format,
"The journal format gate: newer is not corrupt",
Some(Status::Choice),
&[],
),
(
28,
Area::Entry,
"What new commands take: four-digit years, amounts a line can carry",
Some(Status::Convention),
&[],
),
(
29,
Area::Ledger,
"The books keep how they are kept",
Some(Status::Choice),
&[],
),
(
30,
Area::Period,
"A report asked for without dates covers this fiscal year so far",
Some(Status::Authority),
&["default-period-fiscal-year-to-date"],
),
(
31,
Area::Ledger,
"Renaming an account changes its label, nothing else",
Some(Status::Choice),
&["rename-changes-no-amount"],
),
(
32,
Area::Ledger,
"Every revenue and expense account maps to a line of the return",
Some(Status::Authority),
&["tax-category-required"],
),
(
33,
Area::Statement,
"A package as of a day shows the books through that day",
Some(Status::Convention),
&["as-of-package-stops-at-as-of"],
),
(
34,
Area::Format,
"The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried",
Some(Status::Authority),
&["iif-quickbooks-journal-file"],
),
(
35,
Area::Partner,
"A party's part of an entry, and a party's totals",
Some(Status::Convention),
&[
"p3-party-accrual-counts-once",
"p3-party-distribution-still-owed",
"p3-party-gift-card-is-a-promise",
"p3-party-members-equity-and-k1",
"p3-party-own-liability-never-pays",
"p3-party-reversed-card-payment",
"p3-party-signs-and-reversals",
],
),
(
36,
Area::Partner,
"The K-1 worksheet reports tax-basis capital beside book capital",
Some(Status::Authority),
&[
"partnership-k1-tax-basis-capital",
"partnership-k1-tax-basis-odd-cents",
],
),
(
37,
Area::Statement,
"A project is a class with its facts; its figures are its tagged lines",
Some(Status::Choice),
&[
"projects-invoice-and-bill-lines-count-by-project",
"projects-net-is-its-tagged-lines",
"projects-on-spec-costs-are-expenses",
],
),
(
38,
Area::Arap,
"A document's figures are its ledger lines that stand; a control account is its documents",
Some(Status::Choice),
&[],
),
(
39,
Area::Arap,
"Terms: when a document is due (allocated, not yet stated)",
None,
&[],
),
(
40,
Area::Arap,
"Numbers and the issued copy (allocated, not yet stated)",
None,
&[],
),
(
41,
Area::Arap,
"Write-offs (allocated, not yet stated)",
None,
&[],
),
(
42,
Area::Arap,
"Settlements and the fee (allocated, not yet stated)",
None,
&[],
),
(
43,
Area::Entry,
"Drafts and evidence (allocated, not yet stated)",
None,
&[],
),
(
44,
Area::Ledger,
"Extraction and dedup (allocated, not yet stated)",
None,
&[],
),
(
45,
Area::Ledger,
"Audits and attestations (allocated, not yet stated)",
None,
&[],
),
];
File diff suppressed because it is too large. Load diff
+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}"
);
}
}