diff --git a/.gitignore b/.gitignore
index cd52d5e..2d2387c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,3 +6,5 @@ site/dist/
# cargo-mutants output (just mutants*)
/mutants.out*/
/mutants-*/
+# bookspec's own workspace target
+/bookspec/target/
diff --git a/Cargo.lock b/Cargo.lock
index 77f6d79..854e76d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -769,6 +769,25 @@ dependencies = [
"piper",
]
+[[package]]
+name = "bookspec-checker"
+version = "0.1.0"
+dependencies = [
+ "chrono",
+ "rust_decimal",
+ "serde",
+ "serde_json",
+ "toml 1.1.8+spec-1.1.0",
+]
+
+[[package]]
+name = "bookspec-runner"
+version = "0.1.0"
+dependencies = [
+ "bookspec-checker",
+ "serde_json",
+]
+
[[package]]
name = "borrow-or-share"
version = "0.2.4"
@@ -3894,9 +3913,13 @@ dependencies = [
name = "ledger-core"
version = "0.1.0"
dependencies = [
+ "bookspec-checker",
+ "bookspec-runner",
"chrono",
"criterion",
"jiff",
+ "ledger-import",
+ "ledger-package",
"ledger-store",
"proptest",
"rust_decimal",
diff --git a/bookspec/Cargo.lock b/bookspec/Cargo.lock
new file mode 100644
index 0000000..059cda3
--- /dev/null
+++ b/bookspec/Cargo.lock
@@ -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"
diff --git a/bookspec/Cargo.toml b/bookspec/Cargo.toml
new file mode 100644
index 0000000..c8c0410
--- /dev/null
+++ b/bookspec/Cargo.toml
@@ -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"
diff --git a/bookspec/checker/Cargo.toml b/bookspec/checker/Cargo.toml
new file mode 100644
index 0000000..cc52fa9
--- /dev/null
+++ b/bookspec/checker/Cargo.toml
@@ -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
diff --git a/bookspec/checker/src/engine.rs b/bookspec/checker/src/engine.rs
new file mode 100644
index 0000000..372d3eb
--- /dev/null
+++ b/bookspec/checker/src/engine.rs
@@ -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--`), 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--` (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--`, 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 {
+ let rest = s.strip_prefix("BS-").ok_or_else(|| RuleIdError {
+ what: format!("{s:?} is no bookspec rule id (BS--)"),
+ })?;
+ let (area, n) = rest.rsplit_once('-').ok_or_else(|| RuleIdError {
+ what: format!("{s:?} is no bookspec rule id (BS--)"),
+ })?;
+ 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::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) -> 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) -> 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,
+ /// The choice rules whose option the engine picks, and the option.
+ pub choices: BTreeMap,
+}
+
+impl Claims {
+ /// The claims of an engine that claims `rules`, with no choices.
+ pub fn new(rules: impl IntoIterator- ) -> 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) -> 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;
+
+ /// 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) -> Refusal {
+ Refusal::new(fallback, reason)
+}
diff --git a/bookspec/checker/src/lib.rs b/bookspec/checker/src/lib.rs
new file mode 100644
index 0000000..9d398bf
--- /dev/null
+++ b/bookspec/checker/src/lib.rs
@@ -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--`), 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::*;
diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs
new file mode 100644
index 0000000..dc59220
--- /dev/null
+++ b/bookspec/checker/src/rules.rs
@@ -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--`, 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::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 {
+ [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,
+ /// 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> = 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 {
+ // 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,
+ &'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,
+ &[],
+ ),
+];
diff --git a/bookspec/checker/src/schema.rs b/bookspec/checker/src/schema.rs
new file mode 100644
index 0000000..b0e36e1
--- /dev/null
+++ b/bookspec/checker/src/schema.rs
@@ -0,0 +1,1892 @@
+//! The shape of a bookspec example: the TOML schema, the parser, and the
+//! rules a well-formed example follows whatever engine runs it.
+//!
+//! One file per example, named for its `id` (`bookspec/README.md` gives the
+//! shape in prose; this module is its executable form). Every structure
+//! refuses a key it does not know (`deny_unknown_fields`): a typo is an
+//! error, never silently ignored. [`Example::from_toml`] parses and then
+//! checks the shape; an engine's own adapter only has to execute it.
+
+use std::collections::{BTreeMap, BTreeSet};
+use std::fmt;
+
+use chrono::NaiveDate;
+use rust_decimal::Decimal;
+use serde::Deserialize;
+
+use crate::rules::rule_for_refusal;
+
+/// One bookspec example: a setup, its events and changes, and what it
+/// expects of the books.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Example {
+ /// Stable, never reused; the file is named for it.
+ pub id: String,
+ /// What the example shows, in words.
+ pub title: String,
+ /// The normative sentence the example demonstrates: what MUST (or MUST
+ /// NOT) hold.
+ pub rule: String,
+ /// What the expected values rest on: `AUTHORITY`, `CONVENTION` or
+ /// `CHOICE`.
+ pub status: String,
+ /// Exact citations, non-empty.
+ pub sources: Vec,
+ /// Applicability tags, non-empty.
+ pub tags: Vec,
+ /// `"formats"`: an interchange format's example rather than an
+ /// accounting rule's (P13's appendix of formats); absent otherwise.
+ #[serde(default)]
+ pub appendix: Option,
+ /// The books before the first event.
+ pub setup: Setup,
+ /// The events, in order, counting from 1.
+ pub events: Vec,
+ /// Changes that post no entry, each made after the event it names.
+ #[serde(default)]
+ pub changes: Vec,
+ /// What is expected of the books, at least one.
+ pub expect: Vec,
+}
+
+/// The books an example sets up, before any event.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Setup {
+ /// ISO 4217 currency code.
+ pub currency: String,
+ /// `MM-DD`.
+ pub fiscal_year_start: String,
+ /// `accrual` or `cash`.
+ pub basis: String,
+ /// How a figure is rounded to the currency's minor unit: `half-even`,
+ /// `half-away-from-zero` or `largest-remainder`.
+ pub rounding: String,
+ /// The chart, parents before their subaccounts.
+ pub accounts: Vec,
+ /// The cash accounts (ASC 230-10-45-10 to 45-15).
+ #[serde(default)]
+ pub cash_accounts: Vec,
+ /// The financing accounts.
+ #[serde(default)]
+ pub financing_accounts: Vec,
+ /// The investing accounts.
+ #[serde(default)]
+ pub investing_accounts: Vec,
+ /// Accounts that hold property and equipment, for an example whose
+ /// individual assets do not matter.
+ #[serde(default)]
+ pub fixed_assets: Vec,
+ /// Fixed assets registered, with their figures (B-15).
+ #[serde(default)]
+ pub assets: Vec,
+ /// Prepaid-expense and deferred-revenue schedules (B-16).
+ #[serde(default)]
+ pub schedules: Vec,
+ /// The company's directory (B-26).
+ #[serde(default)]
+ pub parties: Vec,
+ /// The members of a multi-member LLC (B-13).
+ #[serde(default)]
+ pub members: Vec,
+ /// Payees marked for an information return (B-19).
+ #[serde(default)]
+ pub payees: Vec,
+ /// A payee's 1099 decision from a date on (B-19, D-40).
+ #[serde(default)]
+ pub status_changes: Vec,
+ /// The ways the company (or a member, for it) pays (B-19).
+ #[serde(default)]
+ pub instruments: Vec,
+ /// The projects (B-37).
+ #[serde(default)]
+ pub projects: Vec,
+ /// The day the books are asked on: what an omitted date means (B-30).
+ #[serde(default)]
+ pub today: Option,
+}
+
+/// A project (B-37): its name, and its kind, status and party if given.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ProjectSpec {
+ /// Its name, once each.
+ pub name: String,
+ /// `internal`, `client`, `on_spec` or `other`.
+ #[serde(default)]
+ pub kind: Option,
+ /// `active`, `paused`, `finished` or `abandoned`.
+ #[serde(default)]
+ pub status: Option,
+ /// A `setup.parties` name.
+ #[serde(default)]
+ pub party: Option,
+}
+
+/// One account of the chart.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AccountSpec {
+ /// Its code.
+ pub code: String,
+ /// Its name.
+ pub name: String,
+ /// `asset`, `liability`, `equity`, `revenue` or `expense`.
+ #[serde(rename = "type")]
+ pub kind: String,
+ /// Its parent's code, declared before it.
+ #[serde(default)]
+ pub parent: Option,
+ /// A header groups other accounts and takes no postings.
+ #[serde(default)]
+ pub header: bool,
+ /// Its tax category, a key of the catalog (B-32).
+ #[serde(default)]
+ pub tax_category: Option,
+}
+
+/// Accounts that hold property and equipment, for an example no asset's
+/// own figures matter to.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct FixedAsset {
+ /// The cost account.
+ pub cost_account: String,
+ /// The accumulated depreciation account.
+ pub accumulated_account: String,
+ /// The depreciation expense account.
+ pub expense_account: String,
+}
+
+/// A fixed asset registered before the first event (B-15).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct AssetSpec {
+ /// Its name in this example.
+ pub id: String,
+ /// What it is.
+ pub description: String,
+ /// Its cost, an exact decimal string.
+ pub cost: String,
+ /// Its salvage value.
+ pub salvage: String,
+ /// Its useful life in months, above zero.
+ pub life_months: u32,
+ /// The day depreciation starts, `YYYY-MM-DD`.
+ pub in_service: String,
+ /// `straight_line`, the one method the books have.
+ pub method: String,
+ /// The cost account.
+ pub cost_account: String,
+ /// The accumulated depreciation account.
+ pub accumulated_account: String,
+ /// The depreciation expense account.
+ pub expense_account: String,
+ /// The event whose entry put it on the books (B-15 rule 7).
+ #[serde(default)]
+ pub acquired_by: Option,
+ /// How the company got it and the facts its tax figures follow from
+ /// (B-25).
+ #[serde(default)]
+ pub basis: Option,
+}
+
+/// An asset's basis facts (B-25).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct BasisSpec {
+ /// `purchase`, `carryover_723` or `personal_use_conversion`.
+ pub rule: String,
+ /// A contribution's contributor's adjusted basis.
+ #[serde(default)]
+ pub contributor_adjusted_basis: Option,
+ /// A contribution's fair market value.
+ #[serde(default)]
+ pub fmv_at_contribution: Option,
+ /// The section 704(c) method, if chosen.
+ #[serde(default)]
+ pub sec704c_method: Option,
+}
+
+/// A prepaid expense or deferred revenue recognized over its months
+/// (B-16), in `setup.schedules` or registered by an event.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ScheduleSpec {
+ /// Its name in this example.
+ pub id: String,
+ /// `prepaid_expense` or `deferred_revenue`.
+ pub kind: String,
+ /// What it is.
+ pub description: String,
+ /// The whole amount, an exact decimal string.
+ pub total: String,
+ /// The day it starts, `YYYY-MM-DD`.
+ pub start: String,
+ /// Its months, above zero.
+ pub months: u32,
+ /// `daily` or `monthly_even`.
+ pub convention: String,
+ /// The balance-sheet account it sits in.
+ pub balance_account: String,
+ /// The profit-and-loss account it recognizes to.
+ pub pl_account: String,
+}
+
+/// One member of a multi-member LLC (B-13).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct MemberSpec {
+ /// The member's name; a party's, with the member role, when the
+ /// example asks about the member as a party.
+ pub name: String,
+ /// Their capital account.
+ pub capital_account: String,
+ /// Their share of income, an exact decimal fraction above zero; every
+ /// member has one or none does, and the shares sum to 1.
+ #[serde(default)]
+ pub share: Option,
+ /// Their distributions account (B-13).
+ #[serde(default)]
+ pub distributions_account: Option,
+}
+
+/// A payee marked for an information return from the beginning (B-19).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct PayeeSpec {
+ /// Its name, once each.
+ pub name: String,
+ /// `1099-NEC` or `1099-MISC`.
+ pub form: String,
+}
+
+/// One party in the directory (B-26, B-35).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct PartySpec {
+ /// Its name, once each.
+ pub name: String,
+ /// Role keys; `vendor` and `customer` give the party its record (one
+ /// of them at most).
+ pub roles: Vec,
+ /// The liabilities, by code, the company owes the party itself
+ /// (B-35 item 6).
+ #[serde(default)]
+ pub creditor_of: Vec,
+ /// Its TIN facts' last four (an SSN's).
+ #[serde(default)]
+ pub tin_last4: Option,
+ /// Its W-9 facts' last four (a 1099 role's).
+ #[serde(default)]
+ pub w9_tin_last4: Option,
+ /// The vault's copy of the TIN purged, the facts kept (needs
+ /// `tin_last4`).
+ #[serde(default)]
+ pub tin_purged: bool,
+}
+
+/// A payee's 1099 decision from `effective` on (B-19, D-40).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct StatusChangeSpec {
+ /// A `setup.payees` name.
+ pub payee: String,
+ /// `YYYY-MM-DD`.
+ pub effective: String,
+ /// `1099-NEC`, `1099-MISC`, `undetermined`, or `not_reportable` (with
+ /// `reason_kind` and `reason`).
+ pub decision: String,
+ /// Why it is not reportable, from the closed list.
+ #[serde(default)]
+ pub reason_kind: Option,
+ /// The facts, in words.
+ #[serde(default)]
+ pub reason: Option,
+}
+
+/// A payment method (B-19).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct InstrumentSpec {
+ /// Its label, once each.
+ pub label: String,
+ /// `bank`, `business_card`, `member_card`, `member_cash_or_check` or
+ /// `platform`.
+ pub kind: String,
+ /// The account it pays through, by code.
+ pub account: String,
+ /// The card's or account's last four, if known.
+ #[serde(default)]
+ pub last4: Option,
+ /// The party it belongs to, by name (informational).
+ #[serde(default)]
+ pub owner: Option,
+}
+
+/// A party named on an entry, with its share (B-35).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ShareSpec {
+ /// A `setup.parties` name.
+ pub party: String,
+ /// Its share as an exact decimal string.
+ #[serde(default)]
+ pub amount: Option,
+ /// Its share as an exact decimal fraction.
+ #[serde(default)]
+ pub percent: Option,
+}
+
+/// One event: an entry, a reversal, a close, a bill, an invoice, a
+/// command that posts what is due, or a schedule's registration.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Event {
+ /// The day of an entry, a reversal, a close, a bill, an invoice or a
+ /// disposal; the other commands date their own entries.
+ #[serde(default)]
+ pub date: Option,
+ /// An entry's, a reversal's, a close's or a bill's; a command writes
+ /// its own.
+ #[serde(default)]
+ pub description: Option,
+ /// An entry's lines.
+ #[serde(default)]
+ pub lines: Vec,
+ /// The event (1-based) whose entry this one reverses.
+ #[serde(default)]
+ pub reverses: Option,
+ /// A close of a period.
+ #[serde(default)]
+ pub close: Option,
+ /// A bill.
+ #[serde(default)]
+ pub bill: Option,
+ /// An invoice.
+ #[serde(default)]
+ pub invoice: Option,
+ /// A depreciation run.
+ #[serde(default)]
+ pub run_depreciation: Option,
+ /// A disposal.
+ #[serde(default)]
+ pub dispose_asset: Option,
+ /// A run of every schedule due through a day.
+ #[serde(default)]
+ pub run_schedules: Option,
+ /// A schedule registered by an event.
+ #[serde(default)]
+ pub register_schedule: Option,
+ /// The engine refuses the event, for this reason in the examples'
+ /// words (`refusal_reasons`); it changes nothing.
+ #[serde(default)]
+ pub refused: Option,
+ /// The payee (a `setup.payees` name) the entry paid.
+ #[serde(default)]
+ pub payee: Option,
+ /// The parties (a `setup.parties` name) the entry is with, and their
+ /// shares.
+ #[serde(default)]
+ pub parties: Vec,
+ /// The payment method (a `setup.instruments` label) it was paid with.
+ #[serde(default)]
+ pub instrument: Option,
+ /// An entry as an earlier version could write it and this version's
+ /// commands refuse (the control guard, B-38): posted as replay posts
+ /// it, since books that hold it must still read right.
+ #[serde(default)]
+ pub older: bool,
+}
+
+impl Event {
+ /// Which of the event's kinds it is, or why it is not exactly one.
+ pub fn kind(&self) -> Result {
+ let kinds = [
+ (EventKind::Lines, !self.lines.is_empty()),
+ (EventKind::Reverses, self.reverses.is_some()),
+ (EventKind::Close, self.close.is_some()),
+ (EventKind::Bill, self.bill.is_some()),
+ (EventKind::Invoice, self.invoice.is_some()),
+ (EventKind::RunDepreciation, self.run_depreciation.is_some()),
+ (EventKind::DisposeAsset, self.dispose_asset.is_some()),
+ (EventKind::RunSchedules, self.run_schedules.is_some()),
+ (
+ EventKind::RegisterSchedule,
+ self.register_schedule.is_some(),
+ ),
+ ];
+ let given: Vec = kinds
+ .iter()
+ .filter_map(|(kind, given)| given.then_some(*kind))
+ .collect();
+ match given.as_slice() {
+ [one] => Ok(*one),
+ _ => Err(
+ "an event is one of lines, reverses, close, bill, invoice, run_depreciation, \
+ dispose_asset, run_schedules or register_schedule"
+ .to_string(),
+ ),
+ }
+ }
+}
+
+/// The kind of an event.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum EventKind {
+ /// An entry, by its `lines`.
+ Lines,
+ /// A reversal of an earlier event's entry.
+ Reverses,
+ /// A close of a period.
+ Close,
+ /// A bill.
+ Bill,
+ /// An invoice.
+ Invoice,
+ /// A depreciation run.
+ RunDepreciation,
+ /// A fixed asset's disposal.
+ DisposeAsset,
+ /// Every schedule's months due through a day.
+ RunSchedules,
+ /// A schedule's registration (posts nothing).
+ RegisterSchedule,
+}
+
+impl EventKind {
+ /// The key the example spells it with.
+ pub fn key(self) -> &'static str {
+ match self {
+ EventKind::Lines => "lines",
+ EventKind::Reverses => "reverses",
+ EventKind::Close => "close",
+ EventKind::Bill => "bill",
+ EventKind::Invoice => "invoice",
+ EventKind::RunDepreciation => "run_depreciation",
+ EventKind::DisposeAsset => "dispose_asset",
+ EventKind::RunSchedules => "run_schedules",
+ EventKind::RegisterSchedule => "register_schedule",
+ }
+ }
+
+ /// True when the example gives the event's day (a command that dates
+ /// its own entries takes none).
+ pub fn is_dated(self) -> bool {
+ matches!(
+ self,
+ EventKind::Lines
+ | EventKind::Reverses
+ | EventKind::Close
+ | EventKind::Bill
+ | EventKind::Invoice
+ | EventKind::DisposeAsset
+ )
+ }
+
+ /// True when the example describes the event (a command writes its own
+ /// descriptions).
+ pub fn is_described(self) -> bool {
+ matches!(
+ self,
+ EventKind::Lines
+ | EventKind::Reverses
+ | EventKind::Close
+ | EventKind::Bill
+ | EventKind::Invoice
+ )
+ }
+
+ /// True when the books take it whole or not at all, so `refused` may
+ /// be given: a reversal or a command, never an entry, a close, a bill
+ /// or an invoice.
+ pub fn may_refuse(self) -> bool {
+ !matches!(
+ self,
+ EventKind::Lines | EventKind::Close | EventKind::Bill | EventKind::Invoice
+ )
+ }
+
+ /// True when the example may tag it with parties and a payment method.
+ pub fn may_tag(self) -> bool {
+ !matches!(
+ self,
+ EventKind::Reverses
+ | EventKind::RunDepreciation
+ | EventKind::DisposeAsset
+ | EventKind::RunSchedules
+ | EventKind::RegisterSchedule
+ )
+ }
+}
+
+/// A depreciation run of `asset` for `period` (`YYYY-MM`), dated at the
+/// month's end (B-15).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RunSpec {
+ /// A `setup.assets` id.
+ pub asset: String,
+ /// `YYYY-MM`.
+ pub period: String,
+}
+
+/// A fixed asset's disposal (B-15).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct DisposeSpec {
+ /// A `setup.assets` id.
+ pub asset: String,
+ /// What it sold for, zero allowed.
+ pub proceeds: String,
+ /// The account the proceeds were received into.
+ pub proceeds_account: String,
+ /// The gain-or-loss account.
+ pub gain_loss_account: String,
+}
+
+/// Every schedule's months due through `through` (B-16).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RunSchedulesSpec {
+ /// `YYYY-MM-DD`.
+ pub through: String,
+}
+
+/// A bill from `vendor` (B-38).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct BillSpec {
+ /// The vendor's name.
+ pub vendor: String,
+ /// One line's amount, exactly one of `amount` and `lines`.
+ #[serde(default)]
+ pub amount: Option,
+ /// Its lines.
+ #[serde(default)]
+ pub lines: Vec,
+}
+
+/// An invoice to `customer` (B-38).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct InvoiceSpec {
+ /// The customer's name.
+ pub customer: String,
+ /// One line's amount, exactly one of `amount` and `lines`.
+ #[serde(default)]
+ pub amount: Option,
+ /// Its lines.
+ #[serde(default)]
+ pub lines: Vec,
+}
+
+/// One line of an invoice or a bill.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct DocumentLineSpec {
+ /// Its amount, an exact decimal string above zero.
+ pub amount: String,
+ /// What it is (the event's description when left out).
+ #[serde(default)]
+ pub description: Option,
+ /// The project it belongs to.
+ #[serde(default)]
+ pub project: Option,
+}
+
+/// A change to the books that posts no entry, made right after event
+/// `after` once that event's expectations are checked: exactly one of
+/// `rename`, `require_tax_category` and `correct_asset`.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct ChangeSpec {
+ /// The event after which it is made (1 to the number of events).
+ pub after: usize,
+ /// A rename.
+ #[serde(default)]
+ pub rename: Option,
+ /// The tax-category policy: `warn` (the books' default) or `refuse`
+ /// (B-32).
+ #[serde(default)]
+ pub require_tax_category: Option,
+ /// An asset's correction.
+ #[serde(default)]
+ pub correct_asset: Option,
+}
+
+/// A registered asset's economics corrected for `reason`: each figure
+/// given replaces the asset's; the rest stay.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct CorrectSpec {
+ /// A `setup.assets` id.
+ pub asset: String,
+ /// Its new cost.
+ #[serde(default)]
+ pub cost: Option,
+ /// Its new salvage value.
+ #[serde(default)]
+ pub salvage: Option,
+ /// Its new useful life, above zero.
+ #[serde(default)]
+ pub life_months: Option,
+ /// Its new in-service day.
+ #[serde(default)]
+ pub in_service: Option,
+ /// Why it is corrected.
+ pub reason: String,
+}
+
+/// Rename the account `code` to `name` (B-31).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct RenameSpec {
+ /// The account's code.
+ pub code: String,
+ /// Its new name.
+ pub name: String,
+}
+
+/// One line of an entry.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct LineSpec {
+ /// The account's code.
+ pub account: String,
+ /// `debit` or `credit`.
+ pub side: String,
+ /// The amount, an exact decimal string above zero.
+ pub amount: String,
+ /// The project the line is tagged with.
+ #[serde(default)]
+ pub project: Option,
+}
+
+/// A close of the period from `start` through the event's date (B-11,
+/// B-13).
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct CloseSpec {
+ /// `YYYY-MM-DD`.
+ pub start: String,
+ /// `members` or `retained_earnings`.
+ pub to: String,
+ /// `per-close`: a members close split on its own, not cumulatively.
+ #[serde(default)]
+ pub rounding: Option,
+}
+
+/// One expectation: a query, the dates or things it asks about, and what
+/// it expects.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct Expect {
+ /// The query's name (`QUERIES`).
+ pub query: String,
+ /// A period query's first day, `YYYY-MM-DD`.
+ #[serde(default)]
+ pub from: Option,
+ /// A period query's last day.
+ #[serde(default)]
+ pub to: Option,
+ /// An as-of query's day.
+ #[serde(default)]
+ pub as_of: Option,
+ /// The event whose entry an `entry` or `party_part` query asks about.
+ #[serde(default)]
+ pub entry: Option,
+ /// The `run_depreciation` event a `run` query asks about.
+ #[serde(default)]
+ pub event: Option,
+ /// The fixed asset a `disposal`, `depreciation_schedule`,
+ /// `register_row` or `asset_basis` query asks about.
+ #[serde(default)]
+ pub asset: Option,
+ /// The schedule a `schedule_plan` query asks about.
+ #[serde(default)]
+ pub schedule: Option,
+ /// The party a `party_part`, `party_totals` or `party_tin_last4` query
+ /// asks about.
+ #[serde(default)]
+ pub party: Option,
+ /// The project a `project` query asks about.
+ #[serde(default)]
+ pub project: Option,
+ /// `party_totals`' basis: `cash` or `all` (B-35 item 4).
+ #[serde(default)]
+ pub basis: Option,
+ /// The payee a `vendor_1099` query asks about.
+ #[serde(default)]
+ pub payee: Option,
+ /// The books after event N, not after the last (a format query may
+ /// not name it).
+ #[serde(default)]
+ pub after: Option,
+ /// The account a command names, by code.
+ #[serde(default)]
+ pub code: Option,
+ /// The new name a `rename_account` command gives.
+ #[serde(default)]
+ pub name: Option,
+ /// The category a `set_tax_category` command sets (absent: it clears
+ /// the account's).
+ #[serde(default)]
+ pub category: Option,
+ /// The account an `add_account` command adds.
+ #[serde(default)]
+ pub account: Option,
+ /// A command the engine accepts: `true` (never `false`).
+ #[serde(default)]
+ pub accepted: Option,
+ /// A command, or a `period`, the engine refuses: why, in the
+ /// examples' words.
+ #[serde(default)]
+ pub refused: Option,
+ /// What the query expects.
+ #[serde(default)]
+ pub values: Option,
+}
+
+/// What a query expects: figures by key (every figure an exact decimal
+/// string), or, for a file format, its lines or its rows of fields.
+#[derive(Clone, Debug, PartialEq, Deserialize)]
+#[serde(untagged)]
+pub enum Values {
+ /// A table of named values, every one a string.
+ Figures(BTreeMap),
+ /// A list of lines.
+ Lines(Vec),
+ /// A list of rows, each a list of fields.
+ Rows(Vec>),
+}
+
+impl Values {
+ /// True when it holds nothing.
+ pub fn is_empty(&self) -> bool {
+ match self {
+ Values::Figures(f) => f.is_empty(),
+ Values::Lines(l) => l.is_empty(),
+ Values::Rows(r) => r.is_empty(),
+ }
+ }
+}
+
+impl Expect {
+ /// The values the query expects; panics when it gives none.
+ pub fn values(&self, id: &str) -> &Values {
+ self.values
+ .as_ref()
+ .unwrap_or_else(|| panic!("{id}: {} expects values", self.query))
+ }
+
+ /// The figures the query expects; panics on lines or rows.
+ pub fn figures(&self, id: &str) -> &BTreeMap {
+ match self.values(id) {
+ Values::Figures(f) => f,
+ _ => panic!("{id}: {} expects figures (a table)", self.query),
+ }
+ }
+
+ /// The question this expectation asks, as the engine receives it.
+ pub fn query(&self) -> Query {
+ Query {
+ name: self.query.clone(),
+ from: self.from.clone(),
+ to: self.to.clone(),
+ as_of: self.as_of.clone(),
+ entry: self.entry,
+ event: self.event,
+ asset: self.asset.clone(),
+ schedule: self.schedule.clone(),
+ party: self.party.clone(),
+ project: self.project.clone(),
+ basis: self.basis.clone(),
+ payee: self.payee.clone(),
+ after: self.after,
+ code: self.code.clone(),
+ new_name: self.name.clone(),
+ category: self.category.clone(),
+ account: self.account.clone(),
+ }
+ }
+}
+
+/// The question an expectation asks: everything but the answer.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Query {
+ /// The query's name.
+ pub name: String,
+ /// A period's first day (raw, `YYYY-MM-DD`).
+ pub from: Option,
+ /// A period's last day.
+ pub to: Option,
+ /// An as-of query's day.
+ pub as_of: Option,
+ /// The event whose entry is asked about.
+ pub entry: Option,
+ /// The event a `run` query asks about.
+ pub event: Option,
+ /// The asset asked about.
+ pub asset: Option,
+ /// The schedule asked about.
+ pub schedule: Option,
+ /// The party asked about.
+ pub party: Option,
+ /// The project asked about.
+ pub project: Option,
+ /// `party_totals`' basis.
+ pub basis: Option,
+ /// The payee asked about.
+ pub payee: Option,
+ /// The books after event N (the runner has already applied them).
+ pub after: Option,
+ /// The account a command names.
+ pub code: Option,
+ /// The new name a rename command gives.
+ pub new_name: Option,
+ /// The category a set-tax-category command sets.
+ pub category: Option,
+ /// The account an add-account command adds.
+ pub account: Option,
+}
+
+/// The queries every engine must answer: the figure queries first, then
+/// the calendar ones, then the commands (tried on a copy of the books),
+/// then the formats appendix's.
+pub const QUERIES: &[&str] = &[
+ "cash_flow",
+ "trial_balance",
+ "profit_and_loss",
+ "entry",
+ "balances",
+ "members_equity",
+ "k1",
+ "k1_tax_basis",
+ "k1_reconciliation",
+ "vendor_1099_paid",
+ "vendor_1099",
+ "party_part",
+ "party_totals",
+ "party_tin_last4",
+ "period",
+ "day",
+ "calendar_year",
+ "account_names",
+ "uncategorized_accounts",
+ "run",
+ "disposal",
+ "depreciation_schedule",
+ "schedule_plan",
+ "register_row",
+ "register_totals",
+ "asset_basis",
+ "project",
+ "rename_account",
+ "add_account",
+ "set_tax_category",
+ "iif_lines",
+ "iif_changes",
+];
+
+/// The commands an example may try: each on a copy of the books, so it
+/// changes nothing checked after it.
+pub const COMMANDS: &[&str] = &["rename_account", "add_account", "set_tax_category"];
+
+/// The formats appendix's queries.
+pub const FORMAT_QUERIES: &[&str] = &["iif_lines", "iif_changes"];
+
+/// The queries whose figures may be text rather than an exact decimal
+/// string: a status, a direction, a date, a year, a name, a rule name,
+/// `"none"`, `"open"`, `"true"` or `"false"`, and the register's dates
+/// beside its figures.
+pub const TEXT_VALUED: &[&str] = &[
+ "vendor_1099",
+ "party_part",
+ "party_tin_last4",
+ "period",
+ "day",
+ "calendar_year",
+ "register_row",
+ "project",
+ "asset_basis",
+ "k1_tax_basis",
+ "account_names",
+];
+
+/// The queries whose example lists every value it covers, so a value left
+/// out of an example is an error. The others name the figures they show:
+/// `cash_flow`, the statements, the register, a schedule's plan and an
+/// asset's basis.
+pub const COMPLETE_QUERIES: &[&str] = &[
+ "trial_balance",
+ "profit_and_loss",
+ "entry",
+ "vendor_1099_paid",
+ "vendor_1099",
+ "party_part",
+ "party_totals",
+ "party_tin_last4",
+ "period",
+ "day",
+ "calendar_year",
+ "account_names",
+ "uncategorized_accounts",
+ "run",
+ "disposal",
+ "project",
+];
+
+impl Query {
+ /// True when it is a command, tried on a copy of the books.
+ pub fn is_command(&self) -> bool {
+ COMMANDS.contains(&self.name.as_str())
+ }
+
+ /// True when it is a formats appendix's query.
+ pub fn is_format(&self) -> bool {
+ FORMAT_QUERIES.contains(&self.name.as_str())
+ }
+
+ /// True when the example must list every value the query covers.
+ pub fn is_complete(&self) -> bool {
+ COMPLETE_QUERIES.contains(&self.name.as_str())
+ }
+}
+
+/// Every refusal reason the specification names (bookspec/README.md), each
+/// the reason of exactly one rule (see [`crate::rules::rule_for_refusal`]).
+pub fn refusal_reasons() -> impl Iterator
- {
+ [
+ "end_before_start",
+ "start_after_today",
+ "blank",
+ "unchanged",
+ "sibling_has_it",
+ "holds_a_tin",
+ "not_one_line",
+ "code_taken",
+ "tax_category_required",
+ "depreciation_past_disposal",
+ "entry_irreversible",
+ "already_disposed",
+ "invalid_depreciation_period",
+ "invalid_schedule",
+ ]
+ .into_iter()
+}
+
+/// Why an example is not well formed.
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub struct SchemaError {
+ /// The example's id, when it has one.
+ pub id: String,
+ /// What is wrong.
+ pub message: String,
+}
+
+impl fmt::Display for SchemaError {
+ fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+ if self.id.is_empty() {
+ write!(f, "{}", self.message)
+ } else {
+ write!(f, "{}: {}", self.id, self.message)
+ }
+ }
+}
+
+impl std::error::Error for SchemaError {}
+
+impl SchemaError {
+ fn new(id: &str, message: impl Into) -> Self {
+ Self {
+ id: id.to_string(),
+ message: message.into(),
+ }
+ }
+}
+
+impl Example {
+ /// Parse an example from its TOML text and check its shape.
+ pub fn from_toml(text: &str) -> Result {
+ let ex: Self = toml::from_str(text).map_err(|e| SchemaError::new("", e.to_string()))?;
+ ex.validate()?;
+ Ok(ex)
+ }
+
+ /// Check the example's shape: its closed lists, its dates and amounts,
+ /// its references and its events' and expectations' forms.
+ pub fn validate(&self) -> Result<(), SchemaError> {
+ let id = self.id.as_str();
+ let e = |m: String| SchemaError::new(id, m);
+ if id.is_empty() {
+ return Err(SchemaError::new("", "an example has an id"));
+ }
+ if self.title.is_empty() || self.rule.is_empty() {
+ return Err(e("an example has a title and a rule".into()));
+ }
+ if self.sources.is_empty() || self.tags.is_empty() {
+ return Err(e("an example is sourced and tagged".into()));
+ }
+ if !matches!(self.status.as_str(), "AUTHORITY" | "CONVENTION" | "CHOICE") {
+ return Err(e(format!("status {:?}", self.status)));
+ }
+ if let Some(a) = &self.appendix
+ && a != "formats"
+ {
+ return Err(e(format!("appendix {a:?}")));
+ }
+ if self.expect.is_empty() {
+ return Err(e("an example expects something".into()));
+ }
+ self.setup.validate(id)?;
+
+ let mut codes = BTreeSet::new();
+ for a in &self.setup.accounts {
+ codes.insert(a.code.as_str());
+ }
+ for a in &self.setup.assets {
+ for c in [&a.cost_account, &a.accumulated_account, &a.expense_account] {
+ if !codes.contains(c.as_str()) {
+ return Err(e(format!("asset {}: no account {c}", a.id)));
+ }
+ }
+ if let Some(n) = a.acquired_by
+ && (n < 1 || n > self.events.len())
+ {
+ return Err(e(format!(
+ "asset {}: acquired_by {n}: events count from 1, to {}",
+ a.id,
+ self.events.len()
+ )));
+ }
+ }
+ for s in &self.setup.schedules {
+ s.validate(id, &codes)?;
+ }
+ let parties: BTreeSet<&str> = self.setup.parties.iter().map(|p| p.name.as_str()).collect();
+ let projects: BTreeSet<&str> = self
+ .setup
+ .projects
+ .iter()
+ .map(|p| p.name.as_str())
+ .collect();
+ let payees: BTreeSet<&str> = self.setup.payees.iter().map(|p| p.name.as_str()).collect();
+ let instruments: BTreeSet<&str> = self
+ .setup
+ .instruments
+ .iter()
+ .map(|i| i.label.as_str())
+ .collect();
+ for (n, ev) in self.events.iter().enumerate() {
+ let n = n + 1;
+ let what = |m: String| e(format!("event {n}: {m}"));
+ let kind = ev.kind().map_err(what)?;
+ if ev.date.is_some() != kind.is_dated() {
+ return Err(what(if kind.is_dated() {
+ format!("{} is dated (date)", kind.key())
+ } else {
+ format!("{} dates its own entries: no date", kind.key())
+ }));
+ }
+ if let Some(d) = &ev.date {
+ check_date(d, &what)?;
+ }
+ if ev.description.is_some() != kind.is_described() {
+ return Err(what(if kind.is_described() {
+ format!("{} is described (description)", kind.key())
+ } else {
+ format!("{} describes its own entries: no description", kind.key())
+ }));
+ }
+ if ev.older && kind != EventKind::Lines {
+ return Err(what(
+ "older is an entry's lines, as an earlier version wrote them".into(),
+ ));
+ }
+ if ev.payee.is_some() && kind != EventKind::Lines {
+ return Err(what(
+ "a payee is an entry's: a reversal takes its original's, a bill names \
+ its vendor, an invoice its customer, a close and a command have none"
+ .into(),
+ ));
+ }
+ if (!ev.parties.is_empty() || ev.instrument.is_some()) && !kind.may_tag() {
+ return Err(what(format!(
+ "{} is tagged with no parties or payment method",
+ kind.key()
+ )));
+ }
+ if ev.refused.is_some() && !kind.may_refuse() {
+ return Err(what(
+ "refused is for a reversal or a command, which the books take whole or \
+ not at all"
+ .into(),
+ ));
+ }
+ if let Some(r) = &ev.refused
+ && rule_for_refusal(r).is_none()
+ {
+ return Err(what(format!("{r:?} is no refusal the specification names")));
+ }
+ if let Some(p) = &ev.payee
+ && !payees.contains(p.as_str())
+ {
+ return Err(what(format!("payee {p:?} not in setup.payees")));
+ }
+ if let Some(i) = &ev.instrument
+ && !instruments.contains(i.as_str())
+ {
+ return Err(what(format!(
+ "payment method {i:?} not in setup.instruments"
+ )));
+ }
+ if let Some(of) = ev.reverses {
+ if of < 1 {
+ return Err(what(format!("reverses = {of}: events count from 1")));
+ }
+ if of >= n {
+ return Err(what(format!("reverses a later event ({of})")));
+ }
+ }
+ for line in &ev.lines {
+ if !codes.contains(line.account.as_str()) {
+ return Err(what(format!("no account {}", line.account)));
+ }
+ if !matches!(line.side.as_str(), "debit" | "credit") {
+ return Err(what(format!("side {:?}", line.side)));
+ }
+ check_amount(&line.amount, true, &what)?;
+ if let Some(p) = &line.project
+ && !projects.contains(p.as_str())
+ {
+ return Err(what(format!("project {p:?} not in setup.projects")));
+ }
+ }
+ for sh in &ev.parties {
+ if !parties.contains(sh.party.as_str()) {
+ return Err(what(format!("party {:?} not in setup.parties", sh.party)));
+ }
+ match (&sh.amount, &sh.percent) {
+ (None, None) => {}
+ (Some(a), None) => check_amount(a, false, &what)?,
+ (None, Some(p)) => check_amount(p, false, &what)?,
+ _ => return Err(what("a share is an amount or a percent".into())),
+ }
+ }
+ if let Some(c) = &ev.close {
+ check_date(&c.start, &what)?;
+ if !matches!(c.to.as_str(), "members" | "retained_earnings") {
+ return Err(what(format!("close to {:?}", c.to)));
+ }
+ if let Some(r) = &c.rounding
+ && !(r == "per-close" && c.to == "members")
+ {
+ return Err(what(format!("close rounding {r:?}")));
+ }
+ }
+ if let Some(b) = &ev.bill {
+ check_document(&b.amount, &b.lines, &projects, &what)?;
+ if b.vendor.is_empty() {
+ return Err(what("a bill names its vendor".into()));
+ }
+ }
+ if let Some(i) = &ev.invoice {
+ check_document(&i.amount, &i.lines, &projects, &what)?;
+ if i.customer.is_empty() {
+ return Err(what("an invoice names its customer".into()));
+ }
+ }
+ if let Some(r) = &ev.run_depreciation {
+ check_month(&r.period, &what)?;
+ if !self.setup.assets.iter().any(|a| a.id == r.asset) {
+ return Err(what(format!("no asset {:?} in setup.assets", r.asset)));
+ }
+ }
+ if let Some(d) = &ev.dispose_asset {
+ if !self.setup.assets.iter().any(|a| a.id == d.asset) {
+ return Err(what(format!("no asset {:?} in setup.assets", d.asset)));
+ }
+ check_amount(&d.proceeds, false, &what)?;
+ for c in [&d.proceeds_account, &d.gain_loss_account] {
+ if !codes.contains(c.as_str()) {
+ return Err(what(format!("no account {c}")));
+ }
+ }
+ }
+ if let Some(r) = &ev.run_schedules {
+ check_date(&r.through, &what)?;
+ }
+ if let Some(s) = &ev.register_schedule
+ && ev.refused.is_none()
+ {
+ s.validate(id, &codes)?;
+ }
+ }
+ for c in &self.changes {
+ let what = |m: String| e(format!("change after {}: {m}", c.after));
+ if c.after < 1 || c.after > self.events.len() {
+ return Err(what(format!(
+ "after = {}: events count from 1, to {}",
+ c.after,
+ self.events.len()
+ )));
+ }
+ let given = [
+ c.rename.is_some(),
+ c.require_tax_category.is_some(),
+ c.correct_asset.is_some(),
+ ];
+ if given.iter().filter(|x| **x).count() != 1 {
+ return Err(what(
+ "a change is one of rename, require_tax_category or correct_asset".into(),
+ ));
+ }
+ if let Some(r) = &c.rename {
+ if !codes.contains(r.code.as_str()) {
+ return Err(what(format!("no account {}", r.code)));
+ }
+ if r.name.trim().is_empty() {
+ return Err(what("a rename names the account".into()));
+ }
+ }
+ if let Some(p) = &c.require_tax_category
+ && !matches!(p.as_str(), "warn" | "refuse")
+ {
+ return Err(what(format!("require_tax_category {p:?}")));
+ }
+ if let Some(f) = &c.correct_asset {
+ if !self.setup.assets.iter().any(|a| a.id == f.asset) {
+ return Err(what(format!("no asset {:?} in setup.assets", f.asset)));
+ }
+ for a in [&f.cost, &f.salvage].into_iter().flatten() {
+ check_amount(a, false, &what)?;
+ }
+ if let Some(d) = &f.in_service {
+ check_date(d, &what)?;
+ }
+ if f.life_months == Some(0) {
+ return Err(what("life_months is above zero".into()));
+ }
+ if f.reason.trim().is_empty() {
+ return Err(what("a correction gives its reason".into()));
+ }
+ }
+ }
+ for x in &self.expect {
+ let q = x.query.as_str();
+ let what = |m: String| e(format!("{q}: {m}"));
+ if !QUERIES.contains(&q) {
+ return Err(e(format!("no query answers {q:?}")));
+ }
+ if let Some(n) = x.after
+ && (n < 1 || n > self.events.len())
+ {
+ return Err(what(format!(
+ "after = {n}: events count from 1, to {}",
+ self.events.len()
+ )));
+ }
+ if x.after.is_some() && FORMAT_QUERIES.contains(&q) {
+ return Err(what(
+ "a format query is of the books after every event".into(),
+ ));
+ }
+ if FORMAT_QUERIES.contains(&q) && self.appendix.as_deref() != Some("formats") {
+ return Err(what(
+ "a format query belongs to an example in appendix \"formats\"".into(),
+ ));
+ }
+ for d in [&x.from, &x.to, &x.as_of].into_iter().flatten() {
+ check_date(d, &what)?;
+ }
+ only(x, &what)?;
+ if COMMANDS.contains(&q) {
+ if x.values.is_some() || (x.accepted == Some(true)) == x.refused.is_some() {
+ return Err(what(
+ "a command is accepted = true, or refused, and shows no values".into(),
+ ));
+ }
+ if x.accepted == Some(false) {
+ return Err(what("a command is accepted = true, or refused".into()));
+ }
+ if let Some(r) = &x.refused
+ && rule_for_refusal(r).is_none()
+ {
+ return Err(what(format!("{r:?} is no refusal the specification names")));
+ }
+ if x.account.as_ref().is_some_and(|a| a.code.trim().is_empty()) {
+ return Err(what("add_account names the account's code".into()));
+ }
+ } else {
+ match (&x.values, &x.refused) {
+ (Some(v), None) if !v.is_empty() => {
+ if let Values::Figures(f) = v {
+ for (k, val) in f {
+ if !matches!(
+ q,
+ "vendor_1099"
+ | "party_part"
+ | "party_tin_last4"
+ | "asset_basis"
+ | "register_row"
+ | "project"
+ ) && !k.is_empty()
+ {
+ // Figures may be dates, names or
+ // amounts; a format's text is a list,
+ // not a figure table.
+ let _ = val;
+ }
+ }
+ }
+ }
+ (None, Some(r)) => {
+ if q != "period" {
+ return Err(what(
+ "only a period may be refused instead of answering".into(),
+ ));
+ }
+ if rule_for_refusal(r).is_none() {
+ return Err(what(format!(
+ "{r:?} is no refusal the specification names"
+ )));
+ }
+ }
+ _ => return Err(what("expects values (a period: values or refused)".into())),
+ }
+ }
+ if let Some(v) = &x.values {
+ match v {
+ Values::Figures(f) => {
+ for (k, val) in f {
+ if k.is_empty() {
+ return Err(what("a figure's key is empty".into()));
+ }
+ if !TEXT_VALUED.contains(&q) && !is_exact_decimal(val) {
+ return Err(what(format!(
+ "{k}: {val:?} is no exact decimal string"
+ )));
+ }
+ }
+ }
+ Values::Lines(l) => {
+ if !matches!(q, "iif_lines" | "uncategorized_accounts") {
+ return Err(what("expects figures (a table), not lines".into()));
+ }
+ if l.is_empty() {
+ return Err(what("an empty list expects nothing".into()));
+ }
+ }
+ Values::Rows(rows) => {
+ if q != "iif_changes" {
+ return Err(what("expects figures (a table), not rows".into()));
+ }
+ if rows.iter().any(|r| r.len() != 3) {
+ return Err(what("an iif_changes row has three fields".into()));
+ }
+ }
+ }
+ }
+ if FORMAT_QUERIES.contains(&q) && x.values.is_none() {
+ return Err(what("a format query expects its lines or rows".into()));
+ }
+ }
+ Ok(())
+ }
+}
+
+impl Setup {
+ /// Check the setup's shape: its closed lists, its dates, its accounts
+ /// and its directory.
+ pub fn validate(&self, id: &str) -> Result<(), SchemaError> {
+ let e = |m: String| SchemaError::new(id, m);
+ if self.currency.len() != 3 || !self.currency.bytes().all(|b| b.is_ascii_uppercase()) {
+ return Err(e(format!("currency {:?}", self.currency)));
+ }
+ if !is_month_day(&self.fiscal_year_start) {
+ return Err(e(format!("fiscal_year_start {:?}", self.fiscal_year_start)));
+ }
+ if !matches!(self.basis.as_str(), "accrual" | "cash") {
+ return Err(e(format!("basis {:?}", self.basis)));
+ }
+ if !matches!(
+ self.rounding.as_str(),
+ "half-even" | "half-away-from-zero" | "largest-remainder"
+ ) {
+ return Err(e(format!("rounding {:?}", self.rounding)));
+ }
+ if let Some(t) = &self.today {
+ check_date(t, &e)?;
+ }
+ let mut codes: BTreeSet<&str> = BTreeSet::new();
+ for a in &self.accounts {
+ if a.code.trim().is_empty() || a.name.trim().is_empty() {
+ return Err(e("an account has a code and a name".into()));
+ }
+ if !matches!(
+ a.kind.as_str(),
+ "asset" | "liability" | "equity" | "revenue" | "expense"
+ ) {
+ return Err(e(format!("account {}: type {:?}", a.code, a.kind)));
+ }
+ if !codes.insert(a.code.as_str()) {
+ return Err(e(format!("account {}: code used twice", a.code)));
+ }
+ if let Some(p) = &a.parent
+ && !codes.contains(p.as_str())
+ {
+ return Err(e(format!(
+ "account {}: parent {p} is not declared before it",
+ a.code
+ )));
+ }
+ }
+ for fa in &self.fixed_assets {
+ for c in [
+ &fa.cost_account,
+ &fa.accumulated_account,
+ &fa.expense_account,
+ ] {
+ if !codes.contains(c.as_str()) {
+ return Err(e(format!("fixed asset: no account {c}")));
+ }
+ }
+ }
+ let mut ids = BTreeSet::new();
+ for a in &self.assets {
+ if a.id.is_empty() || a.description.is_empty() {
+ return Err(e("an asset has an id and a description".into()));
+ }
+ if !ids.insert(a.id.as_str()) {
+ return Err(e(format!("asset {}: id used twice", a.id)));
+ }
+ if a.method != "straight_line" {
+ return Err(e(format!("asset {}: method {:?}", a.id, a.method)));
+ }
+ check_amount(&a.cost, false, &e)?;
+ check_amount(&a.salvage, false, &e)?;
+ if a.life_months == 0 {
+ return Err(e(format!("asset {}: life_months is above zero", a.id)));
+ }
+ check_date(&a.in_service, &e)?;
+ if let Some(b) = &a.basis {
+ if !matches!(
+ b.rule.as_str(),
+ "purchase" | "carryover_723" | "personal_use_conversion"
+ ) {
+ return Err(e(format!("asset {}: basis rule {:?}", a.id, b.rule)));
+ }
+ for x in [&b.contributor_adjusted_basis, &b.fmv_at_contribution]
+ .into_iter()
+ .flatten()
+ {
+ check_amount(x, false, &e)?;
+ }
+ if let Some(m) = &b.sec704c_method
+ && !matches!(
+ m.as_str(),
+ "traditional" | "traditional_with_curative" | "remedial"
+ )
+ {
+ return Err(e(format!("asset {}: section 704(c) method {m:?}", a.id)));
+ }
+ }
+ }
+ let mut schedule_ids = BTreeSet::new();
+ for s in &self.schedules {
+ s.validate(id, &codes)?;
+ if !schedule_ids.insert(s.id.as_str()) {
+ return Err(e(format!("schedule {}: id used twice", s.id)));
+ }
+ }
+ let mut party_names = BTreeSet::new();
+ for p in &self.parties {
+ if p.name.trim().is_empty() {
+ return Err(e("a party has a name".into()));
+ }
+ if !party_names.insert(p.name.as_str()) {
+ return Err(e(format!("party {}: named twice", p.name)));
+ }
+ if p.roles.iter().any(|r| !ROLES.contains(&r.as_str())) {
+ return Err(e(format!("party {}: roles {:?}", p.name, p.roles)));
+ }
+ if p.roles
+ .iter()
+ .filter(|r| *r == "vendor" || *r == "customer")
+ .count()
+ > 1
+ {
+ return Err(e(format!(
+ "party {}: a vendor or a customer, not both",
+ p.name
+ )));
+ }
+ for c in &p.creditor_of {
+ if !codes.contains(c.as_str()) {
+ return Err(e(format!("party {}: no account {c}", p.name)));
+ }
+ }
+ for l in [&p.tin_last4, &p.w9_tin_last4].into_iter().flatten() {
+ if l.len() != 4 || !l.bytes().all(|b| b.is_ascii_digit()) {
+ return Err(e(format!("party {}: last four {l:?}", p.name)));
+ }
+ }
+ if p.tin_purged && p.tin_last4.is_none() {
+ return Err(e(format!("party {}: tin_purged needs tin_last4", p.name)));
+ }
+ }
+ let mut member_names = BTreeSet::new();
+ let shared = self.members.iter().filter(|m| m.share.is_some()).count();
+ if shared != 0 && shared != self.members.len() {
+ return Err(e("every member has a share, or none does".into()));
+ }
+ let mut total = Decimal::ZERO;
+ for m in &self.members {
+ if m.name.trim().is_empty() || !member_names.insert(m.name.as_str()) {
+ return Err(e(format!("member {:?} is named twice", m.name)));
+ }
+ if !codes.contains(m.capital_account.as_str()) {
+ return Err(e(format!(
+ "member {}: no account {}",
+ m.name, m.capital_account
+ )));
+ }
+ if let Some(d) = &m.distributions_account
+ && !codes.contains(d.as_str())
+ {
+ return Err(e(format!("member {}: no account {d}", m.name)));
+ }
+ if let Some(s) = &m.share {
+ let share = decimal(s, &e)?;
+ if share <= Decimal::ZERO {
+ return Err(e(format!("member {}: share {s:?} is above zero", m.name)));
+ }
+ total += share;
+ }
+ }
+ if shared > 0 && total != Decimal::ONE {
+ return Err(e("the shares sum to 1".into()));
+ }
+ let mut payee_names = BTreeSet::new();
+ for p in &self.payees {
+ if p.name.trim().is_empty() || !payee_names.insert(p.name.as_str()) {
+ return Err(e(format!("payee {:?} is named twice", p.name)));
+ }
+ if !matches!(p.form.as_str(), "1099-NEC" | "1099-MISC") {
+ return Err(e(format!("payee {}: form {:?}", p.name, p.form)));
+ }
+ }
+ for c in &self.status_changes {
+ if !payee_names.contains(c.payee.as_str()) {
+ return Err(e(format!(
+ "status change: payee {:?} not in setup",
+ c.payee
+ )));
+ }
+ check_date(&c.effective, &e)?;
+ match c.decision.as_str() {
+ "not_reportable" => {
+ if !c
+ .reason_kind
+ .as_deref()
+ .is_some_and(|k| EXEMPT_REASONS.contains(&k))
+ {
+ return Err(e(format!(
+ "status change of {}: not_reportable needs a reason_kind",
+ c.payee
+ )));
+ }
+ if c.reason.as_deref().is_none_or(|r| r.trim().is_empty()) {
+ return Err(e(format!(
+ "status change of {}: not_reportable needs a reason",
+ c.payee
+ )));
+ }
+ }
+ "undetermined" | "1099-NEC" | "1099-MISC" => {
+ if c.reason_kind.is_some() || c.reason.is_some() {
+ return Err(e(format!(
+ "status change of {}: only not_reportable takes reason_kind and reason",
+ c.payee
+ )));
+ }
+ }
+ other => {
+ return Err(e(format!(
+ "status change of {}: decision {other:?}",
+ c.payee
+ )));
+ }
+ }
+ }
+ let mut labels = BTreeSet::new();
+ for i in &self.instruments {
+ if i.label.trim().is_empty() || !labels.insert(i.label.as_str()) {
+ return Err(e(format!("payment method {:?} is labelled twice", i.label)));
+ }
+ if !matches!(
+ i.kind.as_str(),
+ "bank" | "business_card" | "member_card" | "member_cash_or_check" | "platform"
+ ) {
+ return Err(e(format!("payment method {}: kind {:?}", i.label, i.kind)));
+ }
+ if !codes.contains(i.account.as_str()) {
+ return Err(e(format!(
+ "payment method {}: no account {}",
+ i.label, i.account
+ )));
+ }
+ if let Some(o) = &i.owner
+ && !party_names.contains(o.as_str())
+ {
+ return Err(e(format!(
+ "payment method {}: owner {o:?} not in setup.parties",
+ i.label
+ )));
+ }
+ for c in [&i.last4].into_iter().flatten() {
+ check_last4(c, &e)?;
+ }
+ }
+ let mut project_names = BTreeSet::new();
+ for p in &self.projects {
+ if p.name.trim().is_empty() || !project_names.insert(p.name.as_str()) {
+ return Err(e(format!("project {:?} is named twice", p.name)));
+ }
+ if let Some(k) = &p.kind
+ && !matches!(k.as_str(), "internal" | "client" | "on_spec" | "other")
+ {
+ return Err(e(format!("project {}: kind {k:?}", p.name)));
+ }
+ if let Some(s) = &p.status
+ && !matches!(s.as_str(), "active" | "paused" | "finished" | "abandoned")
+ {
+ return Err(e(format!("project {}: status {s:?}", p.name)));
+ }
+ if let Some(t) = &p.party
+ && !party_names.contains(t.as_str())
+ {
+ return Err(e(format!(
+ "project {}: party {t:?} not in setup.parties",
+ p.name
+ )));
+ }
+ }
+ Ok(())
+ }
+}
+
+impl ScheduleSpec {
+ fn validate(&self, id: &str, codes: &BTreeSet<&str>) -> Result<(), SchemaError> {
+ let e = |m: String| SchemaError::new(id, m);
+ if self.id.trim().is_empty() || self.description.trim().is_empty() {
+ return Err(e("a schedule has an id and a description".into()));
+ }
+ if !matches!(self.kind.as_str(), "prepaid_expense" | "deferred_revenue") {
+ return Err(e(format!("schedule {}: kind {:?}", self.id, self.kind)));
+ }
+ check_amount(&self.total, false, &e)?;
+ check_date(&self.start, &e)?;
+ if self.months == 0 {
+ return Err(e(format!("schedule {}: months is above zero", self.id)));
+ }
+ if !matches!(self.convention.as_str(), "daily" | "monthly_even") {
+ return Err(e(format!(
+ "schedule {}: convention {:?}",
+ self.id, self.convention
+ )));
+ }
+ if self.convention == "monthly_even" {
+ let day = self
+ .start
+ .rsplit_once('-')
+ .and_then(|(_, d)| d.parse::().ok());
+ if day != Some(1) {
+ return Err(e(format!(
+ "schedule {}: an equal-months schedule starts on the 1st",
+ self.id
+ )));
+ }
+ }
+ for c in [&self.balance_account, &self.pl_account] {
+ if !codes.contains(c.as_str()) {
+ return Err(e(format!("schedule {}: no account {c}", self.id)));
+ }
+ }
+ Ok(())
+ }
+}
+
+/// The party roles the specification names.
+pub const ROLES: &[&str] = &[
+ "member",
+ "vendor",
+ "customer",
+ "contractor",
+ "landlord",
+ "employee",
+ "financial_institution",
+ "payout_platform",
+ "tax_agency",
+ "government_agency",
+];
+
+/// Why a party is not reportable (B-19).
+pub const EXEMPT_REASONS: &[&str] = &[
+ "corporation",
+ "tax_exempt",
+ "united_states",
+ "state",
+ "foreign_government",
+ "foreign_person",
+ "merchandise",
+ "employee",
+ "other",
+];
+
+/// A date, `YYYY-MM-DD` and a real day.
+pub fn is_date(s: &str) -> bool {
+ s.len() == 10 && NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok()
+}
+
+/// A month, `YYYY-MM`.
+pub fn is_month(s: &str) -> bool {
+ s.len() == 7 && NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d").is_ok()
+}
+
+/// An exact decimal string: digits, an optional sign and point; never an
+/// exponent, never a float.
+pub fn is_exact_decimal(s: &str) -> bool {
+ !s.is_empty()
+ && s.chars()
+ .all(|c| c.is_ascii_digit() || c == '.' || c == '-')
+ && s.matches('.').count() <= 1
+ && s.parse::().is_ok()
+}
+
+fn decimal(s: &str, e: &impl Fn(String) -> SchemaError) -> Result {
+ if !is_exact_decimal(s) {
+ return Err(e(format!("{s:?} is no exact decimal string")));
+ }
+ s.parse()
+ .map_err(|_| e(format!("{s:?} is no exact decimal string")))
+}
+
+fn check_amount(
+ s: &str,
+ positive: bool,
+ e: &impl Fn(String) -> SchemaError,
+) -> Result<(), SchemaError> {
+ let d = decimal(s, e)?;
+ if positive && d <= Decimal::ZERO {
+ return Err(e(format!("{s:?}: amounts are positive")));
+ }
+ Ok(())
+}
+
+fn check_date(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
+ if !is_date(s) {
+ return Err(e(format!("{s:?} is no date (YYYY-MM-DD)")));
+ }
+ Ok(())
+}
+
+fn check_month(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
+ if !is_month(s) {
+ return Err(e(format!("{s:?} is no month (YYYY-MM)")));
+ }
+ Ok(())
+}
+
+fn check_last4(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
+ if s.len() != 4 || !s.bytes().all(|b| b.is_ascii_digit()) {
+ return Err(e(format!("last four {s:?}")));
+ }
+ Ok(())
+}
+
+fn check_document(
+ amount: &Option,
+ lines: &[DocumentLineSpec],
+ projects: &BTreeSet<&str>,
+ e: &impl Fn(String) -> SchemaError,
+) -> Result<(), SchemaError> {
+ match (amount, lines) {
+ (Some(a), []) => {
+ if decimal(a, e)? <= Decimal::ZERO {
+ return Err(e("amounts are positive".into()));
+ }
+ }
+ (None, [_, ..]) => {
+ for l in lines {
+ if decimal(&l.amount, e)? <= Decimal::ZERO {
+ return Err(e("amounts are positive".into()));
+ }
+ if let Some(p) = &l.project
+ && !projects.contains(p.as_str())
+ {
+ return Err(e(format!("project {p:?} not in setup.projects")));
+ }
+ }
+ }
+ (Some(_), [_, ..]) => {
+ return Err(e("a document has an amount or lines, exactly one".into()));
+ }
+ (None, []) => return Err(e("a document has an amount or lines, exactly one".into())),
+ }
+ Ok(())
+}
+
+/// A key only the queries that ask for it may name.
+fn only(x: &Expect, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> {
+ let q = x.query.as_str();
+ let checks: [(&str, bool, &[&str]); 12] = [
+ ("entry", x.entry.is_some(), &["entry", "party_part"]),
+ ("event", x.event.is_some(), &["run"]),
+ (
+ "asset",
+ x.asset.is_some(),
+ &[
+ "disposal",
+ "depreciation_schedule",
+ "register_row",
+ "asset_basis",
+ ],
+ ),
+ ("schedule", x.schedule.is_some(), &["schedule_plan"]),
+ (
+ "party",
+ x.party.is_some(),
+ &["party_part", "party_totals", "party_tin_last4"],
+ ),
+ ("project", x.project.is_some(), &["project"]),
+ ("basis", x.basis.is_some(), &["party_totals"]),
+ ("payee", x.payee.is_some(), &["vendor_1099"]),
+ (
+ "code",
+ x.code.is_some(),
+ &["rename_account", "set_tax_category"],
+ ),
+ ("name", x.name.is_some(), &["rename_account"]),
+ ("category", x.category.is_some(), &["set_tax_category"]),
+ ("account", x.account.is_some(), &["add_account"]),
+ ];
+ for (key, given, queries) in checks {
+ if given && !queries.contains(&q) {
+ return Err(e(format!("{key} is for {} only", queries.join(" and "))));
+ }
+ }
+ Ok(())
+}
+
+fn is_month_day(s: &str) -> bool {
+ let Some((m, d)) = s.split_once('-') else {
+ return false;
+ };
+ let (Ok(m), Ok(d)) = (m.parse::(), d.parse::()) else {
+ return false;
+ };
+ s.len() == 5 && s.as_bytes()[2] == b'-' && NaiveDate::from_ymd_opt(2001, m, d).is_some()
+}
diff --git a/bookspec/checker/tests/rules.rs b/bookspec/checker/tests/rules.rs
new file mode 100644
index 0000000..3dbc5f2
--- /dev/null
+++ b/bookspec/checker/tests/rules.rs
@@ -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> = 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 = (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 = 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--`, 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);
+}
diff --git a/bookspec/checker/tests/schema.rs b/bookspec/checker/tests/schema.rs
new file mode 100644
index 0000000..08f6b4d
--- /dev/null
+++ b/bookspec/checker/tests/schema.rs
@@ -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 = 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}"
+ );
+ }
+}
diff --git a/bookspec/runner/Cargo.toml b/bookspec/runner/Cargo.toml
new file mode 100644
index 0000000..78f3d1d
--- /dev/null
+++ b/bookspec/runner/Cargo.toml
@@ -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
diff --git a/bookspec/runner/src/lib.rs b/bookspec/runner/src/lib.rs
new file mode 100644
index 0000000..72d9f99
--- /dev/null
+++ b/bookspec/runner/src/lib.rs
@@ -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,
+}
+
+/// 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,
+ /// Every example, in the order run.
+ pub examples: Vec,
+ /// Failures that belong to no rule: an example no rule lists, a rule
+ /// claimed but unknown to the catalogue.
+ pub unclaimed: Vec,
+}
+
+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 {
+ 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(""))
+ .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> = got
+ .iter()
+ .map(|row| {
+ row.as_array()
+ .map(|r| {
+ r.iter()
+ .map(|v| v.as_str().unwrap_or(""))
+ .collect()
+ })
+ .unwrap_or_default()
+ })
+ .collect();
+ let want: Vec> = 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))
+ }
+ }
+ }
+}
diff --git a/bookspec/runner/tests/runner.rs b/bookspec/runner/tests/runner.rs
new file mode 100644
index 0000000..a6ced22
--- /dev/null
+++ b/bookspec/runner/tests/runner.rs
@@ -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 {
+ 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,
+ 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 {
+ 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()
+ );
+}
diff --git a/bookspec/spec/rules-index.md b/bookspec/spec/rules-index.md
new file mode 100644
index 0000000..3a3e5da
--- /dev/null
+++ b/bookspec/spec/rules-index.md
@@ -0,0 +1,60 @@
+# bookspec's rule index
+
+Every rule of `docs/spec/rules.md` (B-1 to B-45), its bookspec
+identifier `BS--` (`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) | — |
diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml
index adacdf2..acefd11 100644
--- a/crates/ledger-core/Cargo.toml
+++ b/crates/ledger-core/Cargo.toml
@@ -26,6 +26,10 @@ proptest = "1"
# store (a dev-dependency only: the store depends on this crate), each in
# a directory of its own.
toml = "1"
+bookspec-checker = { path = "../../bookspec/checker" }
+bookspec-runner = { path = "../../bookspec/runner" }
+ledger-package = { path = "../ledger-package" }
+ledger-import = { path = "../ledger-import" }
ledger-store = { path = "../ledger-store" }
tempfile = "3"
diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs
index 999b959..48a7155 100644
--- a/crates/ledger-core/tests/bookspec.rs
+++ b/crates/ledger-core/tests/bookspec.rs
@@ -35,7 +35,7 @@ use std::borrow::Borrow;
use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};
-use chrono::NaiveDate;
+use chrono::{Datelike, NaiveDate};
use ledger_core::calendar::{PeriodDefaults, period_defaults};
use ledger_core::parties::{Basis, MoneyAccounts};
use ledger_core::settings::{Company, Source};
@@ -51,617 +51,92 @@ use ledger_core::{
};
use ledger_store::{DepreciationRun, DisposalPreview, Store, StoreError};
use rust_decimal::Decimal;
-use serde::Deserialize;
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct Example {
- id: String,
- title: String,
- rule: String,
- status: String,
- sources: Vec,
- tags: Vec,
- /// `"formats"`: an interchange format rather than an accounting rule
- /// (P13's appendix of formats); absent for the rules themselves.
- #[serde(default)]
- appendix: Option,
- setup: Setup,
- events: Vec,
- /// Changes that post no entry, each made after the event it names.
- #[serde(default)]
- changes: Vec,
- expect: Vec,
-}
+use bookspec_checker::{
+ AccountSpec, Area, BasisSpec, ChangeSpec, Claims, DocumentLineSpec, Engine, Event, Example,
+ Expect, Query, Refusal, RuleId, ScheduleSpec, Setup, Values,
+};
+use ledger_import::IifField;
+use ledger_package::export_iif;
+use serde_json::Value;
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct Setup {
- currency: String,
- fiscal_year_start: String,
- basis: String,
- rounding: String,
- accounts: Vec,
- #[serde(default)]
- cash_accounts: Vec,
- #[serde(default)]
- financing_accounts: Vec,
- #[serde(default)]
- investing_accounts: Vec,
- #[serde(default)]
- fixed_assets: Vec,
- /// Fixed assets registered, with their figures (rules.md B-15).
- #[serde(default)]
- assets: Vec,
- /// Prepaid-expense and deferred-revenue schedules registered (rules.md
- /// B-16).
- #[serde(default)]
- schedules: Vec,
- /// The company's directory: parties other than a payee's vendor
- /// record, and the payees' when the example names their roles.
- #[serde(default)]
- parties: Vec,
- #[serde(default)]
- members: Vec,
- #[serde(default)]
- payees: Vec,
- /// A payee's 1099 decision from a date on (rules.md B-19, D-40).
- #[serde(default)]
- status_changes: Vec,
- /// The ways the company (or a member, for it) pays (rules.md B-19).
- #[serde(default)]
- instruments: Vec,
- /// The projects (rules.md B-37), each added with its facts.
- #[serde(default)]
- projects: Vec,
- /// The day the books are asked on, in the company's time zone: what
- /// an omitted date means (rules.md B-30). A query that omits a date
- /// needs it.
- #[serde(default)]
- today: Option,
-}
+/// A JSON object: the shape every figure query answers in.
+type JsonMap = serde_json::Map;
-/// A project (rules.md B-37): its name (once each), and its kind
-/// (`internal`, `client`, `on_spec`, `other`), status and party (a
-/// `setup.parties` name), if given.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct ProjectSpec {
- name: String,
- #[serde(default)]
- kind: Option,
- #[serde(default)]
- status: Option,
- #[serde(default)]
- party: Option,
-}
-
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct AccountSpec {
- code: String,
- name: String,
- #[serde(rename = "type")]
- kind: String,
- /// The parent account's code, declared before it.
- #[serde(default)]
- parent: Option,
- /// A header account: it groups others and takes no postings.
- #[serde(default)]
- header: bool,
- /// Its tax category, a key of the catalog (rules.md B-32).
- #[serde(default)]
- tax_category: Option,
-}
-
-impl AccountSpec {
- fn account_type(&self, id: &str) -> AccountType {
- match self.kind.as_str() {
- "asset" => AccountType::Asset,
- "liability" => AccountType::Liability,
- "equity" => AccountType::Equity,
- "revenue" => AccountType::Revenue,
- "expense" => AccountType::Expense,
- other => panic!("{id}: account type {other:?}"),
- }
+/// The `AccountType` an example's account type names.
+fn account_type(a: &AccountSpec, id: &str) -> AccountType {
+ match a.kind.as_str() {
+ "asset" => AccountType::Asset,
+ "liability" => AccountType::Liability,
+ "equity" => AccountType::Equity,
+ "revenue" => AccountType::Revenue,
+ "expense" => AccountType::Expense,
+ other => panic!("{id}: account type {other:?}"),
}
}
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct FixedAsset {
- cost_account: String,
- accumulated_account: String,
- expense_account: String,
+/// The period `[from, to]` a period query covers, each date omitted
+/// resolved by the one rule (rules.md B-30) on `setup.today`; panics on
+/// `as_of`, on an omitted date without `setup.today`, and on a period the
+/// rule refuses.
+fn period(ex: &Example, x: &Expect) -> (NaiveDate, NaiveDate) {
+ let q = x.query();
+ resolved_q_period(ex, &q).unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, x.query))
}
-/// A fixed asset registered before the first event, named `id` by the
-/// events and queries that ask about it: straight line over its life from
-/// its in-service date (rules.md B-15).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct AssetSpec {
- id: String,
- description: String,
- cost: String,
- salvage: String,
- life_months: u32,
- in_service: String,
- /// `"straight_line"`, the one method the books have.
- method: String,
- cost_account: String,
- accumulated_account: String,
- expense_account: String,
- /// The event whose entry put it on the books (its purchase or a
- /// member's contribution), linked once that event is applied: its date
- /// is the asset's acquired date (rules.md B-15 rule 7).
- #[serde(default)]
- acquired_by: Option,
- /// How the company got it and the facts its tax figures follow from
- /// (rules.md B-25), recorded as it is registered.
- #[serde(default)]
- basis: Option,
+/// [`period`], or the rule's refusal.
+fn resolved_period(ex: &Example, x: &Expect) -> Result<(NaiveDate, NaiveDate), LedgerError> {
+ resolved_q_period(ex, &x.query())
}
-/// An asset's basis facts (rules.md B-25): `rule` `"purchase"`,
-/// `"carryover_723"` or `"personal_use_conversion"`; a contribution's
-/// `contributor_adjusted_basis` and `fmv_at_contribution`; its §704(c)
-/// method, if chosen (`"traditional"`, `"traditional_with_curative"`,
-/// `"remedial"`).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct BasisSpec {
- rule: String,
- #[serde(default)]
- contributor_adjusted_basis: Option,
- #[serde(default)]
- fmv_at_contribution: Option,
- #[serde(default)]
- sec704c_method: Option,
+/// A query's period, or the rule's refusal.
+fn resolved_q_period(ex: &Example, q: &Query) -> Result<(NaiveDate, NaiveDate), LedgerError> {
+ let id = &ex.id;
+ assert!(
+ q.as_of.is_none(),
+ "{id}: {} covers a period (from, to), not as_of",
+ q.name
+ );
+ let from = q.from.as_deref().map(|s| date(s, id));
+ let to = q.to.as_deref().map(|s| date(s, id));
+ // A `period` query asks the rule itself, both dates given or not.
+ if let (Some(from), Some(to)) = (from, to)
+ && q.name != "period"
+ {
+ assert!(from <= to, "{id}: {} from {from} after to {to}", q.name);
+ return Ok((from, to));
+ }
+ defaults(ex, &q.name).statement(from, to)
}
-/// A prepaid expense or deferred revenue recognized over `months` from
-/// `start` (rules.md B-16), named `id`: in `setup.schedules`, or registered
-/// by a `register_schedule` event.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct ScheduleSpec {
- id: String,
- /// `"prepaid_expense"` or `"deferred_revenue"`.
- kind: String,
- description: String,
- total: String,
- start: String,
- months: u32,
- /// `"daily"` or `"monthly_even"`.
- convention: String,
- balance_account: String,
- pl_account: String,
+/// The day an as-of query is made as of (omitted: `setup.today`, rules.md
+/// B-30); panics on `from` or `to`.
+fn day(ex: &Example, x: &Expect) -> NaiveDate {
+ let q = x.query();
+ resolved_q_day(ex, &q).unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, x.query))
}
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct MemberSpec {
- /// The member's name: a party's in `setup.parties` (with the member
- /// role) when the example asks about the member as a party.
- name: String,
- capital_account: String,
- /// Their share of income, an exact decimal fraction; every member has
- /// one or none does, and the shares sum to 1. A close to the members
- /// needs them.
- #[serde(default)]
- share: Option,
- /// Their distributions account (rules.md B-13), when they have one.
- #[serde(default)]
- distributions_account: Option,
-}
-
-/// A payee marked for an information return from the beginning (rules.md
-/// B-19): a vendor record, the party's of that name in `setup.parties`
-/// when there is one with the vendor role.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct PayeeSpec {
- name: String,
- form: String,
-}
-
-/// One party in the directory (rules.md B-26, B-35).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct PartySpec {
- name: String,
- /// Role keys (`member`, `vendor`, `customer`, `contractor`,
- /// `financial_institution`, ...); `vendor` and `customer` give the
- /// party its record (one of them at most).
- roles: Vec,
- /// The liabilities, by code, the company owes the party itself
- /// (rules.md B-35 item 6).
- #[serde(default)]
- creditor_of: Vec,
- /// Its TIN facts' last four digits (an SSN's).
- #[serde(default)]
- tin_last4: Option,
- /// Its W-9 facts' last four digits (a 1099 role's).
- #[serde(default)]
- w9_tin_last4: Option,
- /// The vault's copy of the TIN purged, the facts kept (needs
- /// `tin_last4`).
- #[serde(default)]
- tin_purged: bool,
-}
-
-/// A payee's 1099 decision from `effective` on (rules.md B-19, D-40).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct StatusChangeSpec {
- payee: String,
- effective: String,
- /// `"1099-NEC"`, `"1099-MISC"`, `"not_reportable"` (with `reason_kind`
- /// and `reason`) or `"undetermined"`.
- decision: String,
- #[serde(default)]
- reason_kind: Option,
- #[serde(default)]
- reason: Option,
-}
-
-/// A payment method (rules.md B-19).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct InstrumentSpec {
- label: String,
- /// `bank`, `business_card`, `member_card`, `member_cash_or_check` or
- /// `platform`.
- kind: String,
- /// The account it pays through, by code.
- account: String,
- #[serde(default)]
- last4: Option,
- /// The party it belongs to, by name (informational).
- #[serde(default)]
- owner: Option,
-}
-
-/// A party named on an entry, with its share: an `amount`, a `percent`,
-/// or neither (derived from the entry: rules.md B-35).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct ShareSpec {
- party: String,
- #[serde(default)]
- amount: Option,
- #[serde(default)]
- percent: Option,
-}
-
-/// One event: an entry (`lines`), a reversal (`reverses`), a close
-/// (`close`), a bill (`bill`), or a command that posts what is due
-/// (`run_depreciation`, `dispose_asset`, `run_schedules`) or registers a
-/// schedule (`register_schedule`), exactly one of them.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct Event {
- /// The day of an entry, a reversal, a bill or a disposal, the last day
- /// of a close; a command that dates its own entries takes none.
- #[serde(default)]
- date: Option,
- /// An entry's, a reversal's, a close's or a bill's; a command writes
- /// its own.
- #[serde(default)]
- description: Option,
- #[serde(default)]
- lines: Vec,
- reverses: Option,
- #[serde(default)]
- close: Option,
- #[serde(default)]
- bill: Option,
- #[serde(default)]
- invoice: Option,
- #[serde(default)]
- run_depreciation: Option,
- #[serde(default)]
- dispose_asset: Option,
- #[serde(default)]
- run_schedules: Option,
- #[serde(default)]
- register_schedule: Option,
- /// The engine refuses the event, for this reason in the words of
- /// [`refusal`]: it changes nothing, and the books go on without it.
- /// For a reversal or a command, which the books take whole or not at
- /// all.
- #[serde(default)]
- refused: Option,
- /// The payee (in `setup.payees`) the entry paid.
- #[serde(default)]
- payee: Option,
- /// The parties (in `setup.parties`) the entry is with, and their
- /// shares.
- #[serde(default)]
- parties: Vec,
- /// The payment method (an `setup.instruments` label) it was paid with.
- #[serde(default)]
- instrument: Option,
- /// An entry as an earlier version could write it and this version's
- /// commands refuse: a line by hand on a receivables or payables
- /// account (the control guard, rules.md B-38). Posted as replay posts
- /// it, since books that hold it must still read right.
- #[serde(default)]
- older: bool,
-}
-
-/// A depreciation run of `asset` for `period` ("YYYY-MM"): what is due
-/// through the month's end, dated then (rules.md B-15).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct RunSpec {
- asset: String,
- period: String,
-}
-
-/// The disposal of `asset` on the event's date for `proceeds`, received
-/// into `proceeds_account`, its gain or loss to `gain_loss_account`
-/// (rules.md B-15).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct DisposeSpec {
- asset: String,
- proceeds: String,
- proceeds_account: String,
- gain_loss_account: String,
-}
-
-/// Every schedule's months due through `through`, posted (rules.md B-16).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct RunSchedulesSpec {
- through: String,
-}
-
-/// A bill from `vendor`, dated and due on the event's date, for `amount`
-/// (one line, the event's description) or its `lines`, exactly one of
-/// them: Dr General Expenses (6999), Cr Accounts Payable (2000), each
-/// created with its fixed name when first needed, as the engine creates a
-/// control role's account (rules.md B-32); a line naming a project puts
-/// its cost on that project's expense line (B-37 item 8).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct BillSpec {
- vendor: String,
- #[serde(default)]
- amount: Option,
- #[serde(default)]
- lines: Vec,
-}
-
-/// An invoice to `customer` (a name; added when first named), dated and
-/// due on the event's date, for `amount` or its `lines`, exactly one of
-/// them: Dr Accounts Receivable (1200), Cr Revenue (4000), each created
-/// with its fixed name when first needed (B-32); a line naming a project
-/// puts its revenue on that project's revenue line (B-37 item 8).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct InvoiceSpec {
- customer: String,
- #[serde(default)]
- amount: Option,
- #[serde(default)]
- lines: Vec,
-}
-
-/// One line of an invoice or a bill: its amount, what it is (the event's
-/// description when left out) and the project (a `setup.projects` name)
-/// it belongs to.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct DocumentLineSpec {
- amount: String,
- #[serde(default)]
- description: Option,
- #[serde(default)]
- project: Option,
-}
-
-/// A change to the books that posts no entry, made right after event
-/// `after` (counting from 1) once the expectations asked after that event
-/// are checked: a rename (`rename`), the tax-category policy
-/// (`require_tax_category`) or an asset's correction (`correct_asset`),
-/// exactly one of them.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct ChangeSpec {
- after: usize,
- #[serde(default)]
- rename: Option,
- /// `policy.require_tax_category`: "warn" (the books' default) or
- /// "refuse" (rules.md B-32).
- #[serde(default)]
- require_tax_category: Option,
- #[serde(default)]
- correct_asset: Option,
-}
-
-/// A registered asset's economics corrected (a registration mistake), for
-/// `reason`: each figure given replaces the asset's; the rest stay.
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct CorrectSpec {
- asset: String,
- #[serde(default)]
- cost: Option,
- #[serde(default)]
- salvage: Option,
- #[serde(default)]
- life_months: Option,
- #[serde(default)]
- in_service: Option,
- reason: String,
-}
-
-/// Rename the account `code` to `name` (rules.md B-31).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct RenameSpec {
- code: String,
- name: String,
-}
-
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct LineSpec {
- account: String,
- side: String,
- amount: String,
- /// The project (a `setup.projects` name) the line is tagged with.
- #[serde(default)]
- project: Option,
-}
-
-/// A close of the period from `start` through the event's date, to
-/// `"members"` (by share, in the order listed) or `"retained_earnings"`;
-/// `rounding = "per-close"`: a members close split on its own (books from
-/// an engine that rounded each close by itself), else the cumulative rule
-/// (rules.md B-13).
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct CloseSpec {
- start: String,
- to: String,
- #[serde(default)]
- rounding: Option,
-}
-
-#[derive(Deserialize)]
-#[serde(deny_unknown_fields)]
-struct Expect {
- query: String,
- from: Option,
- to: Option,
- as_of: Option,
- /// The event whose entry an `entry` or `party_part` query asks about.
- entry: Option,
- /// The `run_depreciation` event a `run` query asks about.
- event: Option,
- /// The fixed asset (`setup.assets` id) a `disposal`,
- /// `depreciation_schedule`, `register_row` or `asset_basis` query asks
- /// about.
- asset: Option,
- /// The schedule (its id) a `schedule_plan` query asks about.
- schedule: Option,
- /// The party a `party_part`, `party_totals` or `party_tin_last4`
- /// query asks about (in `setup.parties`).
- party: Option,
- /// The project (in `setup.projects`) a `project` query asks about.
- #[serde(default)]
- project: Option,
- /// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
- basis: Option,
- /// The payee a `vendor_1099` query asks about (in `setup.payees`).
- payee: Option,
- /// The books after event N, not after the last.
- after: Option,
- /// The account a `rename_account` or `set_tax_category` command
- /// names, by code.
- code: Option,
- /// The new name a `rename_account` command gives.
- name: Option,
- /// The category a `set_tax_category` command sets (absent: it clears
- /// the account's).
- category: Option,
- /// The account an `add_account` command adds.
- account: Option,
- /// A command the engine accepts: `true` (never `false`: a command is
- /// accepted or `refused`).
- accepted: Option,
- /// A command, or a `period`, the engine refuses: why, in the words
- /// of [`refusal`].
- refused: Option,
- #[serde(default)]
- values: Option,
-}
-
-/// What a query expects: figures by key (every figure an exact decimal
-/// string), or, for a file format, its lines or its rows of fields.
-#[derive(Deserialize)]
-#[serde(untagged)]
-enum Values {
- Figures(BTreeMap