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:
1 parent
8c956e8d74
commit
b7be5a5472
18 files changed
+5822
-702
No files matched your search
Generated
+704
@@ -0,0 +1,704 @@
|
||||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
|
||||
|
||||
[[package]]
|
||||
name = "autocfg"
|
||||
version = "1.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||
|
||||
[[package]]
|
||||
name = "bookspec-checker"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"toml",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bookspec-runner"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"bookspec-checker",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a"
|
||||
dependencies = [
|
||||
"borsh-derive",
|
||||
"bytes",
|
||||
"cfg_aliases",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh-derive"
|
||||
version = "1.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"proc-macro-crate",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bumpalo"
|
||||
version = "3.20.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
|
||||
|
||||
[[package]]
|
||||
name = "bytes"
|
||||
version = "1.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04"
|
||||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cfg-if"
|
||||
version = "1.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
|
||||
|
||||
[[package]]
|
||||
name = "cfg_aliases"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"serde",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "equivalent"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
|
||||
|
||||
[[package]]
|
||||
name = "futures-core"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
|
||||
|
||||
[[package]]
|
||||
name = "futures-task"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
|
||||
|
||||
[[package]]
|
||||
name = "futures-util"
|
||||
version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
|
||||
dependencies = [
|
||||
"futures-core",
|
||||
"futures-task",
|
||||
"pin-project-lite",
|
||||
"slab",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "getrandom"
|
||||
version = "0.3.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hashbrown"
|
||||
version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "indexmap"
|
||||
version = "2.14.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||
dependencies = [
|
||||
"equivalent",
|
||||
"hashbrown",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "itoa"
|
||||
version = "1.0.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.191"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
|
||||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
|
||||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
|
||||
dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "once_cell"
|
||||
version = "1.21.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
|
||||
|
||||
[[package]]
|
||||
name = "ppv-lite86"
|
||||
version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9"
|
||||
dependencies = [
|
||||
"zerocopy",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro-crate"
|
||||
version = "3.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "proc-macro2"
|
||||
version = "1.0.107"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "quote"
|
||||
version = "1.0.47"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "r-efi"
|
||||
version = "5.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.6.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
|
||||
dependencies = [
|
||||
"ppv-lite86",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c"
|
||||
dependencies = [
|
||||
"getrandom 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
|
||||
dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rust_decimal"
|
||||
version = "1.43.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a"
|
||||
dependencies = [
|
||||
"arrayvec",
|
||||
"borsh",
|
||||
"bytes",
|
||||
"num-traits",
|
||||
"rand 0.8.8",
|
||||
"rand 0.9.5",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustversion"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
|
||||
|
||||
[[package]]
|
||||
name = "serde"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_core"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||
dependencies = [
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_derive"
|
||||
version = "1.0.229"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_json"
|
||||
version = "1.0.154"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
|
||||
dependencies = [
|
||||
"itoa",
|
||||
"memchr",
|
||||
"serde",
|
||||
"serde_core",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "2.0.119"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.17+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-ident"
|
||||
version = "1.0.27"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
|
||||
dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
"rustversion",
|
||||
"serde",
|
||||
"wasm-bindgen-macro",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 3.0.9",
|
||||
"wasm-bindgen-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winnow"
|
||||
version = "1.0.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zmij"
|
||||
version = "1.0.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
|
||||
@@ -0,0 +1,14 @@
|
||||
# bookspec's own workspace, inside the wanderbooks repository until the
|
||||
# specification and its harness are stable enough to move to their own
|
||||
# repository (P13 step 6, D-34).
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["checker", "runner"]
|
||||
|
||||
[workspace.package]
|
||||
edition = "2024"
|
||||
license = "MIT OR Apache-2.0"
|
||||
repository = "https://git.swaits.com/swaits/wanderbooks"
|
||||
|
||||
[workspace.lints.rust]
|
||||
missing_docs = "warn"
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "bookspec-runner"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
description = "Runs bookspec examples against any engine that implements bookspec's Engine trait."
|
||||
|
||||
[dependencies]
|
||||
bookspec-checker = { path = "../checker" }
|
||||
serde_json = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
@@ -0,0 +1,302 @@
|
||||
//! bookspec's conformance runner: it drives any [`Engine`] through the
|
||||
//! examples and reports, per rule, pass, fail or not claimed
|
||||
//! (`plan/served.md` 8.2 P13 step 2).
|
||||
//!
|
||||
//! The runner is the only judge. An engine answers; the runner compares the
|
||||
//! answer with the example's expected values, and with the refusal the
|
||||
//! example names when the engine refuses. The engine is never asked
|
||||
//! whether its own answer is right.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use bookspec_checker::{Claims, Engine, Example, Expect, Query, Rule, RuleId, Values, rules};
|
||||
|
||||
/// What the runner says about one rule.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub enum Outcome {
|
||||
/// The engine claims the rule and every example that demonstrates it
|
||||
/// passed.
|
||||
Pass,
|
||||
/// The engine claims the rule and an example failed, or a claim has no
|
||||
/// example behind it.
|
||||
Fail,
|
||||
/// The engine does not claim the rule.
|
||||
NotClaimed,
|
||||
}
|
||||
|
||||
impl Outcome {
|
||||
/// The outcome as the words a report prints.
|
||||
pub fn key(self) -> &'static str {
|
||||
match self {
|
||||
Outcome::Pass => "pass",
|
||||
Outcome::Fail => "fail",
|
||||
Outcome::NotClaimed => "not claimed",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One example's outcome.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct ExampleReport {
|
||||
/// The example's id.
|
||||
pub id: String,
|
||||
/// Why it failed, if it did.
|
||||
pub failure: Option<String>,
|
||||
}
|
||||
|
||||
/// The runner's report: what it found of each rule, and each example.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||
pub struct Report {
|
||||
/// Every rule of the catalogue the runner was given, and its outcome.
|
||||
pub rules: BTreeMap<RuleId, Outcome>,
|
||||
/// Every example, in the order run.
|
||||
pub examples: Vec<ExampleReport>,
|
||||
/// Failures that belong to no rule: an example no rule lists, a rule
|
||||
/// claimed but unknown to the catalogue.
|
||||
pub unclaimed: Vec<String>,
|
||||
}
|
||||
|
||||
impl Report {
|
||||
/// True when every claimed rule passed and nothing else failed.
|
||||
pub fn passed(&self) -> bool {
|
||||
self.unclaimed.is_empty()
|
||||
&& self.rules.values().all(|o| *o != Outcome::Fail)
|
||||
&& self.examples.iter().all(|e| e.failure.is_none())
|
||||
}
|
||||
|
||||
/// Every failure, with its rule's outcome where it has one.
|
||||
pub fn failures(&self) -> Vec<String> {
|
||||
let mut out = self.unclaimed.clone();
|
||||
for (rule, outcome) in &self.rules {
|
||||
if *outcome == Outcome::Fail {
|
||||
let ids: Vec<&str> = rules()
|
||||
.iter()
|
||||
.find(|r| &r.id == rule)
|
||||
.map(|r| r.examples.to_vec())
|
||||
.unwrap_or_default();
|
||||
out.push(format!("{rule} failed (examples: {})", ids.join(", ")));
|
||||
}
|
||||
}
|
||||
for e in &self.examples {
|
||||
if let Some(f) = &e.failure {
|
||||
out.push(f.clone());
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
/// Run `examples` against engines made by `make`, one fresh engine per
|
||||
/// example, and report per rule against `catalog` (normally
|
||||
/// [`bookspec_checker::rules()`]).
|
||||
pub fn run<'a, E>(
|
||||
examples: &'a [Example],
|
||||
catalog: &[Rule],
|
||||
mut make: impl FnMut(&'a Example) -> E,
|
||||
) -> Report
|
||||
where
|
||||
E: Engine + 'a,
|
||||
{
|
||||
let mut out = Report::default();
|
||||
let mut claims = Claims::default();
|
||||
for ex in examples {
|
||||
let mut engine = make(ex);
|
||||
let declared = engine.claims();
|
||||
claims.rules.extend(declared.rules);
|
||||
for (rule, choice) in declared.choices {
|
||||
claims.choices.entry(rule).or_insert(choice);
|
||||
}
|
||||
let failure = run_example(ex, &mut engine).err();
|
||||
out.examples.push(ExampleReport {
|
||||
id: ex.id.clone(),
|
||||
failure,
|
||||
});
|
||||
}
|
||||
for rule in catalog {
|
||||
let outcome = if !claims.claims(&rule.id) {
|
||||
Outcome::NotClaimed
|
||||
} else if !rule.has_examples() {
|
||||
out.unclaimed
|
||||
.push(format!("{} is claimed but has no example", rule.id));
|
||||
Outcome::Fail
|
||||
} else {
|
||||
let failed = rule.examples.iter().any(|id| {
|
||||
out.examples
|
||||
.iter()
|
||||
.find(|e| &e.id == id)
|
||||
.is_none_or(|e| e.failure.is_some())
|
||||
});
|
||||
if failed { Outcome::Fail } else { Outcome::Pass }
|
||||
};
|
||||
out.rules.insert(rule.id.clone(), outcome);
|
||||
}
|
||||
for rule in &claims.rules {
|
||||
if !catalog.iter().any(|r| &r.id == rule) {
|
||||
out.unclaimed
|
||||
.push(format!("{rule} is claimed but is not in the catalogue"));
|
||||
}
|
||||
}
|
||||
for ex in examples {
|
||||
if !catalog.iter().any(|r| r.examples.contains(&ex.id.as_str())) {
|
||||
out.unclaimed
|
||||
.push(format!("example {} is listed under no rule", ex.id));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Run one example against one engine: load the setup, apply every event
|
||||
/// (accepted or refused as the example says), check the expectations named
|
||||
/// after each event, make the changes named after it, then check the rest.
|
||||
pub fn run_example(ex: &Example, engine: &mut dyn Engine) -> Result<(), String> {
|
||||
engine
|
||||
.load(&ex.setup)
|
||||
.map_err(|r| format!("load refused: {} ({})", r.reason, r.rule))?;
|
||||
for (i, event) in ex.events.iter().enumerate() {
|
||||
let n = i + 1;
|
||||
match (engine.apply(event), &event.refused) {
|
||||
(Ok(()), None) => {}
|
||||
(Err(r), Some(want)) if &r.reason == want => {}
|
||||
(Err(r), Some(want)) => {
|
||||
return Err(format!(
|
||||
"event {n}: refused {:?}, expected {want:?} ({})",
|
||||
r.reason, r.rule
|
||||
));
|
||||
}
|
||||
(Err(r), None) => {
|
||||
return Err(format!(
|
||||
"event {n}: refused {:?}, expected accepted ({})",
|
||||
r.reason, r.rule
|
||||
));
|
||||
}
|
||||
(Ok(()), Some(want)) => {
|
||||
return Err(format!("event {n}: accepted, expected refused: {want}"));
|
||||
}
|
||||
}
|
||||
for x in ex.expect.iter().filter(|x| x.after == Some(n)) {
|
||||
check(engine, x).map_err(|m| format!("after event {n}: {m}"))?;
|
||||
}
|
||||
for c in ex.changes.iter().filter(|c| c.after == n) {
|
||||
engine
|
||||
.change(c)
|
||||
.map_err(|r| format!("change after {n}: {} ({})", r.reason, r.rule))?;
|
||||
}
|
||||
}
|
||||
for x in ex.expect.iter().filter(|x| x.after.is_none()) {
|
||||
check(engine, x)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Check one expectation against the engine's answer.
|
||||
fn check(engine: &dyn Engine, x: &Expect) -> Result<(), String> {
|
||||
let q = x.query();
|
||||
let answer = engine.query(&q);
|
||||
match (answer, &x.refused, x.accepted) {
|
||||
(Ok(v), None, Some(true)) => {
|
||||
if v == serde_json::Value::Bool(true) {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!(
|
||||
"{}: accepted, but answered {v} (a command shows no values)",
|
||||
q.name
|
||||
))
|
||||
}
|
||||
}
|
||||
(Ok(v), None, None) => compare(x, &v),
|
||||
(Ok(v), Some(want), _) => Err(format!(
|
||||
"{}: answered {v}, expected refused: {want}",
|
||||
q.name
|
||||
)),
|
||||
(Err(r), Some(want), _) if &r.reason == want => Ok(()),
|
||||
(Err(r), Some(want), _) => Err(format!(
|
||||
"{}: refused {:?}, expected {want:?} ({})",
|
||||
q.name, r.reason, r.rule
|
||||
)),
|
||||
(Err(r), None, _) => Err(format!("{}: refused {:?} ({})", q.name, r.reason, r.rule)),
|
||||
(Ok(v), None, Some(false)) => Err(format!(
|
||||
"{}: accepted = false is not the examples' vocabulary; answered {v}",
|
||||
q.name
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Compare an answer with what the example expects.
|
||||
fn compare(x: &Expect, got: &serde_json::Value) -> Result<(), String> {
|
||||
let q: Query = x.query();
|
||||
let Some(want) = &x.values else {
|
||||
return Err(format!("{}: expects values, none given", q.name));
|
||||
};
|
||||
match want {
|
||||
Values::Figures(figures) => {
|
||||
let serde_json::Value::Object(map) = got else {
|
||||
return Err(format!("{}: answered {got}, expected a table", q.name));
|
||||
};
|
||||
for (key, want) in figures {
|
||||
match map.get(key) {
|
||||
Some(serde_json::Value::String(got)) if got == want => {}
|
||||
Some(got) => {
|
||||
return Err(format!("{}: {key}: got {got}, expected {want:?}", q.name));
|
||||
}
|
||||
None => {
|
||||
return Err(format!("{}: {key} is missing (expected {want:?})", q.name));
|
||||
}
|
||||
}
|
||||
}
|
||||
if q.is_complete() {
|
||||
let extra: Vec<&String> =
|
||||
map.keys().filter(|k| !figures.contains_key(*k)).collect();
|
||||
let missing: Vec<&String> =
|
||||
figures.keys().filter(|k| !map.contains_key(*k)).collect();
|
||||
if !extra.is_empty() || !missing.is_empty() {
|
||||
return Err(format!(
|
||||
"{}: lists exactly what it covers; missing {missing:?}, extra {extra:?}",
|
||||
q.name
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
Values::Lines(lines) => {
|
||||
let serde_json::Value::Array(got) = got else {
|
||||
return Err(format!("{}: answered {got}, expected a list", q.name));
|
||||
};
|
||||
let got: Vec<&str> = got
|
||||
.iter()
|
||||
.map(|v| v.as_str().unwrap_or("<not text>"))
|
||||
.collect();
|
||||
let want: Vec<&str> = lines.iter().map(String::as_str).collect();
|
||||
if got == want {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{}: got {got:?}, expected {want:?}", q.name))
|
||||
}
|
||||
}
|
||||
Values::Rows(rows) => {
|
||||
let serde_json::Value::Array(got) = got else {
|
||||
return Err(format!("{}: answered {got}, expected rows", q.name));
|
||||
};
|
||||
let got: Vec<Vec<&str>> = got
|
||||
.iter()
|
||||
.map(|row| {
|
||||
row.as_array()
|
||||
.map(|r| {
|
||||
r.iter()
|
||||
.map(|v| v.as_str().unwrap_or("<not text>"))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect();
|
||||
let want: Vec<Vec<&str>> = rows
|
||||
.iter()
|
||||
.map(|r| r.iter().map(String::as_str).collect())
|
||||
.collect();
|
||||
if got == want {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{}: got {got:?}, expected {want:?}", q.name))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
//! The runner's own tests, with an engine that only pretends: the runner
|
||||
//! must judge the answer, never the engine's belief about it.
|
||||
|
||||
use bookspec_checker::{
|
||||
Area, ChangeSpec, Claims, Engine, Event, Example, Query, Refusal, Rule, RuleId, Setup, Status,
|
||||
};
|
||||
use bookspec_runner::Outcome;
|
||||
|
||||
const EXAMPLE: &str = r#"
|
||||
id = "fake.example"
|
||||
title = "A fake engine answers"
|
||||
rule = "The chart's names MUST be the account_names the example expects."
|
||||
status = "AUTHORITY"
|
||||
sources = ["bookspec/README.md"]
|
||||
tags = ["runner"]
|
||||
|
||||
events = []
|
||||
|
||||
[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"
|
||||
|
||||
[[expect]]
|
||||
query = "account_names"
|
||||
|
||||
[expect.values]
|
||||
"1000" = "Cash"
|
||||
"3000" = "Capital"
|
||||
"#;
|
||||
|
||||
fn example() -> Example {
|
||||
Example::from_toml(EXAMPLE).expect("the fake example is well formed")
|
||||
}
|
||||
|
||||
fn catalog() -> Vec<Rule> {
|
||||
vec![Rule {
|
||||
b: 1,
|
||||
id: RuleId::new(Area::Entry, 1),
|
||||
area: Area::Entry,
|
||||
title: "Every entry is a double entry: debits equal credits",
|
||||
status: Some(Status::Authority),
|
||||
examples: &["fake.example"],
|
||||
}]
|
||||
}
|
||||
|
||||
/// The rules an engine that claims the catalogue's example states.
|
||||
fn claim() -> Claims {
|
||||
Claims::new([RuleId::new(Area::Entry, 1)])
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
claims: Claims,
|
||||
names: serde_json::Value,
|
||||
accept: bool,
|
||||
refuse_query: Option<Refusal>,
|
||||
change_ok: bool,
|
||||
}
|
||||
|
||||
impl Default for Fake {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
claims: claim(),
|
||||
names: serde_json::json!({"1000": "Cash", "3000": "Capital"}),
|
||||
accept: true,
|
||||
refuse_query: None,
|
||||
change_ok: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Engine for Fake {
|
||||
fn load(&mut self, _setup: &Setup) -> Result<(), Refusal> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply(&mut self, event: &Event) -> Result<(), Refusal> {
|
||||
if let Some(reason) = &event.refused {
|
||||
return Err(Refusal::of_reason(reason));
|
||||
}
|
||||
if self.accept {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Refusal::of_reason("entry_irreversible"))
|
||||
}
|
||||
}
|
||||
|
||||
fn query(&self, q: &Query) -> Result<serde_json::Value, Refusal> {
|
||||
if let Some(r) = &self.refuse_query {
|
||||
return Err(r.clone());
|
||||
}
|
||||
match q.name.as_str() {
|
||||
"account_names" => Ok(self.names.clone()),
|
||||
other => Err(Refusal::new(
|
||||
RuleId::new(Area::Entry, 1),
|
||||
format!("the fake engine answers no {other}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn claims(&self) -> Claims {
|
||||
self.claims.clone()
|
||||
}
|
||||
|
||||
fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> {
|
||||
if self.change_ok {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Refusal::of_reason("unchanged"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An engine that answers what the example expects passes, and its rule is
|
||||
/// claimed and passed.
|
||||
#[test]
|
||||
fn a_passing_engine_passes() {
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake::default());
|
||||
assert!(report.passed(), "{:?}", report.failures());
|
||||
assert_eq!(
|
||||
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
||||
Some(&Outcome::Pass)
|
||||
);
|
||||
}
|
||||
|
||||
/// The runner judges the answer: a wrong figure fails the rule, whatever
|
||||
/// the engine says about itself.
|
||||
#[test]
|
||||
fn a_wrong_answer_fails() {
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
||||
names: serde_json::json!({"1000": "Coin", "3000": "Capital"}),
|
||||
..Fake::default()
|
||||
});
|
||||
assert!(!report.passed());
|
||||
assert_eq!(
|
||||
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
||||
Some(&Outcome::Fail)
|
||||
);
|
||||
assert!(
|
||||
report.failures().iter().any(|f| f.contains("1000")),
|
||||
"{:?}",
|
||||
report.failures()
|
||||
);
|
||||
}
|
||||
|
||||
/// An engine that claims nothing is not claimed, not failed; and the
|
||||
/// example it does not claim is still run (its answer is compared).
|
||||
#[test]
|
||||
fn an_engine_that_claims_nothing_is_not_claimed() {
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
||||
claims: Claims::default(),
|
||||
..Fake::default()
|
||||
});
|
||||
assert!(report.passed(), "{:?}", report.failures());
|
||||
assert_eq!(
|
||||
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
||||
Some(&Outcome::NotClaimed)
|
||||
);
|
||||
}
|
||||
|
||||
/// A claim with no example behind it is a failure: a claim needs evidence.
|
||||
#[test]
|
||||
fn a_claim_without_an_example_fails() {
|
||||
let mut catalog = catalog();
|
||||
catalog[0].examples = &[];
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &catalog, |_| Fake::default());
|
||||
assert!(!report.passed());
|
||||
assert_eq!(
|
||||
report.rules.get(&RuleId::new(Area::Entry, 1)),
|
||||
Some(&Outcome::Fail)
|
||||
);
|
||||
}
|
||||
|
||||
/// A query the engine refuses fails, and says which rule it refused under.
|
||||
#[test]
|
||||
fn a_refused_query_fails() {
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
||||
refuse_query: Some(Refusal::new(
|
||||
RuleId::new(Area::Ledger, 1),
|
||||
"not implemented",
|
||||
)),
|
||||
..Fake::default()
|
||||
});
|
||||
assert!(!report.passed());
|
||||
let failures = report.failures().join("\n");
|
||||
assert!(failures.contains("not implemented"), "{failures}");
|
||||
assert!(failures.contains("BS-ledger-1"), "{failures}");
|
||||
}
|
||||
|
||||
/// An example no rule lists is a failure: it proves nothing.
|
||||
#[test]
|
||||
fn an_example_listed_under_no_rule_fails() {
|
||||
let examples = [example()];
|
||||
let report = bookspec_runner::run(&examples, &[], |_| Fake::default());
|
||||
assert!(!report.passed());
|
||||
assert!(
|
||||
report
|
||||
.failures()
|
||||
.iter()
|
||||
.any(|f| f.contains("listed under no rule")),
|
||||
"{:?}",
|
||||
report.failures()
|
||||
);
|
||||
}
|
||||
|
||||
/// A change the engine refuses fails the example.
|
||||
#[test]
|
||||
fn a_refused_change_fails() {
|
||||
let text = EXAMPLE.replace("events = []\n", "").replace(
|
||||
"[[expect]]",
|
||||
"[[events]]\ndate = \"2026-01-02\"\ndescription = \"A\"\nlines = [\n { account = \"1000\", side = \"debit\", amount = \"1.00\" },\n { account = \"3000\", side = \"credit\", amount = \"1.00\" },\n]\n\n[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n\n[[expect]]",
|
||||
);
|
||||
let examples = [Example::from_toml(&text).unwrap()];
|
||||
let report = bookspec_runner::run(&examples, &catalog(), |_| Fake {
|
||||
change_ok: false,
|
||||
..Fake::default()
|
||||
});
|
||||
assert!(!report.passed());
|
||||
assert!(
|
||||
report.failures().iter().any(|f| f.contains("unchanged")),
|
||||
"{:?}",
|
||||
report.failures()
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
# bookspec's rule index
|
||||
|
||||
Every rule of `docs/spec/rules.md` (B-1 to B-45), its bookspec
|
||||
identifier `BS-<area>-<n>` (`plan/served.md` P13 step 3), its area,
|
||||
what its statement rests on and the examples that demonstrate it.
|
||||
|
||||
`docs/spec/rules.md`'s **DECISION** status is bookspec's **CHOICE**:
|
||||
the specification names the options, an engine declares its pick
|
||||
(`claims.choices`, `bookspec_checker::Claims`). Rules B-39 to B-45 are
|
||||
allocated in `plan/served.md` 8.0.3 and not yet stated: their
|
||||
identifier and area are fixed here so a later unit fills them in
|
||||
without moving anyone else's number.
|
||||
|
||||
| B | bookspec id | area | status | rule | examples |
|
||||
|---|---|---|---|---|---|
|
||||
| B-1 | `BS-entry-1` | entry | AUTHORITY | Every entry is a double entry: debits equal credits | — |
|
||||
| B-2 | `BS-entry-2` | entry | CONVENTION | A line's amount is positive; its side gives the direction | — |
|
||||
| B-3 | `BS-ledger-1` | ledger | AUTHORITY | One money through the ledger; foreign amounts at the transaction-date rate | — |
|
||||
| B-4 | `BS-ledger-2` | ledger | AUTHORITY | Five elements, closed list | — |
|
||||
| B-5 | `BS-ledger-3` | ledger | CONVENTION | Normal balances | — |
|
||||
| B-6 | `BS-statement-1` | statement | AUTHORITY | The trial balance balances | — |
|
||||
| B-7 | `BS-statement-2` | statement | AUTHORITY | The accounting equation | — |
|
||||
| B-8 | `BS-ledger-4` | ledger | AUTHORITY | Posted is forever: the book is written only forward | — |
|
||||
| B-9 | `BS-ledger-5` | ledger | AUTHORITY | A mistake is corrected by an opposite entry, never erased | — |
|
||||
| B-10 | `BS-ledger-6` | ledger | CHOICE | An entry is reversed at most once; a closing entry never | — |
|
||||
| B-11 | `BS-period-1` | period | AUTHORITY | Revenue and expenses close into profit and loss, and that into capital | — |
|
||||
| B-12 | `BS-period-2` | period | CONVENTION | A closed period takes no new postings; periods close in order | — |
|
||||
| B-13 | `BS-partner-1` | partner | AUTHORITY | Partners share income by the partnership agreement; capital accounts carry it | `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` |
|
||||
| B-14 | `BS-statement-3` | statement | AUTHORITY | Statements: P&L for a period; cash flows by the direct method | `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` |
|
||||
| B-15 | `BS-asset-1` | asset | CONVENTION | Depreciation: straight line, salvage never depreciated, partial months by days | `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` |
|
||||
| B-16 | `BS-prepaid-1` | prepaid | AUTHORITY | Prepaid expenses and deferred revenue are recognized over the period benefited or earned | `schedule-daily-deferred-revenue`, `schedule-daily-small-first-and-last-month`, `schedule-equal-months-start-on-the-first` |
|
||||
| B-17 | `BS-revenue-1` | revenue | AUTHORITY | Platform payouts are recorded gross when the business is the principal | — |
|
||||
| B-18 | `BS-arap-1` | arap | CONVENTION | Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due | — |
|
||||
| B-19 | `BS-1099-1` | 1099 | AUTHORITY | 1099 reporting thresholds, payment methods, and decisions | `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` |
|
||||
| B-20 | `BS-ledger-7` | ledger | AUTHORITY | Every transaction has supporting documents; records are kept | — |
|
||||
| B-21 | `BS-entry-3` | entry | AUTHORITY | Exact amounts in the books; rounding only on returns | — |
|
||||
| B-22 | `BS-asset-2` | asset | AUTHORITY | Expense or capitalize (book policy, mirrors tax) | — |
|
||||
| B-23 | `BS-ledger-8` | ledger | CONVENTION | Control roles: one account each, of the right kind, moved only when empty | — |
|
||||
| B-24 | `BS-asset-3` | asset | CHOICE | Asset tags: one label per asset, never reused | — |
|
||||
| B-25 | `BS-asset-4` | asset | AUTHORITY | Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain | `assets-723-basis-apart-from-depreciable-basis`, `assets-built-in-gain-fixed-at-contribution`, `assets-four-contributed-devices` |
|
||||
| B-26 | `BS-partner-2` | partner | AUTHORITY | Parties: one directory, no full TIN in the books | `p3-party-tin-last4` |
|
||||
| B-27 | `BS-format-1` | format | CHOICE | The journal format gate: newer is not corrupt | — |
|
||||
| B-28 | `BS-entry-4` | entry | CONVENTION | What new commands take: four-digit years, amounts a line can carry | — |
|
||||
| B-29 | `BS-ledger-9` | ledger | CHOICE | The books keep how they are kept | — |
|
||||
| B-30 | `BS-period-3` | period | AUTHORITY | A report asked for without dates covers this fiscal year so far | `default-period-fiscal-year-to-date` |
|
||||
| B-31 | `BS-ledger-10` | ledger | CHOICE | Renaming an account changes its label, nothing else | `rename-changes-no-amount` |
|
||||
| B-32 | `BS-ledger-11` | ledger | AUTHORITY | Every revenue and expense account maps to a line of the return | `tax-category-required` |
|
||||
| B-33 | `BS-statement-4` | statement | CONVENTION | A package as of a day shows the books through that day | `as-of-package-stops-at-as-of` |
|
||||
| B-34 | `BS-format-2` | format | AUTHORITY | The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried | `iif-quickbooks-journal-file` |
|
||||
| B-35 | `BS-partner-3` | partner | CONVENTION | A party's part of an entry, and a party's totals | `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` |
|
||||
| B-36 | `BS-partner-4` | partner | AUTHORITY | The K-1 worksheet reports tax-basis capital beside book capital | `partnership-k1-tax-basis-capital`, `partnership-k1-tax-basis-odd-cents` |
|
||||
| B-37 | `BS-statement-5` | statement | CHOICE | A project is a class with its facts; its figures are its tagged lines | `projects-invoice-and-bill-lines-count-by-project`, `projects-net-is-its-tagged-lines`, `projects-on-spec-costs-are-expenses` |
|
||||
| B-38 | `BS-arap-2` | arap | CHOICE | A document's figures are its ledger lines that stand; a control account is its documents | — |
|
||||
| B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — |
|
||||
| B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — |
|
||||
| B-41 | `BS-arap-5` | arap | — | Write-offs (allocated, not yet stated) | — |
|
||||
| B-42 | `BS-arap-6` | arap | — | Settlements and the fee (allocated, not yet stated) | — |
|
||||
| B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — |
|
||||
| B-44 | `BS-ledger-12` | ledger | — | Extraction and dedup (allocated, not yet stated) | — |
|
||||
| B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — |
|
||||
Reference in new issue
Block a user