From b7be5a5472e3f3bb0be6744d0d817860a09ba84f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 26 Oct 2026 16:34:38 -0600 Subject: [PATCH] feat(bookspec): the checker crate, the Engine trait and the runner (P13) bookspec is a specification first: every rule normative and sourced, every rule demonstrated by concrete examples, and any accounting engine checked against them through one interface (D-34). This moves the examples' schema out of the test that happened to parse them and makes the interface explicit. - `bookspec/checker` (its own workspace, inside this repository until the harness is stable): `Example`, `Setup`, `Event`, `Expect`, `Query`, `Refusal` and the TOML parser, every structure `deny_unknown_fields`, with `Example::from_toml` checking the cross-field shape (closed lists, exact decimal amounts, dates, one event kind, `reverses` counting from 1, the shares summing to 1, commands accepted or refused and not both). - `Engine` (`load`, `apply`, `query`, `claims`) and `Refusal { rule, reason }`; the refusal's rule is the bookspec identifier, so a refusal names the rule it belongs to. `Claims` also carries the option an engine picks for a CHOICE rule. - `bookspec/runner` drives any `Engine` through the examples and reports per rule: pass, fail, not claimed. It compares the answer itself; an engine is never asked whether it agrees with itself. - `crates/ledger-core/tests/bookspec.rs` is the wanderbooks adapter over `Store`: the loader parses through the checker, its own checks are unchanged, and the new `the_runner_reports_every_claimed_rule_green` runs all 56 examples through the trait. The IIF appendix is answered through `ledger_package::export_iif`, the code its own test checks. - `just check` runs the bookspec workspace's fmt, clippy, rustdoc and tests too, so an example cannot rot (D-34, Z-17). The plan's trait has four methods and no path for an example's `[[changes]]` (a rename, the policy, an asset correction), which post no entry: `change` is the fifth, required so an engine cannot skip those examples silently. This is reported to the EM as the one departure. One money rule holds here too: an answer's figures are exact decimal strings, never JSON numbers, so no engine can round a cent through a float. --- .gitignore | 2 + Cargo.lock | 23 + bookspec/Cargo.lock | 704 ++++++++++ bookspec/Cargo.toml | 14 + bookspec/checker/Cargo.toml | 17 + bookspec/checker/src/engine.rs | 224 +++ bookspec/checker/src/lib.rs | 23 + bookspec/checker/src/rules.rs | 583 ++++++++ bookspec/checker/src/schema.rs | 1892 +++++++++++++++++++++++++ bookspec/checker/tests/rules.rs | 169 +++ bookspec/checker/tests/schema.rs | 296 ++++ bookspec/runner/Cargo.toml | 14 + bookspec/runner/src/lib.rs | 302 ++++ bookspec/runner/tests/runner.rs | 240 ++++ bookspec/spec/rules-index.md | 60 + crates/ledger-core/Cargo.toml | 4 + crates/ledger-core/tests/bookspec.rs | 1953 +++++++++++++++++--------- justfile | 4 + 18 files changed, 5822 insertions(+), 702 deletions(-) create mode 100644 bookspec/Cargo.lock create mode 100644 bookspec/Cargo.toml create mode 100644 bookspec/checker/Cargo.toml create mode 100644 bookspec/checker/src/engine.rs create mode 100644 bookspec/checker/src/lib.rs create mode 100644 bookspec/checker/src/rules.rs create mode 100644 bookspec/checker/src/schema.rs create mode 100644 bookspec/checker/tests/rules.rs create mode 100644 bookspec/checker/tests/schema.rs create mode 100644 bookspec/runner/Cargo.toml create mode 100644 bookspec/runner/src/lib.rs create mode 100644 bookspec/runner/tests/runner.rs create mode 100644 bookspec/spec/rules-index.md 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), - Lines(Vec), - Rows(Vec>), -} - -impl Values { - 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(), - } +/// A query's day, or the rule's refusal. +fn resolved_q_day(ex: &Example, q: &Query) -> Result { + let id = &ex.id; + assert!( + q.from.is_none() && q.to.is_none(), + "{id}: {} is as of a day (as_of), not a period", + q.name + ); + match q.as_of.as_deref() { + Some(s) => Ok(date(s, id)), + None => defaults(ex, &q.name).as_of(None).pipe(Ok), } } -impl Expect { - /// The values a query expects; panics when it gives none. - fn values(&self, id: &str) -> &Values { - self.values - .as_ref() - .unwrap_or_else(|| panic!("{id}: {} expects values", self.query)) - } - - /// The figures a figure query expects; panics on lines or rows. - fn figures(&self, id: &str) -> &BTreeMap { - match self.values(id) { - Values::Figures(f) => f, - _ => panic!("{id}: {} expects figures (a table)", self.query), - } - } - - /// 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(&self, ex: &Example) -> (NaiveDate, NaiveDate) { - self.resolved_period(ex) - .unwrap_or_else(|e| panic!("{}: {}: {e}", ex.id, self.query)) - } - - /// [`Self::period`], or the rule's refusal. - fn resolved_period(&self, ex: &Example) -> Result<(NaiveDate, NaiveDate), LedgerError> { - let id = &ex.id; - assert!( - self.as_of.is_none(), - "{id}: {} covers a period (from, to), not as_of", - self.query - ); - let from = self.from.as_deref().map(|s| date(s, id)); - let to = self.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) - && self.query != "period" - { - assert!(from <= to, "{id}: {} from {from} after to {to}", self.query); - return Ok((from, to)); - } - defaults(ex, &self.query).statement(from, to) - } - - /// The day an as-of query is made as of (omitted: `setup.today`, - /// rules.md B-30); panics on `from` or `to`. - fn day(&self, ex: &Example) -> NaiveDate { - let id = &ex.id; - assert!( - self.from.is_none() && self.to.is_none(), - "{id}: {} is as of a day (as_of), not a period", - self.query - ); - match self.as_of.as_deref() { - Some(s) => date(s, id), - None => defaults(ex, &self.query).as_of(None), - } +/// Rust has no pipe; this keeps the day rule a single expression. +trait Pipe: Sized { + fn pipe(self, f: impl FnOnce(Self) -> T) -> T { + f(self) } } - +impl Pipe for T {} /// What an omitted date means for `ex` (rules.md B-30): its fiscal year /// on `setup.today`, which `query` needs. fn defaults(ex: &Example, query: &str) -> PeriodDefaults { @@ -962,14 +437,41 @@ impl<'a> Books<'a> { /// The books an example sets up, before any event: its shape checked, /// its accounts, fixed assets, schedules, parties, members, payees, /// their dated decisions, and payment methods. - fn new(ex: &'a Example) -> Self { - let s = &ex.setup; - let currency = Currency::new(&s.currency).unwrap(); + fn blank(ex: &'a Example) -> Self { + let currency = Currency::new(&ex.setup.currency).unwrap(); let dir = tempfile::tempdir().unwrap(); - let mut store = took( + let store = took( Store::create(&dir.path().join("books"), currency), &format!("{}: the books", ex.id), ); + Books { + ex, + store, + _dir: dir, + currency, + members: Vec::new(), + party_members: Vec::new(), + parties: BTreeMap::new(), + payees: BTreeMap::new(), + vendors: BTreeMap::new(), + customers: BTreeMap::new(), + instruments: BTreeMap::new(), + assets: BTreeMap::new(), + projects: BTreeMap::new(), + schedules: BTreeMap::new(), + posted: Vec::new(), + runs: BTreeMap::new(), + disposals: BTreeMap::new(), + } + } + + /// The books an example sets up, before any event: its shape checked, + /// its accounts, fixed assets, schedules, parties, members, payees, + /// their dated decisions, and payment methods. + fn setup(&mut self) { + let ex = self.ex; + let s = &ex.setup; + let currency = self.currency; assert!( ex.appendix.as_deref().is_none_or(|a| a == "formats"), "{}: appendix {:?}", @@ -1016,13 +518,14 @@ impl<'a> Books<'a> { for a in &s.accounts { let what = format!("{}: account {}", ex.id, a.code); // A parent is declared before its subaccounts. - let parent = a.parent.as_deref().map(|p| id(store.ledger(), p)); + let parent = a.parent.as_deref().map(|p| id(self.store.ledger(), p)); let account = took( - store.add_account(&a.code, &a.name, a.account_type(&ex.id), parent, a.header), + self.store + .add_account(&a.code, &a.name, account_type(a, &ex.id), parent, a.header), &what, ); if let Some(c) = &a.tax_category { - took(store.set_account_tax(account, Some(c), None), &what); + took(self.store.set_account_tax(account, Some(c), None), &what); } } let first = ex @@ -1037,12 +540,12 @@ impl<'a> Books<'a> { cost: Money::new(Decimal::ONE, currency), salvage_value: Money::new(Decimal::ZERO, currency), useful_life_months: 12, - cost_account: id(store.ledger(), &fa.cost_account), - accum_depr_account: id(store.ledger(), &fa.accumulated_account), - expense_account: id(store.ledger(), &fa.expense_account), + cost_account: id(self.store.ledger(), &fa.cost_account), + accum_depr_account: id(self.store.ledger(), &fa.accumulated_account), + expense_account: id(self.store.ledger(), &fa.expense_account), }; took( - store.register_asset(new), + self.store.register_asset(new), &format!("{}: fixed asset", ex.id), ); } @@ -1056,17 +559,17 @@ impl<'a> Books<'a> { cost: Money::new(exact(&a.cost, &what), currency), salvage_value: Money::new(exact(&a.salvage, &what), currency), useful_life_months: a.life_months, - cost_account: id(store.ledger(), &a.cost_account), - accum_depr_account: id(store.ledger(), &a.accumulated_account), - expense_account: id(store.ledger(), &a.expense_account), + cost_account: id(self.store.ledger(), &a.cost_account), + accum_depr_account: id(self.store.ledger(), &a.accumulated_account), + expense_account: id(self.store.ledger(), &a.expense_account), }; - let asset = took(store.register_asset(new), &what); + let asset = took(self.store.register_asset(new), &what); assert!( assets.insert(a.id.clone(), asset).is_none(), "{what}: id used twice" ); if let Some(b) = &a.basis { - took(store.set_asset_basis(asset, basis(b, &what)), &what); + took(self.store.set_asset_basis(asset, basis(b, &what)), &what); } assert!( a.acquired_by.is_none_or(|n| n >= 1 && n <= ex.events.len()), @@ -1078,8 +581,8 @@ impl<'a> Books<'a> { let mut schedules = BTreeMap::new(); for sc in &s.schedules { let what = format!("{}: schedule {}", ex.id, sc.id); - let new = new_schedule(store.ledger(), sc, currency, &what); - let schedule = took(store.register_schedule(new), &what); + let new = new_schedule(self.store.ledger(), sc, currency, &what); + let schedule = took(self.store.register_schedule(new), &what); assert!( schedules.insert(sc.id.clone(), schedule).is_none(), "{what}: id used twice" @@ -1096,11 +599,11 @@ impl<'a> Books<'a> { .iter() .map(|m| MemberCapital { name: m.name.clone(), - capital_account: id(store.ledger(), &m.capital_account), + capital_account: id(self.store.ledger(), &m.capital_account), distribution_account: m .distributions_account .as_deref() - .map(|c| id(store.ledger(), c)), + .map(|c| id(self.store.ledger(), c)), share: m.share.as_deref().map_or(Decimal::ZERO, |x| { let share = exact(x, &format!("{} {} share", ex.id, m.name)); assert!(share > Decimal::ZERO, "{}: {}'s share", ex.id, m.name); @@ -1112,13 +615,13 @@ impl<'a> Books<'a> { let total: Decimal = members.iter().map(|m| m.share).sum(); assert_eq!(total, Decimal::ONE, "{}: the shares sum to 1", ex.id); } - let parties = add_parties(ex, &mut store); + let parties = add_parties(ex, &mut self.store); // A member who is a party (by name, with the member role), as the // settings' members are (settings::Company::party_members). let party_members: Vec = members .iter() .filter_map(|m| { - let p = store.ledger().party_by_name(&m.name)?; + let p = self.store.ledger().party_by_name(&m.name)?; p.roles.contains(&Role::Member).then_some(PartyMember { party: p.id, share: m.share, @@ -1138,13 +641,13 @@ impl<'a> Books<'a> { // party's vendor record. let known = parties .get(&p.name) - .and_then(|&id| store.ledger().get_party(id)?.vendor); + .and_then(|&id| self.store.ledger().get_party(id)?.vendor); let v = match known { Some(v) => v, - None => took(store.add_vendor(&p.name), &what), + None => took(self.store.add_vendor(&p.name), &what), }; let dated = Dated1099::from_beginning(Decision1099::Files(form), Some(source.clone())); - took(store.set_vendor_1099(v, &dated, Some(false)), &what); + took(self.store.set_vendor_1099(v, &dated, Some(false)), &what); assert!( payees.insert(p.name.clone(), v).is_none(), "{}: payee {} twice", @@ -1167,7 +670,7 @@ impl<'a> Books<'a> { ), source: Some(source.clone()), }; - took(store.set_vendor_1099(v, &dated, Some(false)), &what); + took(self.store.set_vendor_1099(v, &dated, Some(false)), &what); } let mut projects = BTreeMap::new(); for p in &s.projects { @@ -1188,7 +691,7 @@ impl<'a> Books<'a> { }), ..Default::default() }; - let id = took(store.add_project(&p.name, &facts), &what); + let id = took(self.store.add_project(&p.name, &facts), &what); assert!( projects.insert(p.name.clone(), id).is_none(), "{what}: named twice" @@ -1200,14 +703,15 @@ impl<'a> Books<'a> { let kind = InstrumentKind::parse(&i.kind) .filter(|k| k.key() == i.kind) .unwrap_or_else(|| panic!("{what}: kind {:?}", i.kind)); - let account = id(store.ledger(), &i.account); + let account = id(self.store.ledger(), &i.account); let owner = i.owner.as_ref().map(|o| { *parties .get(o) .unwrap_or_else(|| panic!("{what}: owner {o:?} not in setup.parties")) }); let id = took( - store.add_instrument(kind, &i.label, i.last4.as_deref(), owner, account), + self.store + .add_instrument(kind, &i.label, i.last4.as_deref(), owner, account), &what, ); assert!( @@ -1215,25 +719,23 @@ impl<'a> Books<'a> { "{what}: twice" ); } - Books { - ex, - store, - _dir: dir, - currency, - members, - party_members, - parties, - vendors: payees.clone(), - customers: BTreeMap::new(), - payees, - instruments, - assets, - projects, - schedules, - posted: Vec::new(), - runs: BTreeMap::new(), - disposals: BTreeMap::new(), - } + self.members = members; + self.party_members = party_members; + self.parties = parties; + self.vendors = payees.clone(); + self.customers = BTreeMap::new(); + self.payees = payees; + self.instruments = instruments; + self.assets = assets; + self.projects = projects; + self.schedules = schedules; + } + + /// The books an example sets up, already loaded. + fn new(ex: &'a Example) -> Self { + let mut books = Self::blank(ex); + books.setup(); + books } /// The books as they stand. @@ -1277,9 +779,8 @@ impl<'a> Books<'a> { /// Event `n` (counting from 1): accepted, or refused for the reason it /// gives and changing nothing; the entries it posted kept as event /// n's, tagged with the parties, payee and payment method it names. - fn apply(&mut self, n: usize, e: &Event) { - let spec = self.ex; - let ex = spec.id.as_str(); + /// Event `n`'s kind; panics on a shape the schema refuses. + fn event_kind(&self, n: usize, e: &Event) -> &'static str { let kinds = [ ("lines", !e.lines.is_empty()), ("reverses", e.reverses.is_some()), @@ -1291,12 +792,19 @@ impl<'a> Books<'a> { ("run_schedules", e.run_schedules.is_some()), ("register_schedule", e.register_schedule.is_some()), ]; - let kind = match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { - [(kind, _)] => *kind, + match kinds.iter().filter(|(_, given)| *given).collect::>()[..] { + [(kind, _)] => kind, _ => panic!( - "{ex} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule" + "{} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule", + self.ex.id ), - }; + } + } + + /// The shape the event must have, checked before it is taken: what the + /// schema says and what the loader's own words say. + fn check_event_shape(&self, n: usize, e: &Event, kind: &str) { + let ex = self.ex.id.as_str(); // An entry of the example's own words: dated and described. A // disposal is dated; the other commands date their own entries, // and every command describes its own. @@ -1341,40 +849,26 @@ impl<'a> Books<'a> { e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"), "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" ); - let what = format!("{ex} event {n}"); + } + + /// Take event `n` as the books take it, keep what it posted and tag it + /// with the parties, payee and payment method the example names; the + /// engine's answer, never compared with what the example expects. + fn apply_event(&mut self, n: usize, e: &Event) -> Result<(), StoreError> { + let kind = self.event_kind(n, e); + self.check_event_shape(n, e, kind); + let what = format!("{} event {n}", self.ex.id); let when = e.date.as_deref().map(|d| date(d, &what)); let description = e.description.as_deref().unwrap_or_default(); - let head = self.store.head(); let first = self.l().next_entry_id(); let result = self.take(n, e, kind, when, description, &what); - match (result, &e.refused) { - (Ok(()), None) => {} - (Err(err), Some(want)) => { - assert_eq!(store_refusal(&err), *want, "{what}: {kind} refused ({err})"); - assert_eq!( - self.store.head(), - head, - "{what}: a refused {kind} changes nothing" - ); - } - (Err(err), None) => panic!("{what}: {kind}: {err}"), - (Ok(()), Some(want)) => panic!("{what}: {kind} accepted, expected refused: {want}"), - } let next = self.l().next_entry_id(); let posted: Vec = (first..next).collect(); - for &id in &posted { - assert_eq!( - self.l().get_entry(id).map(|x| x.status()), - Some(EntryStatus::Posted), - "{what}: entry {id}, which it made, is posted" - ); - } - if own && e.refused.is_none() { - assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); - } self.posted.push(posted); + result?; // The assets this event's entry put on the books. - for a in spec + for a in self + .ex .setup .assets .iter() @@ -1396,8 +890,8 @@ impl<'a> Books<'a> { .unwrap_or_else(|| panic!("{what}: party {:?} not in setup", sh.party)); let share = match (&sh.amount, &sh.percent) { (None, None) => Share::Derived, - (Some(a), None) => Share::Amount(exact(a, ex)), - (None, Some(p)) => Share::Percent(exact(p, ex)), + (Some(a), None) => Share::Amount(exact(a, &self.ex.id)), + (None, Some(p)) => Share::Percent(exact(p, &self.ex.id)), _ => panic!("{what}: a share is an amount or a percent"), }; PartyShare { party, share } @@ -1423,6 +917,49 @@ impl<'a> Books<'a> { let entry = self.entry_of(n, &what); took(self.store.set_entry_instrument(entry, Some(i)), &what); } + Ok(()) + } + + /// Apply event `n` as the example says and check it: accepted, or + /// refused for the reason it gives and changing nothing. + fn apply(&mut self, n: usize, e: &Event) { + let ex = self.ex.id.as_str(); + let kind = self.event_kind(n, e); + let what = format!("{ex} event {n}"); + let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice"); + let head = self.store.head(); + let first = self.l().next_entry_id(); + let result = self.apply_event(n, e); + match (result, &e.refused) { + (Ok(()), None) => {} + (Err(err), Some(want)) => { + assert_eq!(store_refusal(&err), *want, "{what}: {kind} refused ({err})"); + assert_eq!( + self.store.head(), + head, + "{what}: a refused {kind} changes nothing" + ); + } + (Err(err), None) => panic!("{what}: {kind}: {err}"), + (Ok(()), Some(want)) => panic!("{what}: {kind} accepted, expected refused: {want}"), + } + let next = self.l().next_entry_id(); + let posted: Vec = (first..next).collect(); + assert_eq!( + &posted, + &self.posted[n - 1], + "{what}: what the event posted is kept" + ); + for &id in &posted { + assert_eq!( + self.l().get_entry(id).map(|x| x.status()), + Some(EntryStatus::Posted), + "{what}: entry {id}, which it made, is posted" + ); + } + if own && e.refused.is_none() { + assert_eq!(posted.len(), 1, "{what}: {kind} posts one entry"); + } } /// An invoice's or bill's lines: `amount` (one line, described as the @@ -1643,14 +1180,19 @@ impl<'a> Books<'a> { impl Books<'_> { /// Make a change that posts no entry (after event `after`). - fn change(&mut self, c: &ChangeSpec) { - let ex = &self.ex.id; + /// Make a change that posts no entry (after event `after`), or say why + /// the books refuse it. + fn try_change(&mut self, c: &ChangeSpec) -> Result<(), Refusal> { + let ex = &self.ex.id.clone(); let after = c.after; let what = format!("{ex} change after {after}"); match (&c.rename, &c.require_tax_category, &c.correct_asset) { (Some(r), None, None) => { let account = self.id(&r.code); - took(self.store.rename_account(account, &r.name, None), &what); + self.store + .rename_account(account, &r.name, None) + .map(|_| ()) + .map_err(|e| store_refusal_obj(&e)) } (None, None, Some(f)) => { let id = self.asset(&f.asset, &what); @@ -1668,7 +1210,9 @@ impl Books<'_> { .as_deref() .map_or(a.in_service_date, |d| date(d, &what)), }; - took(self.store.correct_asset(id, fix, &f.reason), &what); + self.store + .correct_asset(id, fix, &f.reason) + .map_err(|e| store_refusal_obj(&e)) } (None, Some(policy), None) => { assert!( @@ -1680,14 +1224,978 @@ impl Books<'_> { key: "policy.require_tax_category".into(), value: Some(serde_json::json!(policy)), }; - took( - self.store.change_settings(&[change], &bookspec_source(ex)), - &what, - ); + self.store + .change_settings(&[change], &bookspec_source(ex)) + .map(|_| ()) + .map_err(|e| store_refusal_obj(&e)) } _ => panic!("{what}: a change is one of rename, require_tax_category or correct_asset"), } } + + /// Make a change that posts no entry, checked: the books must take it. + fn change(&mut self, c: &ChangeSpec) { + let after = c.after; + let what = format!("{} change after {after}", self.ex.id); + self.try_change(c) + .unwrap_or_else(|r| panic!("{what}: {}", r.reason)); + } +} + +/// The refusal a store error is reported as: the examples' words when the +/// engine's reason is one of them, the engine's own otherwise. +fn store_refusal_obj(e: &StoreError) -> Refusal { + match e { + StoreError::Ledger(e) => Refusal::of_reason(refusal(e)), + other => Refusal::of_reason(format!("another refusal: {other}")), + } +} + +/// The worker's refusal for an engine error the examples' words don't +/// name. +fn engine_refusal(rule: RuleId, e: impl std::fmt::Display) -> Refusal { + Refusal::new(rule, format!("another refusal: {e}")) +} + +impl Books<'_> { + /// A refusal of a question the example did not fill in. + fn missing(&self, what: &str) -> Refusal { + Refusal::new( + RuleId::new(Area::Ledger, 1), + format!("{} names its {what}", "the query"), + ) + } + + /// The period a query covers, or the rule's refusal. + fn q_period(&self, q: &Query) -> Result<(NaiveDate, NaiveDate), Refusal> { + resolved_q_period(self.ex, q).map_err(|e| Refusal::of_reason(refusal(&e))) + } + + /// The day an as-of query is made as of. + fn q_day(&self, q: &Query) -> Result { + resolved_q_day(self.ex, q).map_err(|e| Refusal::of_reason(refusal(&e))) + } + + /// The event whose entry `entry` is, counting from 1. + fn event_of(&self, entry: EntryId) -> Option { + self.posted + .iter() + .position(|entries| entries.contains(&entry)) + .map(|i| i + 1) + } + + /// The entry event `n` posted. + fn entry_or_refuse(&self, n: usize) -> Result { + assert!(n >= 1 && n <= self.posted.len(), "event {n} not applied"); + match self.posted[n - 1].as_slice() { + [entry] => Ok(*entry), + other => Err(Refusal::new( + RuleId::new(Area::Ledger, 1), + format!("event {n} posted {} entries", other.len()), + )), + } + } + + /// The account `code` names. + fn account_or_refuse(&self, code: &str) -> Result { + self.l() + .chart() + .find_by_code(code) + .map(|a| a.id) + .ok_or_else(|| Refusal::new(RuleId::new(Area::Ledger, 1), format!("no account {code}"))) + } + + /// The payment accounts the example names. + fn payment_accounts(&self) -> Result { + let ex = &self.ex; + Ok(PaymentAccounts { + cash: codes(self.l(), &ex.setup.cash_accounts, &ex.id), + financing: codes(self.l(), &ex.setup.financing_accounts, &ex.id), + }) + } + + /// The engine's answer to one query, as the runner compares it: exact + /// decimal strings, never floats; dates `YYYY-MM-DD`. + fn answer(&self, q: &Query) -> Result { + match q.name.as_str() { + "cash_flow" => self.answer_cash_flow(q), + "trial_balance" => self.answer_trial_balance(q), + "profit_and_loss" => self.answer_profit_and_loss(q), + "entry" => self.answer_entry(q), + "balances" => self.answer_balances(q), + "members_equity" => self.answer_members_equity(q), + "k1" => self.answer_k1(q), + "k1_tax_basis" => self.answer_k1_tax_basis(q), + "k1_reconciliation" => self.answer_k1_reconciliation(q), + "vendor_1099_paid" => self.answer_vendor_1099_paid(q), + "vendor_1099" => self.answer_vendor_1099(q), + "party_part" => self.answer_party_part(q), + "party_totals" => self.answer_party_totals(q), + "party_tin_last4" => self.answer_party_tin_last4(q), + "project" => self.answer_project(q), + "run" => self.answer_run(q), + "disposal" => self.answer_disposal(q), + "depreciation_schedule" => self.answer_depreciation_schedule(q), + "schedule_plan" => self.answer_schedule_plan(q), + "register_row" => self.answer_register_row(q), + "register_totals" => self.answer_register_totals(q), + "asset_basis" => self.answer_asset_basis(q), + "period" => { + let (from, to) = self.q_period(q)?; + Ok(json_figures([ + ("from".to_string(), from.to_string()), + ("to".to_string(), to.to_string()), + ])) + } + "day" => Ok(json_figures([( + "as_of".to_string(), + self.q_day(q)?.to_string(), + )])), + "calendar_year" => Ok(json_figures([( + "year".to_string(), + defaults(self.ex, &q.name).calendar_year().to_string(), + )])), + "account_names" => Ok(json_figures( + self.l() + .chart() + .all() + .map(|a| (a.code.clone(), a.name.clone())), + )), + "uncategorized_accounts" => Ok(Value::Array( + ledger_core::tax::uncategorized_accounts(self.l()) + .into_iter() + .map(|a| Value::String(format!("{} {}", a.code, a.name))) + .collect(), + )), + "iif_lines" | "iif_changes" => self.answer_iif(q), + "rename_account" | "add_account" | "set_tax_category" => self.answer_command(q), + other => Err(Refusal::new( + RuleId::new(Area::Ledger, 1), + format!("no answer for query {other:?}"), + )), + } + } + + fn answer_cash_flow(&self, q: &Query) -> Result { + let (start, end) = self.q_period(q)?; + let l = self.l(); + let params = CashFlowParams { + start, + end, + cash_accounts: codes(l, &self.ex.setup.cash_accounts, &self.ex.id), + financing_accounts: codes(l, &self.ex.setup.financing_accounts, &self.ex.id), + investing_accounts: codes(l, &self.ex.setup.investing_accounts, &self.ex.id), + members: self.members.clone(), + }; + let cf = cash_flow(l, ¶ms) + .map_err(|e| engine_refusal(RuleId::new(Area::Statement, 3), e))?; + let mut m = JsonMap::new(); + let put = |m: &mut JsonMap, key: &str, d: Decimal| { + m.insert(key.to_string(), Value::String(figure(d))); + }; + put(&mut m, "net_change", cf.net_change_in_cash); + put(&mut m, "cash_beginning", cf.cash_beginning); + put(&mut m, "cash_ending", cf.cash_ending); + put(&mut m, "difference", cf.reconciles.difference); + put(&mut m, "net_income", cf.operating_reconciliation.net_income); + m.insert( + "noncash.count".to_string(), + Value::String(cf.noncash_disclosures.len().to_string()), + ); + for (name, section) in [ + ("operating", CashFlowSection::Operating), + ("investing", CashFlowSection::Investing), + ("financing", CashFlowSection::Financing), + ] { + put(&mut m, name, cf.total(section)); + let lines = cf.lines(section); + let entries: BTreeSet = lines.iter().map(|l| l.entry).collect(); + m.insert( + format!("{name}.count"), + Value::String(entries.len().to_string()), + ); + for l in lines { + let Some(n) = self.event_of(l.entry) else { + continue; + }; + let key = format!("{name}.{n}"); + let amount = m + .get(&key) + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + m.insert(key, Value::String(figure(amount + l.amount))); + let key = format!("{name}.{n}.on_behalf"); + let amount = m + .get(&key) + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + m.insert(key, Value::String(figure(amount + l.on_behalf))); + } + } + for d in &cf.noncash_disclosures { + if let Some(n) = self.event_of(d.entry) { + let key = format!("noncash.{n}"); + let amount = m + .get(&key) + .and_then(Value::as_str) + .and_then(|s| s.parse::().ok()) + .unwrap_or_default(); + m.insert(key, Value::String(figure(amount + d.amount))); + } + } + for (class, amount) in cf.operating_by_class() { + let letter = ["a", "b", "c", "d", "e", "f", "g"] + .into_iter() + .find(|l| operating_class(l) == Some(class)) + .expect("a class has its letter"); + put(&mut m, &format!("class.{letter}"), amount); + } + for line in &cf.operating_reconciliation.lines { + put(&mut m, &reconciliation_key(line), line.amount); + } + Ok(Value::Object(m)) + } + + fn answer_trial_balance(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let tb = trial_balance_as_of(self.l(), as_of); + if !tb.balanced() { + return Err(Refusal::new( + RuleId::new(Area::Statement, 1), + "the trial balance balances", + )); + } + Ok(json_figures(self.ex.setup.accounts.iter().map(|a| { + let (dr, cr) = tb + .lines + .iter() + .find(|t| t.code == a.code) + .map_or((Decimal::ZERO, Decimal::ZERO), |t| (t.debit, t.credit)); + (a.code.clone(), figure(dr - cr)) + }))) + } + + fn answer_profit_and_loss(&self, q: &Query) -> Result { + let (start, end) = self.q_period(q)?; + let pl = profit_and_loss_for_period(self.l(), start, end, None) + .map_err(|e| engine_refusal(RuleId::new(Area::Statement, 3), e))?; + let mut m = JsonMap::new(); + for a in self + .ex + .setup + .accounts + .iter() + .filter(|a| matches!(a.kind.as_str(), "revenue" | "expense")) + { + let amount: Decimal = pl + .revenue + .iter() + .chain(&pl.expenses) + .filter(|s| s.code == a.code) + .map(|s| s.amount) + .sum(); + m.insert(a.code.clone(), Value::String(figure(amount))); + } + m.insert( + "net_income".to_string(), + Value::String(figure(pl.net_income())), + ); + Ok(Value::Object(m)) + } + + fn answer_entry(&self, q: &Query) -> Result { + let n = q.entry.ok_or_else(|| self.missing("entry"))?; + let entry = self + .l() + .get_entry(self.entry_or_refuse(n)?) + .expect("an entry an event posted"); + let mut got: BTreeMap = BTreeMap::new(); + for line in entry.lines() { + let code = &self.l().chart().get(line.account).unwrap().code; + let side = match line.side { + Side::Debit => "debit", + Side::Credit => "credit", + }; + *got.entry(format!("{code}.{side}")).or_default() += line.amount.amount; + } + Ok(json_figures(got.into_iter().map(|(k, v)| (k, figure(v))))) + } + + fn answer_balances(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let tb = trial_balance_as_of(self.l(), as_of); + let mut m = JsonMap::new(); + for account in self.l().chart().all() { + let (dr, cr) = tb + .lines + .iter() + .find(|t| t.code == account.code) + .map_or((Decimal::ZERO, Decimal::ZERO), |t| (t.debit, t.credit)); + let got = match account.account_type.normal_side() { + Side::Debit => dr - cr, + Side::Credit => cr - dr, + }; + m.insert(account.code.clone(), Value::String(figure(got))); + } + Ok(Value::Object(m)) + } + + fn answer_members_equity(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let st = members_equity(self.l(), from, to, &self.members) + .map_err(|e| engine_refusal(RuleId::new(Area::Partner, 1), e))?; + let mut m = JsonMap::new(); + for member in &st.members { + for (column, v) in [ + ("beginning", member.beginning), + ("contributions", member.contributions), + ("net_income", member.net_income), + ("distributions", member.distributions), + ("other", member.other), + ("ending", member.ending), + ] { + m.insert( + format!("{}.{column}", member.name), + Value::String(figure(v)), + ); + } + } + Ok(Value::Object(m)) + } + + fn answer_k1(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let cash = codes(self.l(), &self.ex.setup.cash_accounts, &self.ex.id); + let rows = ledger_core::k1::k1_worksheet(self.l(), from, to, &self.members, &cash) + .map_err(|e| engine_refusal(RuleId::new(Area::Partner, 1), e))?; + let mut m = JsonMap::new(); + for r in &rows { + for (column, v) in [ + ("beginning", r.beginning), + ("contributions_cash", r.contributions_cash), + ("contributions_property", r.contributions_property), + ("net_income", r.net_income), + ("distributions", r.distributions), + ("other", r.other), + ("ending", r.ending), + ] { + m.insert(format!("{}.{column}", r.name), Value::String(figure(v))); + } + } + Ok(Value::Object(m)) + } + + fn tax_rows(&self, from: NaiveDate, to: NaiveDate) -> Result, Refusal> { + let cash = codes(self.l(), &self.ex.setup.cash_accounts, &self.ex.id); + ledger_core::tax_capital::tax_basis_capital(self.l(), from, to, &self.members, &cash) + .map(|r| r.rows) + .map_err(|e| engine_refusal(RuleId::new(Area::Partner, 4), e)) + } + + fn answer_k1_tax_basis(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let mut m = JsonMap::new(); + for row in self.tax_rows(from, to)? { + let name = &row.book.name; + m.insert( + format!("{name}.contributions_cash"), + Value::String(figure(row.contributions_cash)), + ); + for (line, tax) in [ + ("beginning", &row.beginning), + ("contributions_property", &row.contributions_property), + ("net_income", &row.net_income), + ("other", &row.other), + ("distributions", &row.distributions), + ("ending", &row.ending), + ] { + m.insert( + format!("{name}.{line}"), + Value::String(tax.tax().map_or("open".to_string(), figure)), + ); + m.insert( + format!("{name}.known_{line}"), + Value::String(figure(tax.known)), + ); + } + } + Ok(Value::Object(m)) + } + + fn answer_k1_reconciliation(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let mut m = JsonMap::new(); + for row in self.tax_rows(from, to)? { + let r = &row.reconciliation; + let total = r.total().ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 4), + "the reconciliation's total is too large", + ) + })?; + for (cause, v) in RECONCILING_CAUSES.into_iter().zip([ + r.beginning, + r.built_in_gain_704c, + r.property_without_basis, + r.property_distributed, + total, + ]) { + m.insert( + format!("{}.{cause}", row.book.name), + Value::String(figure(v)), + ); + } + } + Ok(Value::Object(m)) + } + + fn answer_vendor_1099_paid(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let year = chrono::Datelike::year(&to); + let l = self.l(); + let sources = self.payment_accounts()?; + let r = vendor_1099_report(l, year, None, &sources, &self.party_members, Some(to)) + .map_err(|e| engine_refusal(RuleId::new(Area::Tax1099, 1), e))?; + let _ = from; + let mut m = JsonMap::new(); + for (name, vendor) in &self.payees { + let paid: Decimal = r + .vendors + .iter() + .filter(|v| v.vendor_id == *vendor) + .map(|v| v.paid) + .sum(); + m.insert(name.clone(), Value::String(figure(paid))); + } + Ok(Value::Object(m)) + } + + fn answer_vendor_1099(&self, q: &Query) -> Result { + let (_, to) = self.q_period(q)?; + let year = chrono::Datelike::year(&to); + let name = q.payee.as_deref().ok_or_else(|| self.missing("payee"))?; + let v = *self.payees.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Tax1099, 1), + format!("payee {name:?} not in setup"), + ) + })?; + let sources = self.payment_accounts()?; + let r = vendor_1099_report( + self.l(), + year, + None, + &sources, + &self.party_members, + Some(to), + ) + .map_err(|e| engine_refusal(RuleId::new(Area::Tax1099, 1), e))?; + let line = r.vendors.iter().find(|l| l.vendor_id == v).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Tax1099, 1), + format!("the 1099 worksheet has no line for {name}"), + ) + })?; + Ok(json_figures([ + ("status".to_string(), line.status.key().to_string()), + ("threshold".to_string(), figure(r.threshold)), + ("paid".to_string(), figure(line.paid)), + ("excluded_card".to_string(), figure(line.excluded_card)), + ("undetermined".to_string(), figure(line.undetermined)), + ("not_reportable".to_string(), figure(line.not_reportable)), + ])) + } + + fn answer_party_part(&self, q: &Query) -> Result { + let event = q.entry.ok_or_else(|| self.missing("entry"))?; + let n = self.entry_or_refuse(event)?; + let name = q.party.as_deref().ok_or_else(|| self.missing("party"))?; + let party = *self.parties.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 3), + format!("party {name:?} not in setup"), + ) + })?; + let ex = &self.ex; + let r = ledger_core::shares::resolve( + self.l(), + n, + self.l().entry_parties(n), + &party_money(ex, self.l()), + &self.party_members, + ) + .map_err(|why| { + Refusal::new( + RuleId::new(Area::Partner, 3), + format!("entry {n}'s shares: {why}"), + ) + })?; + let part = r.part(party).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 3), + format!("entry {n} doesn't name {name:?}"), + ) + })?; + Ok(json_figures([ + ("resolved".to_string(), figure(part.amount)), + ( + "direction".to_string(), + r.target.direction.key().to_string(), + ), + ("cash".to_string(), figure(part.cash)), + ("in_kind".to_string(), figure(part.in_kind)), + ])) + } + + fn answer_party_totals(&self, q: &Query) -> Result { + let (from, to) = self.q_period(q)?; + let basis = q + .basis + .as_deref() + .ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 3), + "party_totals names its basis", + ) + }) + .and_then(|k| { + Basis::parse(k).filter(|b| b.key() == k).ok_or_else(|| { + Refusal::new(RuleId::new(Area::Partner, 3), format!("basis {k:?}")) + }) + })?; + let name = q.party.as_deref().ok_or_else(|| self.missing("party"))?; + let party = *self.parties.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 3), + format!("party {name:?} not in setup"), + ) + })?; + let ex = &self.ex; + let a = ledger_core::parties::activity_with( + self.l(), + party, + (from, to), + &party_money(ex, self.l()), + &self.party_members, + ); + if !a.unresolved.is_empty() { + return Err(Refusal::new( + RuleId::new(Area::Partner, 3), + format!("{name:?}'s entries don't resolve"), + )); + } + let (paid, received) = a.totals(basis); + Ok(json_figures([ + ("paid".to_string(), figure(paid)), + ("received".to_string(), figure(received)), + ])) + } + + fn answer_party_tin_last4(&self, q: &Query) -> Result { + let name = q.party.as_deref().ok_or_else(|| self.missing("party"))?; + let party = *self.parties.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Partner, 2), + format!("party {name:?} not in setup"), + ) + })?; + let got = self + .l() + .get_party(party) + .and_then(|p| p.tin_last4()) + .unwrap_or_default(); + Ok(json_figures([("tin_last4".to_string(), got.to_string())])) + } + + fn answer_project(&self, q: &Query) -> Result { + let name = q + .project + .as_deref() + .ok_or_else(|| self.missing("project"))?; + let id = *self.projects.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Statement, 5), + format!("project {name:?} not in setup.projects"), + ) + })?; + let (from, to) = self.q_period(q)?; + let f = ledger_core::project_figures(self.l(), id, from, to) + .map_err(|e| engine_refusal(RuleId::new(Area::Statement, 5), e))?; + let opt = |d: Option| d.map_or("none".to_string(), figure); + Ok(json_figures([ + ("revenue".to_string(), figure(f.period.total_revenue)), + ("expenses".to_string(), figure(f.period.total_expenses)), + ("net".to_string(), figure(f.period.net_income())), + ("to_date.revenue".to_string(), figure(f.to_date.revenue)), + ("to_date.expenses".to_string(), figure(f.to_date.expenses)), + ("to_date.net".to_string(), figure(f.to_date.net())), + ( + "unrecovered_cost".to_string(), + opt(f.to_date.unrecovered_cost), + ), + ])) + } + + fn answer_run(&self, q: &Query) -> Result { + let n = q.event.ok_or_else(|| self.missing("event"))?; + let run = self.runs.get(&n).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Asset, 1), + format!("event {n} is no run_depreciation (or not yet run)"), + ) + })?; + let posted = if run.already_posted { + Decimal::ZERO + } else { + run.amount + }; + let caught_up = (posted - run.scheduled).max(Decimal::ZERO); + Ok(json_figures([ + ("amount".to_string(), figure(posted)), + ("caught_up".to_string(), figure(caught_up)), + ])) + } + + fn answer_disposal(&self, q: &Query) -> Result { + let name = q.asset.as_deref().ok_or_else(|| self.missing("asset"))?; + let p = self.disposals.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Asset, 1), + format!("asset {name:?} wasn't disposed of"), + ) + })?; + Ok(json_figures([ + ( + "final_depreciation".to_string(), + figure(p.final_depreciation), + ), + ( + "catch_up_depreciation".to_string(), + figure(p.catch_up_depreciation), + ), + ( + "excess_depreciation".to_string(), + figure(p.excess_depreciation), + ), + ("accumulated".to_string(), figure(p.accumulated)), + ("book_value".to_string(), figure(p.book_value)), + ("gain_loss".to_string(), figure(p.gain_loss)), + ])) + } + + fn answer_depreciation_schedule(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let name = q.asset.as_deref().ok_or_else(|| self.missing("asset"))?; + let asset = self + .l() + .get_asset(self.asset(name, name)) + .expect("a registered asset"); + let lines = ledger_core::depreciation_schedule(asset, as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 1), e))?; + let mut m = JsonMap::new(); + for l in &lines { + m.insert(l.period.clone(), Value::String(figure(l.amount))); + } + // A month the schedule doesn't reach is 0.00: from acquisition + // (never later than in-service) through the day asked as of. + let start = self + .l() + .asset_acquired_date(asset.id) + .unwrap_or(asset.in_service_date); + let (mut year, mut month) = (start.year(), start.month()); + while (year, month) <= (as_of.year(), as_of.month()) { + m.entry(format!("{year:04}-{month:02}")) + .or_insert_with(|| Value::String("0.00".to_string())); + if month == 12 { + year += 1; + month = 1; + } else { + month += 1; + } + } + let accumulated = ledger_core::accumulated_scheduled_through(asset, as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 1), e))?; + let book_value = ledger_core::scheduled_book_value(asset, as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 1), e))?; + m.insert( + "accumulated".to_string(), + Value::String(figure(accumulated)), + ); + m.insert("book_value".to_string(), Value::String(figure(book_value))); + Ok(Value::Object(m)) + } + + fn answer_schedule_plan(&self, q: &Query) -> Result { + let name = q + .schedule + .as_deref() + .ok_or_else(|| self.missing("schedule"))?; + let id = *self.schedules.get(name).ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Prepaid, 1), + format!("no schedule {name:?}"), + ) + })?; + let plan = self + .l() + .get_schedule(id) + .expect("a registered schedule") + .plan() + .map_err(|e| engine_refusal(RuleId::new(Area::Prepaid, 1), e))?; + let mut m = JsonMap::new(); + let mut total = Decimal::ZERO; + for (d, amount) in &plan { + m.insert(d.to_string(), Value::String(figure(*amount))); + total += *amount; + } + m.insert("total".to_string(), Value::String(figure(total))); + Ok(Value::Object(m)) + } + + fn answer_register_row(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let name = q.asset.as_deref().ok_or_else(|| self.missing("asset"))?; + let id = self.asset(name, name); + let r = ledger_core::fixed_asset_register(self.l(), as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 3), e))?; + let row = r + .rows + .iter() + .find(|row| row.asset_id == id) + .ok_or_else(|| { + Refusal::new( + RuleId::new(Area::Asset, 3), + "not on the register (acquired later)", + ) + })?; + Ok(json_figures([ + ("acquired".to_string(), row.acquired_date.to_string()), + ("in_service".to_string(), row.in_service_date.to_string()), + ("cost".to_string(), figure(row.cost)), + ( + "accumulated_scheduled".to_string(), + figure(row.accumulated_scheduled), + ), + ( + "accumulated_posted".to_string(), + figure(row.accumulated_posted), + ), + ( + "unposted".to_string(), + figure(row.accumulated_scheduled - row.accumulated_posted), + ), + ("book_value".to_string(), figure(row.book_value)), + ])) + } + + fn answer_register_totals(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let r = ledger_core::fixed_asset_register(self.l(), as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 3), e))?; + Ok(json_figures([ + ("total_cost".to_string(), figure(r.total_cost)), + ( + "total_accumulated_scheduled".to_string(), + figure(r.total_accumulated_scheduled), + ), + ( + "total_accumulated_posted".to_string(), + figure(r.total_accumulated_posted), + ), + ("total_book_value".to_string(), figure(r.total_book_value)), + ( + "total_depreciable_basis".to_string(), + figure(r.total_depreciable_basis), + ), + ( + "total_tax_basis_723".to_string(), + figure(r.total_tax_basis_723), + ), + ("missing_basis".to_string(), r.missing_basis.to_string()), + ( + "unregistered_cost".to_string(), + figure(r.unregistered_cost()), + ), + ( + "unposted_depreciation".to_string(), + figure(r.unposted_depreciation()), + ), + ( + "unattributed_accumulated".to_string(), + figure(r.unattributed_accumulated()), + ), + ])) + } + + fn answer_asset_basis(&self, q: &Query) -> Result { + let name = q.asset.as_deref().ok_or_else(|| self.missing("asset"))?; + let a = self + .l() + .get_asset(self.asset(name, name)) + .expect("a registered asset"); + let f = a + .basis_figures() + .map_err(|e| engine_refusal(RuleId::new(Area::Asset, 4), e))? + .ok_or_else(|| Refusal::new(RuleId::new(Area::Asset, 4), "no basis recorded"))?; + let opt = |d: Option| d.map_or("none".to_string(), figure); + let rule = a.basis.as_ref().expect("a basis recorded").rule; + Ok(json_figures([ + ("rule".to_string(), rule.key().to_string()), + ( + "contributor_adjusted_basis".to_string(), + opt(a.basis.as_ref().and_then(|b| b.contributor_adjusted_basis)), + ), + ( + "fmv_at_contribution".to_string(), + opt(a.fmv_at_contribution()), + ), + ("tax_basis_723".to_string(), opt(f.tax_basis_723)), + ("depreciable_basis".to_string(), figure(f.depreciable_basis)), + ("built_in_gain_704c".to_string(), opt(f.built_in_gain_704c)), + ("sec704c_1c".to_string(), a.sec704c_1c().to_string()), + ])) + } + + fn answer_iif(&self, q: &Query) -> Result { + let as_of = self.q_day(q)?; + let iif = export_iif(self.l(), &self.ex.setup.cash_accounts, as_of) + .map_err(|e| engine_refusal(RuleId::new(Area::Format, 2), e))?; + match q.name.as_str() { + "iif_lines" => { + let mut lines: Vec<&str> = iif.text.split("\r\n").collect(); + if lines.last() == Some(&"") { + lines.pop(); + } + Ok(Value::Array( + lines + .into_iter() + .map(|l| Value::String(l.to_string())) + .collect(), + )) + } + _ => Ok(Value::Array( + iif.changes + .iter() + .map(|c| { + let what = match &c.field { + IifField::AccountName { code } => format!("name {code}"), + IifField::Memo { entry } => format!("memo {entry}"), + IifField::AccountNumber | IifField::AccountNumberLeftOut { .. } => { + "number".to_string() + } + IifField::AtTopLevel { code, .. } => format!("top level {code}"), + IifField::ClassName { class } => format!("class {class}"), + }; + Value::Array(vec![ + Value::String(what), + Value::String(c.books.clone()), + Value::String(c.written.clone()), + ]) + }) + .collect(), + )), + } + } + + fn answer_command(&self, q: &Query) -> Result { + let ex = &self.ex; + let mut copy = self.l().clone(); + let result: Result<(), LedgerError> = match q.name.as_str() { + "rename_account" => { + let code = q.code.as_deref().ok_or_else(|| self.missing("account"))?; + let name = q.new_name.as_deref().ok_or_else(|| self.missing("name"))?; + let account = self.account_or_refuse(code)?; + copy.rename_account(account, name) + } + "add_account" => { + let a = q.account.as_ref().ok_or_else(|| self.missing("account"))?; + let kind = account_type(a, &ex.id); + let parent = a + .parent + .as_deref() + .map(|p| self.account_or_refuse(p)) + .transpose()?; + copy.add_account(&a.code, &a.name, kind, parent, a.header) + .and_then(|id| match &a.tax_category { + Some(c) => copy.set_account_tax(id, Some(c), None), + None => Ok(()), + }) + .and_then(|()| { + self.l().check_tax_category_required( + &a.code, + &a.name, + kind, + a.header, + a.tax_category.as_deref(), + ) + }) + } + "set_tax_category" => { + let code = q.code.as_deref().ok_or_else(|| self.missing("account"))?; + let account = self.account_or_refuse(code)?; + let category = q.category.as_deref(); + copy.set_account_tax(account, category, None) + .and_then(|()| self.l().check_tax_category_kept(account, category)) + } + other => unreachable!("{other} is no command"), + }; + result + .map(|()| Value::Bool(true)) + .map_err(|e| Refusal::of_reason(refusal(&e))) + } +} + +/// The `Engine` adapter: the wanderbooks implementation over `Store`, as +/// the bookspec runner drives any engine (`plan/served.md` P13 step 2). +impl Engine for Books<'_> { + fn load(&mut self, setup: &Setup) -> Result<(), Refusal> { + if setup != &self.ex.setup { + return Err(Refusal::new( + RuleId::new(Area::Ledger, 1), + "the setup handed over is not the example's", + )); + } + self.setup(); + Ok(()) + } + + fn apply(&mut self, event: &Event) -> Result<(), Refusal> { + let n = self.posted.len() + 1; + self.apply_event(n, event) + .map_err(|e| store_refusal_obj(&e)) + } + + fn change(&mut self, change: &ChangeSpec) -> Result<(), Refusal> { + self.try_change(change) + } + + fn query(&self, q: &Query) -> Result { + self.answer(q) + } + + fn claims(&self) -> Claims { + Claims::new( + bookspec_checker::covered_rules() + .into_iter() + .map(|r| r.id.clone()), + ) + } +} + +/// A JSON object of named strings (every figure an exact decimal string, +/// never a number). +fn json_figures(figures: I) -> Value +where + I: IntoIterator, +{ + Value::Object( + figures + .into_iter() + .map(|(k, v)| (k, Value::String(v))) + .collect(), + ) } /// The books an example sets up, with every event and change applied and @@ -1830,7 +2338,7 @@ fn reconciliation_key(l: &ledger_core::ReconcilingLine) -> String { /// Check one `cash_flow` expectation, every value it gives. fn check_cash_flow(b: &Books<'_>, x: &Expect) { let (ex, l, members) = (b.ex, b.l(), &b.members); - let (start, end) = x.period(ex); + let (start, end) = period(ex, x); let values = x.figures(&ex.id); let params = CashFlowParams { start, @@ -1927,7 +2435,7 @@ fn exactly(ex: &Example, x: &Expect, keys: &BTreeSet) { /// the day, debits less credits (a credit balance negative), every /// account listed (rules.md B-6, B-33). fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { - let as_of = x.day(ex); + let as_of = day(ex, x); let tb = trial_balance_as_of(l, as_of); assert!(tb.balanced(), "{}: the trial balance balances", ex.id); let keys = ex.setup.accounts.iter().map(|a| a.code.clone()).collect(); @@ -1949,7 +2457,7 @@ fn check_trial_balance(ex: &Example, l: &Ledger, x: &Expect) { /// left out, and `net_income`, revenue less expenses; every revenue and /// expense account listed (rules.md B-14, B-33). fn check_profit_and_loss(ex: &Example, l: &Ledger, x: &Expect) { - let (start, end) = x.period(ex); + let (start, end) = period(ex, x); let pl = profit_and_loss_for_period(l, start, end, None) .unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let mut keys: BTreeSet = ex @@ -1995,7 +2503,7 @@ fn report_1099(b: &Books<'_>, x: &Expect) -> (NaiveDate, NaiveDate, ledger_core: ex.id, x.query ); - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); let year = chrono::Datelike::year(&to); assert_eq!( Some(from), @@ -2165,7 +2673,7 @@ fn check_party_totals(b: &Books<'_>, x: &Expect) { "{}: party_totals gives from and to", ex.id ); - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); let basis = x .basis .as_deref() @@ -2225,7 +2733,7 @@ fn check_project(b: &Books<'_>, x: &Expect) { .projects .get(name) .unwrap_or_else(|| panic!("{}: project {name:?} not in setup.projects", ex.id)); - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); let f = ledger_core::project_figures(l, id, from, to).unwrap_or_else(|e| panic!("{}: {e}", ex.id)); let keys: BTreeSet = [ @@ -2294,7 +2802,7 @@ fn check_party_tin_last4(b: &Books<'_>, x: &Expect) { /// are the example's choice. It needs `cash_accounts` (what is money /// rather than property). fn check_k1(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{}: k1 needs the members and their shares", @@ -2356,7 +2864,7 @@ fn check_format_shape(ex: &Example, x: &Expect) { ); // ledger-package reads the as_of itself: given, never defaulted. assert!(x.as_of.is_some(), "{}: {} gives as_of", ex.id, x.query); - x.day(ex); + day(ex, x); assert!( x.after.is_none(), "{}: {}: a format query is of the books after every event", @@ -2411,7 +2919,7 @@ fn check_entry(b: &Books<'_>, x: &Expect) { /// of the day on its normal side (so a member's capital after a loss is /// negative); the accounts named are the example's choice. fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { - let as_of = x.day(ex); + let as_of = day(ex, x); let tb = trial_balance_as_of(l, as_of); for (code, want) in x.figures(&ex.id) { let want = exact(want, &format!("{} {code}", ex.id)); @@ -2438,7 +2946,7 @@ fn check_balances(ex: &Example, l: &Ledger, x: &Expect) { /// and `ending`; the figures named are the example's choice (rules.md /// B-13). fn check_members_equity(ex: &Example, l: &Ledger, members: &[MemberCapital], x: &Expect) { - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); assert!( !members.is_empty() && members.iter().all(|m| m.share > Decimal::ZERO), "{}: members_equity needs the members and their shares", @@ -2561,7 +3069,7 @@ fn month_key(key: &str, what: &str) { /// figures it shows. fn check_depreciation_schedule(b: &Books<'_>, x: &Expect) { let ex = b.ex; - let as_of = x.day(ex); + let as_of = day(ex, x); let name = x .asset .as_deref() @@ -2666,7 +3174,7 @@ fn figure(d: Decimal) -> String { /// later) is refused. fn check_register_row(b: &Books<'_>, x: &Expect) { let ex = b.ex; - let as_of = x.day(ex); + let as_of = day(ex, x); let name = x .asset .as_deref() @@ -2704,7 +3212,7 @@ fn check_register_row(b: &Books<'_>, x: &Expect) { /// `unattributed_accumulated`; the example names the figures it shows. fn check_register_totals(b: &Books<'_>, x: &Expect) { let ex = b.ex; - let as_of = x.day(ex); + let as_of = day(ex, x); let what = format!("{} register_totals as of {as_of}", ex.id); let r = ledger_core::fixed_asset_register(b.l(), as_of).unwrap_or_else(|e| panic!("{what}: {e}")); @@ -2778,7 +3286,7 @@ fn check_asset_basis(b: &Books<'_>, x: &Expect) { /// their shares, and `cash_accounts`. fn tax_capital_rows(b: &Books<'_>, x: &Expect, what: &str) -> Vec { let ex = b.ex; - let (from, to) = x.period(ex); + let (from, to) = period(ex, x); assert!( !b.members.is_empty() && b.members.iter().all(|m| m.share > Decimal::ZERO), "{what}: needs the members and their shares" @@ -2937,7 +3445,7 @@ fn check_command(ex: &Example, l: &Ledger, x: &Expect) { .account .as_ref() .unwrap_or_else(|| panic!("{}: add_account gives the account", ex.id)); - let kind = a.account_type(&ex.id); + let kind = account_type(a, &ex.id); let parent = a.parent.as_deref().map(|p| { l.chart() .find_by_code(p) @@ -2981,7 +3489,7 @@ fn check_command(ex: &Example, l: &Ledger, x: &Expect) { /// these dates covers, by the one rule (rules.md B-30), as `from` and /// `to`; or the rule's refusal. fn check_period(ex: &Example, x: &Expect) { - match (x.resolved_period(ex), &x.refused) { + match (resolved_period(ex, x), &x.refused) { (Ok((from, to)), None) => { exactly(ex, x, &["from".to_string(), "to".to_string()].into()); let f = x.figures(&ex.id); @@ -3001,7 +3509,7 @@ fn check_period(ex: &Example, x: &Expect) { fn check_day(ex: &Example, x: &Expect) { exactly(ex, x, &["as_of".to_string()].into()); let want = date(&x.figures(&ex.id)["as_of"], &ex.id); - assert_eq!(x.day(ex), want, "{}: day", ex.id); + assert_eq!(day(ex, x), want, "{}: day", ex.id); } /// Check one `calendar_year` expectation: the year a calendar-year count @@ -3227,6 +3735,47 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { out } +/// The `Engine` adapter over `Store` runs every example through the +/// runner, and every rule the adapter claims passes (`plan/served.md` P13 +/// step 2). The loader above proves the same figures with its own checks; +/// this proves the trait path, which is what another engine supplies. +#[test] +fn the_runner_reports_every_claimed_rule_green() { + 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(); + let mut examples = Vec::new(); + for f in &files { + let text = std::fs::read_to_string(f).unwrap(); + examples.push(Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display()))); + } + assert!(examples.len() >= 56, "the seed has at least 56 examples"); + let report = bookspec_runner::run(&examples, bookspec_checker::rules(), Books::blank); + assert!( + report.passed(), + "the runner is not green:\n{}", + report.failures().join("\n") + ); + for rule in bookspec_checker::covered_rules() { + assert_eq!( + report.rules.get(&rule.id), + Some(&bookspec_runner::Outcome::Pass), + "{}", + rule.id + ); + } + assert!( + report + .rules + .values() + .any(|o| *o == bookspec_runner::Outcome::NotClaimed), + "a rule with no example is not claimed" + ); +} + #[test] fn every_bookspec_example_holds() { let mut files: Vec = std::fs::read_dir(examples_dir()) @@ -3239,7 +3788,7 @@ fn every_bookspec_example_holds() { let mut checked: BTreeMap = BTreeMap::new(); for f in &files { let text = std::fs::read_to_string(f).unwrap(); - let ex: Example = toml::from_str(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())); + 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); diff --git a/justfile b/justfile index 6671f25..cbe97f1 100644 --- a/justfile +++ b/justfile @@ -14,9 +14,13 @@ default: # Everything CI should check: format, clippy and rustdoc (zero warnings), tests check: sweep-test-tmp cargo fmt --all --check + cargo fmt --manifest-path bookspec/Cargo.toml --all --check cargo clippy --workspace --all-targets -- -D warnings + cargo clippy --manifest-path bookspec/Cargo.toml --workspace --all-targets -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet + RUSTDOCFLAGS="-D warnings" cargo doc --manifest-path bookspec/Cargo.toml --workspace --no-deps --quiet cargo test --workspace + cargo test --manifest-path bookspec/Cargo.toml --workspace @just server-only @just sweep-test-tmp