bookspec_checker/engine.rs
1//! The interface an accounting engine implements to be checked against
2//! bookspec (`plan/served.md` P13, D-34).
3//!
4//! bookspec is a specification first: its examples are the concrete cases
5//! (`bookspec/examples/*.toml`), its rule identifiers the normative rules
6//! (`BS-<area>-<n>`), and this trait the only thing an engine has to
7//! provide for the runner to check it. The engine's answers are compared
8//! with the examples' expected values by the runner; an engine is never
9//! asked whether it agrees with itself.
10
11use std::collections::{BTreeMap, BTreeSet};
12use std::fmt;
13use std::str::FromStr;
14
15use crate::rules::{Area, Rule, rule_for_id, rule_for_refusal};
16use crate::schema::{ChangeSpec, Event, Query, Setup};
17
18/// A bookspec rule identifier: `BS-<area>-<n>` (e.g. `BS-entry-1`).
19///
20/// The areas are the specification's subjects (`Area`), and `n` counts
21/// from 1 within an area. The catalogue in [`mod@crate::rules`] is the list of
22/// every identifier the specification states; an identifier that is not in
23/// it is a typo, not a rule.
24#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
25pub struct RuleId(String);
26
27/// Why a string is not a bookspec rule identifier.
28#[derive(Clone, Debug, PartialEq, Eq)]
29pub struct RuleIdError {
30 what: String,
31}
32
33impl fmt::Display for RuleIdError {
34 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
35 write!(f, "{}", self.what)
36 }
37}
38
39impl std::error::Error for RuleIdError {}
40
41impl RuleId {
42 /// The identifier of area `area`, rule number `n` (counting from 1).
43 pub fn new(area: Area, n: u32) -> Self {
44 assert!(n >= 1, "bookspec rule numbers count from 1");
45 Self(format!("BS-{}-{n}", area.key()))
46 }
47
48 /// Parse `BS-<area>-<n>`, refusing an unknown area, a missing or
49 /// non-numeric number, and a number that is zero or written with a
50 /// leading zero (one spelling per rule).
51 pub fn parse(s: &str) -> Result<Self, RuleIdError> {
52 let rest = s.strip_prefix("BS-").ok_or_else(|| RuleIdError {
53 what: format!("{s:?} is no bookspec rule id (BS-<area>-<n>)"),
54 })?;
55 let (area, n) = rest.rsplit_once('-').ok_or_else(|| RuleIdError {
56 what: format!("{s:?} is no bookspec rule id (BS-<area>-<n>)"),
57 })?;
58 let area = Area::parse(area).ok_or_else(|| RuleIdError {
59 what: format!("{s:?}: {:?} is no bookspec area", area),
60 })?;
61 if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) {
62 return Err(RuleIdError {
63 what: format!("{s:?}: {n:?} is no rule number"),
64 });
65 }
66 if n.starts_with('0') {
67 return Err(RuleIdError {
68 what: format!("{s:?}: rule numbers count from 1, without a leading zero"),
69 });
70 }
71 let n: u32 = n.parse().map_err(|_| RuleIdError {
72 what: format!("{s:?}: {n:?} is no rule number"),
73 })?;
74 if n == 0 {
75 return Err(RuleIdError {
76 what: format!("{s:?}: rule numbers count from 1"),
77 });
78 }
79 Ok(Self::new(area, n))
80 }
81
82 /// The identifier as the specification writes it.
83 pub fn as_str(&self) -> &str {
84 &self.0
85 }
86
87 /// The area it belongs to.
88 pub fn area(&self) -> Area {
89 // Constructed only by `new`/`parse`, both of which check the area.
90 Area::parse(self.0.split('-').nth(1).expect("a checked rule id"))
91 .expect("a checked rule id's area")
92 }
93
94 /// The rule's entry in the catalogue, if the specification states it.
95 pub fn rule(&self) -> Option<&'static Rule> {
96 rule_for_id(self)
97 }
98}
99
100impl fmt::Display for RuleId {
101 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
102 f.write_str(&self.0)
103 }
104}
105
106impl FromStr for RuleId {
107 type Err = RuleIdError;
108
109 fn from_str(s: &str) -> Result<Self, Self::Err> {
110 Self::parse(s)
111 }
112}
113
114/// An engine's refusal: the rule that refuses, and why in the example's
115/// words (bookspec/README.md's refusal vocabulary, never an engine's own
116/// message).
117#[derive(Clone, Debug, PartialEq, Eq)]
118pub struct Refusal {
119 /// The bookspec rule the refusal belongs to.
120 pub rule: RuleId,
121 /// What was refused, in the specification's words.
122 pub reason: String,
123}
124
125impl Refusal {
126 /// A refusal of `reason` under `rule`.
127 pub fn new(rule: RuleId, reason: impl Into<String>) -> Self {
128 Self {
129 rule,
130 reason: reason.into(),
131 }
132 }
133
134 /// A refusal of `reason`, under the rule the specification assigns
135 /// that refusal (see [`rule_for_refusal`]); an unknown reason is a
136 /// specification defect, and the first rule of the ledger area is
137 /// named so the report still has an identifier to show.
138 pub fn of_reason(reason: impl Into<String>) -> Self {
139 let reason = reason.into();
140 let rule = rule_for_refusal(&reason)
141 .map(|r| r.id.clone())
142 .unwrap_or_else(|| RuleId::new(Area::Ledger, 1));
143 Self { rule, reason }
144 }
145}
146
147/// What an engine declares about itself: the rules it takes the
148/// conformance test on, and the option it picks for each of the
149/// specification's choices (`CHOICE`: the specification names the options,
150/// an engine declares its pick; `plan/served.md` P13 step 3).
151#[derive(Clone, Debug, Default, PartialEq, Eq)]
152pub struct Claims {
153 /// The rule identifiers the engine claims.
154 pub rules: BTreeSet<RuleId>,
155 /// The choice rules whose option the engine picks, and the option.
156 pub choices: BTreeMap<RuleId, String>,
157}
158
159impl Claims {
160 /// The claims of an engine that claims `rules`, with no choices.
161 pub fn new(rules: impl IntoIterator<Item = RuleId>) -> Self {
162 Self {
163 rules: rules.into_iter().collect(),
164 choices: BTreeMap::new(),
165 }
166 }
167
168 /// True when `rule` is claimed.
169 pub fn claims(&self, rule: &RuleId) -> bool {
170 self.rules.contains(rule)
171 }
172
173 /// Declare the option this engine picks for `rule`.
174 pub fn with_choice(mut self, rule: RuleId, option: impl Into<String>) -> Self {
175 self.choices.insert(rule, option.into());
176 self
177 }
178
179 /// The option this engine picks for `rule`, if it declares one.
180 pub fn choice(&self, rule: &RuleId) -> Option<&str> {
181 self.choices.get(rule).map(String::as_str)
182 }
183}
184
185/// An accounting engine, as bookspec checks it.
186///
187/// The four methods are the plan's (`plan/served.md` 8.2 P13 step 2); the
188/// fifth, [`Engine::change`], is the one departure: an example's
189/// `[[changes]]` (a rename, the tax-category policy, an asset correction)
190/// posts no entry, so it is neither a setup nor an event, and the plan's
191/// four methods have no path for it. It is required, never defaulted, so
192/// an engine cannot silently skip the examples that use one.
193pub trait Engine {
194 /// Set the books up: the chart, the fixed assets, the schedules, the
195 /// parties, the members, the payees and their dated decisions, the
196 /// payment methods and the projects.
197 fn load(&mut self, setup: &Setup) -> Result<(), Refusal>;
198
199 /// Apply one event: an entry, a reversal, a close, a bill, an invoice,
200 /// a depreciation run, a disposal, a schedules run or a schedule's
201 /// registration. An event the engine refuses leaves the books as they
202 /// were.
203 fn apply(&mut self, event: &Event) -> Result<(), Refusal>;
204
205 /// Answer one query with the engine's own figures. A figure is an
206 /// exact decimal string (never a JSON number: no engine may round a
207 /// cent through a float), a date is `YYYY-MM-DD`, and a format
208 /// query's value is its lines or rows. A query the engine refuses
209 /// (a period the rule refuses, a command it refuses) is an
210 /// [`Err`].
211 fn query(&self, q: &Query) -> Result<serde_json::Value, Refusal>;
212
213 /// The rules and choices this engine declares.
214 fn claims(&self) -> Claims;
215
216 /// Apply one change that posts no entry (a `[[changes]]` entry).
217 fn change(&mut self, change: &ChangeSpec) -> Result<(), Refusal>;
218}
219
220/// The refusal an engine reports for an error it has no example-word
221/// reason for: the reason as given, under `fallback`.
222pub fn fallback_refusal(fallback: RuleId, reason: impl Into<String>) -> Refusal {
223 Refusal::new(fallback, reason)
224}