diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..059cda3 --- /dev/null +++ b/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/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c8c0410 --- /dev/null +++ b/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/checker/Cargo.toml b/checker/Cargo.toml new file mode 100644 index 0000000..cc52fa9 --- /dev/null +++ b/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/checker/src/engine.rs b/checker/src/engine.rs new file mode 100644 index 0000000..372d3eb --- /dev/null +++ b/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/checker/src/lib.rs b/checker/src/lib.rs new file mode 100644 index 0000000..9d398bf --- /dev/null +++ b/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/checker/src/rules.rs b/checker/src/rules.rs new file mode 100644 index 0000000..dc59220 --- /dev/null +++ b/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/checker/src/schema.rs b/checker/src/schema.rs new file mode 100644 index 0000000..b0e36e1 --- /dev/null +++ b/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/checker/tests/rules.rs b/checker/tests/rules.rs new file mode 100644 index 0000000..3dbc5f2 --- /dev/null +++ b/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/checker/tests/schema.rs b/checker/tests/schema.rs new file mode 100644 index 0000000..08f6b4d --- /dev/null +++ b/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/runner/Cargo.toml b/runner/Cargo.toml new file mode 100644 index 0000000..78f3d1d --- /dev/null +++ b/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/runner/src/lib.rs b/runner/src/lib.rs new file mode 100644 index 0000000..72d9f99 --- /dev/null +++ b/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/runner/tests/runner.rs b/runner/tests/runner.rs new file mode 100644 index 0000000..a6ced22 --- /dev/null +++ b/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/spec/rules-index.md b/spec/rules-index.md new file mode 100644 index 0000000..3a3e5da --- /dev/null +++ b/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) | — |