Skip to main content

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}