Skip to main content

rustledger_core/
directive.rs

1//! Directive types representing all beancount directives.
2//!
3//! Beancount has 12 directive types that can appear in a ledger file:
4//!
5//! - [`Transaction`] - The most common directive, recording transfers between accounts
6//! - [`Balance`] - Assert that an account has a specific balance
7//! - [`Open`] - Open an account for use
8//! - [`Close`] - Close an account
9//! - [`Commodity`] - Declare a commodity/currency
10//! - [`Pad`] - Automatically pad an account to match a balance assertion
11//! - [`Event`] - Record a life event
12//! - [`Query`] - Store a named BQL query
13//! - [`Note`] - Add a note to an account
14//! - [`Document`] - Link a document to an account
15//! - [`Price`] - Record a price for a commodity
16//! - [`Custom`] - Custom directive type
17
18use crate::NaiveDate;
19use rust_decimal::Decimal;
20use rustc_hash::FxHashMap;
21use serde::{Deserialize, Serialize};
22use std::fmt;
23
24use crate::intern::InternedStr;
25#[cfg(feature = "rkyv")]
26use crate::intern::{AsDecimal, AsInternedStr, AsNaiveDate, AsOptionInternedStr};
27use crate::{Amount, CostSpec, IncompleteAmount};
28
29/// Metadata value types.
30#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
31#[cfg_attr(
32    feature = "rkyv",
33    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
34)]
35pub enum MetaValue {
36    /// String value
37    String(String),
38    /// Account reference
39    Account(crate::Account),
40    /// Currency code
41    Currency(crate::Currency),
42    /// Tag reference
43    Tag(crate::Tag),
44    /// Link reference
45    Link(crate::Link),
46    /// Date value
47    Date(#[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))] NaiveDate),
48    /// Numeric value
49    Number(#[cfg_attr(feature = "rkyv", rkyv(with = AsDecimal))] Decimal),
50    /// Boolean value
51    Bool(bool),
52    /// Amount value
53    Amount(Amount),
54    /// Null/None value
55    None,
56    /// Integer value (e.g. beancount `key: 42`, distinct from `42.0`).
57    ///
58    /// MUST remain the LAST variant: rkyv encodes enum discriminants by
59    /// declaration order, so appending keeps the existing variants' discriminants
60    /// stable. `CACHE_VERSION` (rustledger-loader) was bumped when this was added.
61    Int(i64),
62}
63
64impl fmt::Display for MetaValue {
65    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
66        match self {
67            Self::String(s) => write!(f, "\"{}\"", crate::format::escape_string(s)),
68            Self::Account(a) => write!(f, "{a}"),
69            Self::Currency(c) => write!(f, "{c}"),
70            Self::Tag(t) => write!(f, "#{t}"),
71            Self::Link(l) => write!(f, "^{l}"),
72            Self::Date(d) => write!(f, "{d}"),
73            Self::Number(n) => write!(f, "{n}"),
74            Self::Bool(b) => write!(f, "{b}"),
75            Self::Amount(a) => write!(f, "{a}"),
76            Self::None => write!(f, "None"),
77            Self::Int(i) => write!(f, "{i}"),
78        }
79    }
80}
81
82/// Metadata is a key-value map attached to directives and postings.
83pub type Metadata = FxHashMap<String, MetaValue>;
84
85/// The spellings of a boolean accepted in ledger source.
86///
87/// Stated ONCE because a boolean can arrive two ways — `option "x" "TRUE"` and
88/// `x: TRUE` metadata — and they are the same concept to a ledger author. Two
89/// separate vocabularies would mean `render_commas: YES` grouping numerals
90/// while `option "render_commas" "YES"` warns and does nothing.
91///
92/// `None` means "not recognizably boolean": the option parser turns that into
93/// an E7002 warning, and metadata leaves the default in place.
94#[must_use]
95pub fn parse_bool_word(word: &str) -> Option<bool> {
96    if word.eq_ignore_ascii_case("true") || word == "1" {
97        Some(true)
98    } else if word.eq_ignore_ascii_case("false") || word == "0" {
99        Some(false)
100    } else {
101        None
102    }
103}
104
105/// Read a boolean-valued metadata entry.
106///
107/// `TRUE` is an uppercase bare word, so depending on context the parser may
108/// classify it as a boolean, a currency, or a string rather than one canonical
109/// variant. Accepting all three keeps `render_commas: TRUE` working however it
110/// lexes, instead of silently ignoring the declaration — the failure mode that
111/// makes a display option look broken. The VALUE vocabulary is
112/// [`parse_bool_word`]'s, shared with the option parser.
113#[must_use]
114pub fn meta_value_as_bool(value: &MetaValue) -> Option<bool> {
115    match value {
116        MetaValue::Bool(b) => Some(*b),
117        MetaValue::String(s) => parse_bool_word(s),
118        MetaValue::Currency(c) => parse_bool_word(c),
119        _ => None,
120    }
121}
122
123/// Try to interpret a [`MetaValue`] as a non-negative integer ≤ `u32::MAX`.
124///
125/// Used by the `precision` metadata feature on `commodity` directives (issue
126/// #991). Shared between the loader (which silently skips invalid values and
127/// falls back to inferred precision) and the validator (which surfaces the
128/// problem as an `InvalidPrecisionMetadata` warning), so both paths agree on
129/// what counts as valid.
130///
131/// # Errors
132///
133/// Returns a human-readable explanation when the value is not a number,
134/// is negative, has a fractional part, or is out of `u32` range.
135#[must_use = "ignoring the result silently drops invalid `precision:` metadata; the loader expects to skip invalid values, the validator expects to surface them"]
136pub fn parse_precision_meta(value: &MetaValue) -> Result<u32, String> {
137    use rust_decimal::prelude::ToPrimitive;
138    match value {
139        // `precision: 2` now parses as an integer metadata value.
140        MetaValue::Int(i) => u32::try_from(*i).map_err(|_| {
141            if *i < 0 {
142                format!("expected a non-negative integer, got {i}")
143            } else {
144                format!(
145                    "value {i} exceeds the maximum supported precision ({})",
146                    u32::MAX
147                )
148            }
149        }),
150        // `precision: 2.0` (an explicit decimal) is still accepted if integral.
151        MetaValue::Number(n) => {
152            if n.is_sign_negative() {
153                return Err(format!("expected a non-negative integer, got {n}"));
154            }
155            if !n.fract().is_zero() {
156                return Err(format!("expected an integer, got {n}"));
157            }
158            n.to_u32().ok_or_else(|| {
159                format!(
160                    "value {n} exceeds the maximum supported precision ({})",
161                    u32::MAX
162                )
163            })
164        }
165        _ => Err(format!(
166            "expected a non-negative integer, got {} value",
167            meta_value_kind(value)
168        )),
169    }
170}
171
172const fn meta_value_kind(v: &MetaValue) -> &'static str {
173    match v {
174        MetaValue::String(_) => "string",
175        MetaValue::Account(_) => "account",
176        MetaValue::Currency(_) => "currency",
177        MetaValue::Tag(_) => "tag",
178        MetaValue::Link(_) => "link",
179        MetaValue::Date(_) => "date",
180        MetaValue::Number(_) => "number",
181        MetaValue::Bool(_) => "bool",
182        MetaValue::Amount(_) => "amount",
183        MetaValue::None => "none",
184        MetaValue::Int(_) => "int",
185    }
186}
187
188/// A posting within a transaction.
189///
190/// Postings represent the individual legs of a transaction. Each posting
191/// specifies an account and optionally an amount, cost, and price.
192///
193/// When the units are `None`, the entire amount will be inferred by the
194/// interpolation algorithm to balance the transaction. When units is
195/// `Some(IncompleteAmount)`, it may still have missing components that
196/// need to be filled in.
197#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
198#[cfg_attr(
199    feature = "rkyv",
200    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
201)]
202pub struct Posting {
203    /// The account for this posting
204    pub account: crate::Account,
205    /// The units (may be incomplete or None for auto-calculated postings)
206    pub units: Option<IncompleteAmount>,
207    /// Cost specification for the position.
208    ///
209    /// Boxed because it is absent on the overwhelming majority of postings
210    /// and `CostSpec` is the largest field by far. Inline, `Posting` was 280
211    /// bytes and a plain two-posting expense carried ~190 bytes of `None`.
212    /// rkyv mirrors the in-memory layout, so that padding was also written to
213    /// and validated from the parse cache — the archive ran 5.2x the size of
214    /// the ledger it came from. Use `as_deref()` to read through it.
215    pub cost: Option<Box<CostSpec>>,
216    /// Price annotation (`@` or `@@`). Boxed for the same reason as
217    /// [`Self::cost`].
218    pub price: Option<Box<PriceAnnotation>>,
219    /// Whether this posting has the "!" flag
220    pub flag: Option<char>,
221    /// Posting metadata
222    pub meta: Metadata,
223    /// Comments that appear before this posting (one per line)
224    #[serde(default, skip_serializing_if = "Vec::is_empty")]
225    pub comments: Vec<String>,
226    /// Trailing comment(s) on the same line as the posting
227    #[serde(default, skip_serializing_if = "Vec::is_empty")]
228    pub trailing_comments: Vec<String>,
229}
230
231impl Posting {
232    /// Create a new posting with the given account and complete units.
233    #[must_use]
234    pub fn new(account: impl Into<crate::Account>, units: Amount) -> Self {
235        Self {
236            account: account.into(),
237            units: Some(IncompleteAmount::Complete(units)),
238            cost: None,
239            price: None,
240            flag: None,
241            meta: Metadata::default(),
242            comments: Vec::new(),
243            trailing_comments: Vec::new(),
244        }
245    }
246
247    /// Create a new posting with an incomplete amount.
248    #[must_use]
249    pub fn with_incomplete(account: impl Into<crate::Account>, units: IncompleteAmount) -> Self {
250        Self {
251            account: account.into(),
252            units: Some(units),
253            cost: None,
254            price: None,
255            flag: None,
256            meta: Metadata::default(),
257            comments: Vec::new(),
258            trailing_comments: Vec::new(),
259        }
260    }
261
262    /// Create a posting without any amount (to be fully interpolated).
263    #[must_use]
264    pub fn auto(account: impl Into<crate::Account>) -> Self {
265        Self {
266            account: account.into(),
267            units: None,
268            cost: None,
269            price: None,
270            flag: None,
271            meta: Metadata::default(),
272            comments: Vec::new(),
273            trailing_comments: Vec::new(),
274        }
275    }
276
277    /// Get the complete amount if available.
278    #[must_use]
279    pub fn amount(&self) -> Option<&Amount> {
280        self.units.as_ref().and_then(|u| u.as_amount())
281    }
282
283    /// Add a cost specification.
284    ///
285    /// Consuming builder — takes `self` by value and returns `Posting`.
286    /// To apply this to a `Spanned<Posting>` while preserving its source
287    /// location, use [`crate::Spanned::map`]:
288    ///
289    /// ```no_run
290    /// # use rustledger_core::{Posting, Amount, CostSpec, CostNumber, Spanned};
291    /// # use rust_decimal_macros::dec;
292    /// # let cost = CostSpec { number: Some(CostNumber::PerUnit { value: dec!(150) }),
293    /// #     currency: Some("USD".into()), date: None, label: None, merge: false };
294    /// let spanned: Spanned<Posting> = Spanned::synthesized(
295    ///     Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
296    /// );
297    /// let with_cost: Spanned<Posting> = spanned.map(|p| p.with_cost(cost));
298    /// ```
299    #[must_use]
300    pub fn with_cost(mut self, cost: CostSpec) -> Self {
301        self.cost = Some(Box::new(cost));
302        self
303    }
304
305    /// Add a price annotation.
306    ///
307    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
308    #[must_use]
309    pub fn with_price(mut self, price: PriceAnnotation) -> Self {
310        self.price = Some(Box::new(price));
311        self
312    }
313
314    /// Add a flag.
315    ///
316    /// See [`Self::with_cost`] for the `Spanned<Posting>` pattern.
317    #[must_use]
318    pub const fn with_flag(mut self, flag: char) -> Self {
319        self.flag = Some(flag);
320        self
321    }
322
323    /// Check if this posting has an amount.
324    #[must_use]
325    pub const fn has_units(&self) -> bool {
326        self.units.is_some()
327    }
328}
329
330impl fmt::Display for Posting {
331    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
332        write!(f, "  ")?;
333        if let Some(flag) = self.flag {
334            write!(f, "{flag} ")?;
335        }
336        write!(f, "{}", self.account)?;
337        if let Some(units) = &self.units {
338            write!(f, "  {units}")?;
339        }
340        if let Some(cost) = &self.cost {
341            write!(f, " {cost}")?;
342        }
343        if let Some(price) = &self.price {
344            write!(f, " {price}")?;
345        }
346        // Posting-level metadata
347        for (key, value) in &self.meta {
348            write!(f, "\n    {key}: {value}")?;
349        }
350        Ok(())
351    }
352}
353
354/// Whether a price annotation is per-unit (`@`) or total (`@@`).
355#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
356#[cfg_attr(
357    feature = "rkyv",
358    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
359)]
360pub enum PriceKind {
361    /// Per-unit price (`@`).
362    Unit,
363    /// Total price for the posting (`@@`).
364    Total,
365}
366
367impl fmt::Display for PriceKind {
368    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
369        f.write_str(match self {
370            Self::Unit => "@",
371            Self::Total => "@@",
372        })
373    }
374}
375
376/// Price annotation for a posting (`@` or `@@`).
377///
378/// Factored along its two orthogonal axes (#1167):
379/// - `kind`: per-unit (`@`) vs total (`@@`)
380/// - `amount`: present (possibly with missing number or currency that
381///   interpolation will fill) vs absent (the bare `@`/`@@` sigil
382///   without any amount)
383///
384/// Pre-#1167 these axes were flattened into a 6-variant enum
385/// (`Unit/Total/UnitIncomplete/TotalIncomplete/UnitEmpty/TotalEmpty`),
386/// which forced every consumer to write six match arms even when only
387/// one axis mattered.
388#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
389#[cfg_attr(
390    feature = "rkyv",
391    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
392)]
393pub struct PriceAnnotation {
394    /// Per-unit (`@`) or total (`@@`).
395    pub kind: PriceKind,
396    /// The price amount, or `None` for the bare-sigil form.
397    /// `IncompleteAmount::Complete` indicates the parser saw a full
398    /// number+currency; the other variants indicate one of those
399    /// pieces was elided and will be filled by interpolation.
400    pub amount: Option<IncompleteAmount>,
401}
402
403impl PriceAnnotation {
404    /// Per-unit (`@`) price with a complete amount.
405    #[must_use]
406    pub const fn unit(amount: Amount) -> Self {
407        Self {
408            kind: PriceKind::Unit,
409            amount: Some(IncompleteAmount::Complete(amount)),
410        }
411    }
412
413    /// Total (`@@`) price with a complete amount.
414    #[must_use]
415    pub const fn total(amount: Amount) -> Self {
416        Self {
417            kind: PriceKind::Total,
418            amount: Some(IncompleteAmount::Complete(amount)),
419        }
420    }
421
422    /// Per-unit (`@`) price with an incomplete amount.
423    #[must_use]
424    pub const fn unit_incomplete(amount: IncompleteAmount) -> Self {
425        Self {
426            kind: PriceKind::Unit,
427            amount: Some(amount),
428        }
429    }
430
431    /// Total (`@@`) price with an incomplete amount.
432    #[must_use]
433    pub const fn total_incomplete(amount: IncompleteAmount) -> Self {
434        Self {
435            kind: PriceKind::Total,
436            amount: Some(amount),
437        }
438    }
439
440    /// Bare per-unit sigil (`@`) with no amount.
441    #[must_use]
442    pub const fn unit_empty() -> Self {
443        Self {
444            kind: PriceKind::Unit,
445            amount: None,
446        }
447    }
448
449    /// Bare total sigil (`@@`) with no amount.
450    #[must_use]
451    pub const fn total_empty() -> Self {
452        Self {
453            kind: PriceKind::Total,
454            amount: None,
455        }
456    }
457
458    /// Get the complete amount if available.
459    #[must_use]
460    pub fn amount(&self) -> Option<&Amount> {
461        self.amount.as_ref().and_then(IncompleteAmount::as_amount)
462    }
463
464    /// Check if this is a per-unit price (`@` vs `@@`).
465    #[must_use]
466    pub const fn is_unit(&self) -> bool {
467        matches!(self.kind, PriceKind::Unit)
468    }
469}
470
471impl fmt::Display for PriceAnnotation {
472    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
473        match &self.amount {
474            Some(amt) => write!(f, "{} {amt}", self.kind),
475            None => write!(f, "{}", self.kind),
476        }
477    }
478}
479
480/// Directive ordering priority for sorting.
481///
482/// When directives have the same date, they are sorted by type priority
483/// to ensure proper processing order.
484#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
485pub enum DirectivePriority {
486    /// Open accounts first so they exist before use
487    Open = 0,
488    /// Commodities declared before use
489    Commodity = 1,
490    /// Padding before balance assertions
491    Pad = 2,
492    /// Balance assertions checked at start of day
493    Balance = 3,
494    /// Main entries
495    Transaction = 4,
496    /// Annotations after transactions
497    Note = 5,
498    /// Attachments after transactions
499    Document = 6,
500    /// State changes
501    Event = 7,
502    /// Queries defined after data
503    Query = 8,
504    /// Prices at end of day
505    Price = 9,
506    /// Accounts closed after all activity
507    Close = 10,
508    /// User extensions last
509    Custom = 11,
510}
511
512/// All directive types in beancount.
513#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
514#[cfg_attr(
515    feature = "rkyv",
516    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
517)]
518pub enum Directive {
519    /// Transaction directive - records transfers between accounts
520    Transaction(Transaction),
521    /// Balance assertion - asserts an account balance at a point in time
522    Balance(Balance),
523    /// Open account - opens an account for use
524    Open(Open),
525    /// Close account - closes an account
526    Close(Close),
527    /// Commodity declaration - declares a currency/commodity
528    Commodity(Commodity),
529    /// Pad directive - auto-pad an account to match a balance
530    Pad(Pad),
531    /// Event directive - records a life event
532    Event(Event),
533    /// Query directive - stores a named BQL query
534    Query(Query),
535    /// Note directive - adds a note to an account
536    Note(Note),
537    /// Document directive - links a document to an account
538    Document(Document),
539    /// Price directive - records a commodity price
540    Price(Price),
541    /// Custom directive - custom user-defined directive
542    Custom(Custom),
543}
544
545impl Directive {
546    /// Get the date of this directive.
547    #[must_use]
548    pub const fn date(&self) -> NaiveDate {
549        match self {
550            Self::Transaction(t) => t.date,
551            Self::Balance(b) => b.date,
552            Self::Open(o) => o.date,
553            Self::Close(c) => c.date,
554            Self::Commodity(c) => c.date,
555            Self::Pad(p) => p.date,
556            Self::Event(e) => e.date,
557            Self::Query(q) => q.date,
558            Self::Note(n) => n.date,
559            Self::Document(d) => d.date,
560            Self::Price(p) => p.date,
561            Self::Custom(c) => c.date,
562        }
563    }
564
565    /// Get the metadata of this directive.
566    #[must_use]
567    pub const fn meta(&self) -> &Metadata {
568        match self {
569            Self::Transaction(t) => &t.meta,
570            Self::Balance(b) => &b.meta,
571            Self::Open(o) => &o.meta,
572            Self::Close(c) => &c.meta,
573            Self::Commodity(c) => &c.meta,
574            Self::Pad(p) => &p.meta,
575            Self::Event(e) => &e.meta,
576            Self::Query(q) => &q.meta,
577            Self::Note(n) => &n.meta,
578            Self::Document(d) => &d.meta,
579            Self::Price(p) => &p.meta,
580            Self::Custom(c) => &c.meta,
581        }
582    }
583
584    /// Check if this is a transaction.
585    #[must_use]
586    pub const fn is_transaction(&self) -> bool {
587        matches!(self, Self::Transaction(_))
588    }
589
590    /// Get as a transaction, if this is one.
591    #[must_use]
592    pub const fn as_transaction(&self) -> Option<&Transaction> {
593        match self {
594            Self::Transaction(t) => Some(t),
595            _ => None,
596        }
597    }
598
599    /// Get the directive type name.
600    #[must_use]
601    pub const fn type_name(&self) -> &'static str {
602        match self {
603            Self::Transaction(_) => "transaction",
604            Self::Balance(_) => "balance",
605            Self::Open(_) => "open",
606            Self::Close(_) => "close",
607            Self::Commodity(_) => "commodity",
608            Self::Pad(_) => "pad",
609            Self::Event(_) => "event",
610            Self::Query(_) => "query",
611            Self::Note(_) => "note",
612            Self::Document(_) => "document",
613            Self::Price(_) => "price",
614            Self::Custom(_) => "custom",
615        }
616    }
617
618    /// Get the sorting priority for this directive.
619    ///
620    /// Used to determine order when directives have the same date.
621    #[must_use]
622    pub const fn priority(&self) -> DirectivePriority {
623        match self {
624            Self::Open(_) => DirectivePriority::Open,
625            Self::Commodity(_) => DirectivePriority::Commodity,
626            Self::Pad(_) => DirectivePriority::Pad,
627            Self::Balance(_) => DirectivePriority::Balance,
628            Self::Transaction(_) => DirectivePriority::Transaction,
629            Self::Note(_) => DirectivePriority::Note,
630            Self::Document(_) => DirectivePriority::Document,
631            Self::Event(_) => DirectivePriority::Event,
632            Self::Query(_) => DirectivePriority::Query,
633            Self::Price(_) => DirectivePriority::Price,
634            Self::Close(_) => DirectivePriority::Close,
635            Self::Custom(_) => DirectivePriority::Custom,
636        }
637    }
638}
639
640/// Sort directives by date, then type priority.
641///
642/// This is a stable sort, so directives sharing a date and type keep their
643/// file order — which is the order they are booked in, matching Python's
644/// `(date, type_priority, lineno)`.
645pub fn sort_directives(directives: &mut [Directive]) {
646    directives.sort_by_cached_key(booking_sort_key);
647}
648
649/// The canonical booking-order sort key: `(date, priority)`.
650///
651/// Deliberately WITHOUT a reduction tiebreak. Same-date directives book in
652/// file order (the sorts through this key are stable), which is what Python
653/// does — its `entry_sortkey` is `(date, type_priority, lineno)` and has no
654/// notion of augmentations going first.
655///
656/// This key used to carry a third component, `has_cost_reduction`, which
657/// floated cost augmentations ahead of the reductions matching against them
658/// so that lots would exist when matched (#841). That was a workaround for a
659/// missing behavior rather than an ordering problem, and the behavior has
660/// since landed: a cost-bearing posting is a REDUCTION only when the account
661/// already holds the opposite sign in that currency
662/// ([`crate::Inventory::is_booking_reduction`], #1560, mirroring Python's
663/// `balance.is_reduced_by`). Otherwise it is an augmentation and simply opens
664/// a negative lot for a later posting to close. #841's ledger passes on that
665/// path alone, with no reordering — and reordering actively broke ledgers
666/// Python accepts (#2093), because moving a same-date augmentation ahead of a
667/// reduction changes which lots an ambiguous match can see.
668///
669/// This is the SINGLE source of the booking order. [`sort_directives`] sorts a
670/// slice in place by it, and both `book()` (rustledger-booking) and
671/// `run_booking()` (rustledger-loader) key an index permutation through it
672/// (they preserve the input order for reassembly, so they cannot sort the slice
673/// directly). Change a booking-order tiebreak here only.
674#[must_use]
675pub const fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority) {
676    (d.date(), d.priority())
677}
678
679/// A transaction directive.
680///
681/// Transactions are the most common directive type. They record transfers
682/// between accounts and must balance (sum of all postings equals zero).
683#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684#[cfg_attr(
685    feature = "rkyv",
686    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
687)]
688pub struct Transaction {
689    /// Transaction date
690    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
691    pub date: NaiveDate,
692    /// Transaction flag (* or !)
693    pub flag: char,
694    /// Payee (optional)
695    #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
696    pub payee: Option<InternedStr>,
697    /// Narration (description)
698    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
699    pub narration: InternedStr,
700    /// Tags attached to this transaction
701    pub tags: Vec<crate::Tag>,
702    /// Links attached to this transaction
703    pub links: Vec<crate::Link>,
704    /// Transaction metadata
705    pub meta: Metadata,
706    /// Postings (account entries), each wrapped with its source span and
707    /// file ID. Parser-emitted postings carry the byte range of the
708    /// posting line (from leading indent through trailing same-line
709    /// comment, not including following metadata lines); programmatically
710    /// constructed postings use [`crate::Spanned::synthesized`] which
711    /// pairs [`crate::Span::ZERO`] with [`crate::SYNTHESIZED_FILE_ID`].
712    pub postings: Vec<crate::Spanned<Posting>>,
713    /// Comments that appear after all postings
714    #[serde(default, skip_serializing_if = "Vec::is_empty")]
715    pub trailing_comments: Vec<String>,
716}
717
718impl Transaction {
719    /// Create a new transaction.
720    #[must_use]
721    pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
722        Self {
723            date,
724            flag: '*',
725            payee: None,
726            narration: narration.into(),
727            tags: Vec::new(),
728            links: Vec::new(),
729            meta: Metadata::default(),
730            postings: Vec::new(),
731            trailing_comments: Vec::new(),
732        }
733    }
734
735    /// Set the flag.
736    #[must_use]
737    pub const fn with_flag(mut self, flag: char) -> Self {
738        self.flag = flag;
739        self
740    }
741
742    /// Set the payee.
743    #[must_use]
744    pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
745        self.payee = Some(payee.into());
746        self
747    }
748
749    /// Add a tag.
750    #[must_use]
751    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
752        self.tags.push(tag.into());
753        self
754    }
755
756    /// Add a link.
757    #[must_use]
758    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
759        self.links.push(link.into());
760        self
761    }
762
763    /// Add a posting that already carries source location metadata.
764    /// Use this when constructing transactions from source (e.g.
765    /// a parser implementation) or when round-tripping through a
766    /// plugin that preserves spans.
767    #[must_use]
768    pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
769        self.postings.push(posting);
770        self
771    }
772
773    /// Add a programmatically-built posting (no source representation),
774    /// wrapping it with [`crate::Spanned::synthesized`]. Use this from
775    /// test fixtures, CLI commands, and importers that build directives
776    /// in memory rather than parsing them. The name is intentionally
777    /// longer than [`Self::with_posting`] so that synthesis is visible
778    /// at the call site — silently dropping a real span is the foot-gun
779    /// this rename prevents.
780    #[must_use]
781    pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
782        self.postings.push(crate::Spanned::synthesized(posting));
783        self
784    }
785
786    /// Check if this transaction is marked as complete (*).
787    #[must_use]
788    pub const fn is_complete(&self) -> bool {
789        self.flag == '*'
790    }
791
792    /// Check if this transaction is marked as incomplete/pending (!).
793    #[must_use]
794    pub const fn is_incomplete(&self) -> bool {
795        self.flag == '!'
796    }
797
798    /// Check if this transaction is marked as pending (!).
799    /// Alias for `is_incomplete`.
800    #[must_use]
801    pub const fn is_pending(&self) -> bool {
802        self.flag == '!'
803    }
804
805    /// Check if this is a summarization transaction (S).
806    #[must_use]
807    pub const fn is_summarization(&self) -> bool {
808        self.flag == 'S'
809    }
810
811    /// Check if this is a transfer transaction (T).
812    #[must_use]
813    pub const fn is_transfer(&self) -> bool {
814        self.flag == 'T'
815    }
816
817    /// Check if this is a currency conversion transaction (C).
818    #[must_use]
819    pub const fn is_conversion(&self) -> bool {
820        self.flag == 'C'
821    }
822
823    /// Check if this is an unrealized gains transaction (U).
824    #[must_use]
825    pub const fn is_unrealized(&self) -> bool {
826        self.flag == 'U'
827    }
828
829    /// Check if this is a return/dividend transaction (R).
830    #[must_use]
831    pub const fn is_return(&self) -> bool {
832        self.flag == 'R'
833    }
834
835    /// Check if this is a merge transaction (M).
836    #[must_use]
837    pub const fn is_merge(&self) -> bool {
838        self.flag == 'M'
839    }
840
841    /// Check if this transaction is bookmarked (#).
842    #[must_use]
843    pub const fn is_bookmarked(&self) -> bool {
844        self.flag == '#'
845    }
846
847    /// Check if this transaction needs investigation (?).
848    #[must_use]
849    pub const fn needs_investigation(&self) -> bool {
850        self.flag == '?'
851    }
852
853    /// Check if the given character is a valid transaction flag.
854    #[must_use]
855    pub const fn is_valid_flag(flag: char) -> bool {
856        matches!(
857            flag,
858            '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
859        )
860    }
861}
862
863impl fmt::Display for Transaction {
864    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
865        write!(f, "{} {} ", self.date, self.flag)?;
866        if let Some(payee) = &self.payee {
867            write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
868        }
869        write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
870        for tag in &self.tags {
871            write!(f, " #{tag}")?;
872        }
873        for link in &self.links {
874            write!(f, " ^{link}")?;
875        }
876        // Transaction-level metadata
877        for (key, value) in &self.meta {
878            write!(f, "\n  {key}: {value}")?;
879        }
880        for posting in &self.postings {
881            write!(f, "\n{posting}")?;
882        }
883        Ok(())
884    }
885}
886
887/// A balance assertion directive.
888///
889/// Asserts that an account has a specific balance at the beginning of a date.
890#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
891#[cfg_attr(
892    feature = "rkyv",
893    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
894)]
895pub struct Balance {
896    /// Assertion date
897    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
898    pub date: NaiveDate,
899    /// Account to check
900    pub account: crate::Account,
901    /// Expected amount
902    pub amount: Amount,
903    /// Tolerance (if explicitly specified)
904    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
905    pub tolerance: Option<Decimal>,
906    /// Metadata
907    pub meta: Metadata,
908}
909
910impl Balance {
911    /// Create a new balance assertion.
912    #[must_use]
913    pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
914        Self {
915            date,
916            account: account.into(),
917            amount,
918            tolerance: None,
919            meta: Metadata::default(),
920        }
921    }
922
923    /// Set explicit tolerance.
924    #[must_use]
925    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
926        self.tolerance = Some(tolerance);
927        self
928    }
929
930    /// Set metadata.
931    #[must_use]
932    pub fn with_meta(mut self, meta: Metadata) -> Self {
933        self.meta = meta;
934        self
935    }
936}
937
938impl fmt::Display for Balance {
939    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
940        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
941        if let Some(tol) = self.tolerance {
942            write!(f, " ~ {tol}")?;
943        }
944        Ok(())
945    }
946}
947
948/// An open account directive.
949///
950/// Opens an account for use. Accounts must be opened before they can be used.
951#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
952#[cfg_attr(
953    feature = "rkyv",
954    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
955)]
956pub struct Open {
957    /// Date account was opened
958    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
959    pub date: NaiveDate,
960    /// Account name (e.g., "Assets:Bank:Checking")
961    pub account: crate::Account,
962    /// Allowed currencies (empty = any currency allowed)
963    pub currencies: Vec<crate::Currency>,
964    /// Booking method for this account
965    pub booking: Option<String>,
966    /// Metadata
967    pub meta: Metadata,
968}
969
970impl Open {
971    /// Create a new open directive.
972    #[must_use]
973    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
974        Self {
975            date,
976            account: account.into(),
977            currencies: Vec::new(),
978            booking: None,
979            meta: Metadata::default(),
980        }
981    }
982
983    /// Set allowed currencies.
984    #[must_use]
985    pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
986        self.currencies = currencies;
987        self
988    }
989
990    /// Set booking method.
991    #[must_use]
992    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
993        self.booking = Some(booking.into());
994        self
995    }
996
997    /// Set metadata.
998    #[must_use]
999    pub fn with_meta(mut self, meta: Metadata) -> Self {
1000        self.meta = meta;
1001        self
1002    }
1003}
1004
1005impl fmt::Display for Open {
1006    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1007        write!(f, "{} open {}", self.date, self.account)?;
1008        if !self.currencies.is_empty() {
1009            let currencies: Vec<&str> = self
1010                .currencies
1011                .iter()
1012                .map(crate::Currency::as_str)
1013                .collect();
1014            write!(f, " {}", currencies.join(","))?;
1015        }
1016        if let Some(booking) = &self.booking {
1017            write!(f, " \"{}\"", crate::format::escape_string(booking))?;
1018        }
1019        Ok(())
1020    }
1021}
1022
1023/// A close account directive.
1024///
1025/// Closes an account. The account should have zero balance when closed.
1026#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1027#[cfg_attr(
1028    feature = "rkyv",
1029    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1030)]
1031pub struct Close {
1032    /// Date account was closed
1033    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1034    pub date: NaiveDate,
1035    /// Account name
1036    pub account: crate::Account,
1037    /// Metadata
1038    pub meta: Metadata,
1039}
1040
1041impl Close {
1042    /// Create a new close directive.
1043    #[must_use]
1044    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1045        Self {
1046            date,
1047            account: account.into(),
1048            meta: Metadata::default(),
1049        }
1050    }
1051
1052    /// Set metadata.
1053    #[must_use]
1054    pub fn with_meta(mut self, meta: Metadata) -> Self {
1055        self.meta = meta;
1056        self
1057    }
1058}
1059
1060impl fmt::Display for Close {
1061    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1062        write!(f, "{} close {}", self.date, self.account)
1063    }
1064}
1065
1066/// A commodity declaration directive.
1067///
1068/// Declares a commodity/currency that can be used in the ledger.
1069#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1070#[cfg_attr(
1071    feature = "rkyv",
1072    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1073)]
1074pub struct Commodity {
1075    /// Declaration date
1076    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1077    pub date: NaiveDate,
1078    /// Currency/commodity code (e.g., "USD", "AAPL")
1079    pub currency: crate::Currency,
1080    /// Metadata
1081    pub meta: Metadata,
1082}
1083
1084impl Commodity {
1085    /// Create a new commodity declaration.
1086    #[must_use]
1087    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1088        Self {
1089            date,
1090            currency: currency.into(),
1091            meta: Metadata::default(),
1092        }
1093    }
1094
1095    /// Set metadata.
1096    #[must_use]
1097    pub fn with_meta(mut self, meta: Metadata) -> Self {
1098        self.meta = meta;
1099        self
1100    }
1101}
1102
1103impl fmt::Display for Commodity {
1104    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1105        write!(f, "{} commodity {}", self.date, self.currency)
1106    }
1107}
1108
1109/// A pad directive.
1110///
1111/// Automatically inserts a transaction to pad an account to match
1112/// a subsequent balance assertion.
1113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1114#[cfg_attr(
1115    feature = "rkyv",
1116    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1117)]
1118pub struct Pad {
1119    /// Pad date
1120    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1121    pub date: NaiveDate,
1122    /// Account to pad
1123    pub account: crate::Account,
1124    /// Source account for padding (e.g., Equity:Opening-Balances)
1125    pub source_account: crate::Account,
1126    /// Metadata
1127    pub meta: Metadata,
1128}
1129
1130impl Pad {
1131    /// Create a new pad directive.
1132    #[must_use]
1133    pub fn new(
1134        date: NaiveDate,
1135        account: impl Into<crate::Account>,
1136        source_account: impl Into<crate::Account>,
1137    ) -> Self {
1138        Self {
1139            date,
1140            account: account.into(),
1141            source_account: source_account.into(),
1142            meta: Metadata::default(),
1143        }
1144    }
1145
1146    /// Set metadata.
1147    #[must_use]
1148    pub fn with_meta(mut self, meta: Metadata) -> Self {
1149        self.meta = meta;
1150        self
1151    }
1152}
1153
1154impl fmt::Display for Pad {
1155    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1156        write!(
1157            f,
1158            "{} pad {} {}",
1159            self.date, self.account, self.source_account
1160        )
1161    }
1162}
1163
1164/// An event directive.
1165///
1166/// Records a life event (e.g., location changes, employment changes).
1167#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1168#[cfg_attr(
1169    feature = "rkyv",
1170    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1171)]
1172pub struct Event {
1173    /// Event date
1174    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1175    pub date: NaiveDate,
1176    /// Event type (e.g., "location", "employer")
1177    pub event_type: String,
1178    /// Event value
1179    pub value: String,
1180    /// Metadata
1181    pub meta: Metadata,
1182}
1183
1184impl Event {
1185    /// Create a new event directive.
1186    #[must_use]
1187    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1188        Self {
1189            date,
1190            event_type: event_type.into(),
1191            value: value.into(),
1192            meta: Metadata::default(),
1193        }
1194    }
1195
1196    /// Set metadata.
1197    #[must_use]
1198    pub fn with_meta(mut self, meta: Metadata) -> Self {
1199        self.meta = meta;
1200        self
1201    }
1202}
1203
1204impl fmt::Display for Event {
1205    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1206        write!(
1207            f,
1208            "{} event \"{}\" \"{}\"",
1209            self.date,
1210            crate::format::escape_string(&self.event_type),
1211            crate::format::escape_string(&self.value)
1212        )
1213    }
1214}
1215
1216/// A query directive.
1217///
1218/// Stores a named BQL query that can be referenced later.
1219#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1220#[cfg_attr(
1221    feature = "rkyv",
1222    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1223)]
1224pub struct Query {
1225    /// Query date
1226    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1227    pub date: NaiveDate,
1228    /// Query name
1229    pub name: String,
1230    /// BQL query string
1231    pub query: String,
1232    /// Metadata
1233    pub meta: Metadata,
1234}
1235
1236impl Query {
1237    /// Create a new query directive.
1238    #[must_use]
1239    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1240        Self {
1241            date,
1242            name: name.into(),
1243            query: query.into(),
1244            meta: Metadata::default(),
1245        }
1246    }
1247
1248    /// Set metadata.
1249    #[must_use]
1250    pub fn with_meta(mut self, meta: Metadata) -> Self {
1251        self.meta = meta;
1252        self
1253    }
1254}
1255
1256impl fmt::Display for Query {
1257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1258        write!(
1259            f,
1260            "{} query \"{}\" \"{}\"",
1261            self.date,
1262            crate::format::escape_string(&self.name),
1263            crate::format::escape_string(&self.query)
1264        )
1265    }
1266}
1267
1268/// A note directive.
1269///
1270/// Adds a note/comment to an account on a specific date.
1271#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1272#[cfg_attr(
1273    feature = "rkyv",
1274    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1275)]
1276pub struct Note {
1277    /// Note date
1278    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1279    pub date: NaiveDate,
1280    /// Account
1281    pub account: crate::Account,
1282    /// Note text
1283    pub comment: String,
1284    /// Metadata
1285    pub meta: Metadata,
1286}
1287
1288impl Note {
1289    /// Create a new note directive.
1290    #[must_use]
1291    pub fn new(
1292        date: NaiveDate,
1293        account: impl Into<crate::Account>,
1294        comment: impl Into<String>,
1295    ) -> Self {
1296        Self {
1297            date,
1298            account: account.into(),
1299            comment: comment.into(),
1300            meta: Metadata::default(),
1301        }
1302    }
1303
1304    /// Set metadata.
1305    #[must_use]
1306    pub fn with_meta(mut self, meta: Metadata) -> Self {
1307        self.meta = meta;
1308        self
1309    }
1310}
1311
1312impl fmt::Display for Note {
1313    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1314        write!(
1315            f,
1316            "{} note {} \"{}\"",
1317            self.date,
1318            self.account,
1319            crate::format::escape_string(&self.comment)
1320        )
1321    }
1322}
1323
1324/// A document directive.
1325///
1326/// Links an external document file to an account.
1327#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1328#[cfg_attr(
1329    feature = "rkyv",
1330    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1331)]
1332pub struct Document {
1333    /// Document date
1334    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1335    pub date: NaiveDate,
1336    /// Account
1337    pub account: crate::Account,
1338    /// File path to the document
1339    pub path: String,
1340    /// Tags
1341    pub tags: Vec<crate::Tag>,
1342    /// Links
1343    pub links: Vec<crate::Link>,
1344    /// Metadata
1345    pub meta: Metadata,
1346}
1347
1348impl Document {
1349    /// Create a new document directive.
1350    #[must_use]
1351    pub fn new(
1352        date: NaiveDate,
1353        account: impl Into<crate::Account>,
1354        path: impl Into<String>,
1355    ) -> Self {
1356        Self {
1357            date,
1358            account: account.into(),
1359            path: path.into(),
1360            tags: Vec::new(),
1361            links: Vec::new(),
1362            meta: Metadata::default(),
1363        }
1364    }
1365
1366    /// Add a tag.
1367    #[must_use]
1368    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1369        self.tags.push(tag.into());
1370        self
1371    }
1372
1373    /// Add a link.
1374    #[must_use]
1375    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1376        self.links.push(link.into());
1377        self
1378    }
1379
1380    /// Set metadata.
1381    #[must_use]
1382    pub fn with_meta(mut self, meta: Metadata) -> Self {
1383        self.meta = meta;
1384        self
1385    }
1386}
1387
1388impl fmt::Display for Document {
1389    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1390        write!(
1391            f,
1392            "{} document {} \"{}\"",
1393            self.date,
1394            self.account,
1395            crate::format::escape_string(&self.path)
1396        )
1397    }
1398}
1399
1400/// A price directive.
1401///
1402/// Records the price of a commodity in another currency.
1403#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1404#[cfg_attr(
1405    feature = "rkyv",
1406    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1407)]
1408pub struct Price {
1409    /// Price date
1410    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1411    pub date: NaiveDate,
1412    /// Currency being priced
1413    pub currency: crate::Currency,
1414    /// Price amount (in another currency)
1415    pub amount: Amount,
1416    /// Metadata
1417    pub meta: Metadata,
1418}
1419
1420impl Price {
1421    /// Create a new price directive.
1422    #[must_use]
1423    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1424        Self {
1425            date,
1426            currency: currency.into(),
1427            amount,
1428            meta: Metadata::default(),
1429        }
1430    }
1431
1432    /// Set metadata.
1433    #[must_use]
1434    pub fn with_meta(mut self, meta: Metadata) -> Self {
1435        self.meta = meta;
1436        self
1437    }
1438}
1439
1440impl fmt::Display for Price {
1441    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1442        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1443    }
1444}
1445
1446/// A custom directive.
1447///
1448/// User-defined directive type for extensions.
1449#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1450#[cfg_attr(
1451    feature = "rkyv",
1452    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1453)]
1454pub struct Custom {
1455    /// Custom directive date
1456    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1457    pub date: NaiveDate,
1458    /// Custom type name (e.g., "budget", "autopay")
1459    pub custom_type: String,
1460    /// Values/arguments for this custom directive
1461    pub values: Vec<MetaValue>,
1462    /// Metadata
1463    pub meta: Metadata,
1464}
1465
1466impl Custom {
1467    /// Create a new custom directive.
1468    #[must_use]
1469    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1470        Self {
1471            date,
1472            custom_type: custom_type.into(),
1473            values: Vec::new(),
1474            meta: Metadata::default(),
1475        }
1476    }
1477
1478    /// Add a value.
1479    #[must_use]
1480    pub fn with_value(mut self, value: MetaValue) -> Self {
1481        self.values.push(value);
1482        self
1483    }
1484
1485    /// Set metadata.
1486    #[must_use]
1487    pub fn with_meta(mut self, meta: Metadata) -> Self {
1488        self.meta = meta;
1489        self
1490    }
1491}
1492
1493impl fmt::Display for Custom {
1494    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1495        write!(
1496            f,
1497            "{} custom \"{}\"",
1498            self.date,
1499            crate::format::escape_string(&self.custom_type)
1500        )?;
1501        for value in &self.values {
1502            write!(f, " {value}")?;
1503        }
1504        Ok(())
1505    }
1506}
1507
1508impl fmt::Display for Directive {
1509    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1510        match self {
1511            Self::Transaction(t) => write!(f, "{t}"),
1512            Self::Balance(b) => write!(f, "{b}"),
1513            Self::Open(o) => write!(f, "{o}"),
1514            Self::Close(c) => write!(f, "{c}"),
1515            Self::Commodity(c) => write!(f, "{c}"),
1516            Self::Pad(p) => write!(f, "{p}"),
1517            Self::Event(e) => write!(f, "{e}"),
1518            Self::Query(q) => write!(f, "{q}"),
1519            Self::Note(n) => write!(f, "{n}"),
1520            Self::Document(d) => write!(f, "{d}"),
1521            Self::Price(p) => write!(f, "{p}"),
1522            Self::Custom(c) => write!(f, "{c}"),
1523        }
1524    }
1525}
1526
1527#[cfg(test)]
1528mod bool_vocabulary_tests {
1529    use super::*;
1530
1531    /// One concept, one vocabulary: what `option "x" "V"` accepts is exactly
1532    /// what `x: V` metadata accepts.
1533    ///
1534    /// The loader's option parser calls [`parse_bool_word`] directly, so this
1535    /// pins the shared set rather than a copy of it. Before they were unified,
1536    /// metadata took `YES`/`T` while the option warned on them.
1537    #[test]
1538    fn options_and_metadata_accept_the_same_spellings() {
1539        for word in ["TRUE", "true", "True", "1"] {
1540            assert_eq!(parse_bool_word(word), Some(true), "{word}");
1541        }
1542        for word in ["FALSE", "false", "False", "0"] {
1543            assert_eq!(parse_bool_word(word), Some(false), "{word}");
1544        }
1545        for word in ["YES", "NO", "T", "F", "on", "", "2", "maybe"] {
1546            assert_eq!(
1547                parse_bool_word(word),
1548                None,
1549                "{word} is not accepted by `option`, so metadata must not take \
1550                 it either — the option parser warns (E7002) and metadata \
1551                 leaves the default"
1552            );
1553        }
1554    }
1555
1556    /// An uppercase bare word's token classification depends on context, so
1557    /// the same declaration can arrive as three different `MetaValue`s.
1558    #[test]
1559    fn a_bare_word_is_read_however_it_lexed() {
1560        assert_eq!(meta_value_as_bool(&MetaValue::Bool(true)), Some(true));
1561        assert_eq!(
1562            meta_value_as_bool(&MetaValue::String("TRUE".into())),
1563            Some(true)
1564        );
1565        assert_eq!(
1566            meta_value_as_bool(&MetaValue::Currency("TRUE".into())),
1567            Some(true)
1568        );
1569        assert_eq!(
1570            meta_value_as_bool(&MetaValue::Currency("FALSE".into())),
1571            Some(false)
1572        );
1573        assert_eq!(meta_value_as_bool(&MetaValue::Currency("USD".into())), None);
1574    }
1575}
1576
1577#[cfg(test)]
1578mod tests {
1579    use super::*;
1580    use rust_decimal_macros::dec;
1581
1582    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1583        crate::naive_date(year, month, day).unwrap()
1584    }
1585
1586    /// Layout/distinctness snapshot for [`MetaValue`] — the rkyv companion to
1587    /// `CostNumber`'s `cost_number_archived_bytes_snapshot`.
1588    ///
1589    /// The cache archives the whole `Directive` graph, including metadata, but the
1590    /// only frozen-byte tripwire pinned `CostNumber`. A `MetaValue` variant
1591    /// reorder or discriminant collision changes the on-disk bytes while
1592    /// `CostNumber` stays identical, so a stale cache would deserialize old bytes
1593    /// into the new layout. This pins that every variant archives non-empty and
1594    /// PAIRWISE-DISTINCT — including same-payload pairs like `Number(42)`/`Int(42)`
1595    /// and `String("USD")`/`Currency("USD")` whose only difference is the
1596    /// discriminant. A collision or reorder trips here; the exact frozen bytes
1597    /// that also catch a *uniform* encoding shift live in `rustledger-loader::cache`.
1598    #[cfg(feature = "rkyv")]
1599    #[test]
1600    fn meta_value_archived_bytes_snapshot() {
1601        let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();
1602
1603        // Same-payload pairs are deliberate so pairwise distinctness exercises the
1604        // discriminant, not the payload.
1605        let cases: &[(&str, MetaValue)] = &[
1606            ("String", MetaValue::String("USD".to_string())),
1607            (
1608                "Account",
1609                MetaValue::Account(crate::Account::from("Assets:Bank")),
1610            ),
1611            (
1612                "Currency",
1613                MetaValue::Currency(crate::Currency::from("USD")),
1614            ),
1615            ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
1616            ("Link", MetaValue::Link(crate::Link::from("t"))),
1617            ("Date", MetaValue::Date(date(2024, 1, 15))),
1618            ("Number", MetaValue::Number(dec!(42))),
1619            ("Bool", MetaValue::Bool(true)),
1620            ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
1621            ("None", MetaValue::None),
1622            ("Int", MetaValue::Int(42)),
1623        ];
1624
1625        let archived: Vec<(&str, Vec<u8>)> =
1626            cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();
1627
1628        for (name, bytes) in &archived {
1629            assert!(
1630                !bytes.is_empty(),
1631                "MetaValue::{name} archived to empty bytes"
1632            );
1633        }
1634        for (i, (na, a)) in archived.iter().enumerate() {
1635            for (nb, b) in archived.iter().skip(i + 1) {
1636                assert_ne!(
1637                    a, b,
1638                    "MetaValue::{na} and MetaValue::{nb} archive identically — a \
1639                     discriminant collision (variant reorder?) the cache can't tell apart"
1640                );
1641            }
1642        }
1643    }
1644
1645    #[test]
1646    fn test_transaction() {
1647        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1648            .with_payee("Whole Foods")
1649            .with_flag('*')
1650            .with_tag("food")
1651            .with_synthesized_posting(Posting::new(
1652                "Expenses:Food",
1653                Amount::new(dec!(50.00), "USD"),
1654            ))
1655            .with_synthesized_posting(Posting::auto("Assets:Checking"));
1656
1657        assert_eq!(txn.flag, '*');
1658        assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1659        assert_eq!(txn.postings.len(), 2);
1660        assert!(txn.is_complete());
1661    }
1662
1663    #[test]
1664    fn test_balance() {
1665        let bal = Balance::new(
1666            date(2024, 1, 1),
1667            "Assets:Checking",
1668            Amount::new(dec!(1000.00), "USD"),
1669        );
1670
1671        assert_eq!(bal.account, "Assets:Checking");
1672        assert_eq!(bal.amount.number, dec!(1000.00));
1673    }
1674
1675    #[test]
1676    fn test_open() {
1677        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1678            .with_currencies(vec!["USD".into()])
1679            .with_booking("FIFO");
1680
1681        assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1682        assert_eq!(open.booking, Some("FIFO".to_string()));
1683    }
1684
1685    #[test]
1686    fn test_directive_date() {
1687        let txn = Transaction::new(date(2024, 1, 15), "Test");
1688        let dir = Directive::Transaction(txn);
1689
1690        assert_eq!(dir.date(), date(2024, 1, 15));
1691        assert!(dir.is_transaction());
1692        assert_eq!(dir.type_name(), "transaction");
1693    }
1694
1695    #[test]
1696    fn test_posting_display() {
1697        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1698        let s = format!("{posting}");
1699        assert!(s.contains("Assets:Checking"));
1700        assert!(s.contains("100.00 USD"));
1701    }
1702
1703    #[test]
1704    fn test_transaction_display() {
1705        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1706            .with_payee("Test Payee")
1707            .with_synthesized_posting(Posting::new(
1708                "Expenses:Test",
1709                Amount::new(dec!(50.00), "USD"),
1710            ))
1711            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1712
1713        let s = format!("{txn}");
1714        assert!(s.contains("2024-01-15"));
1715        assert!(s.contains("Test Payee"));
1716        assert!(s.contains("Test transaction"));
1717    }
1718
1719    #[test]
1720    fn test_directive_priority() {
1721        // Test that priorities are ordered correctly
1722        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1723        assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1724        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1725        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1726        assert!(DirectivePriority::Price < DirectivePriority::Close);
1727    }
1728
1729    #[test]
1730    fn test_sort_directives_by_date() {
1731        let mut directives = vec![
1732            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1733            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1734            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1735        ];
1736
1737        sort_directives(&mut directives);
1738
1739        assert_eq!(directives[0].date(), date(2024, 1, 1));
1740        assert_eq!(directives[1].date(), date(2024, 1, 10));
1741        assert_eq!(directives[2].date(), date(2024, 1, 15));
1742    }
1743
1744    #[test]
1745    fn test_sort_directives_by_type_same_date() {
1746        // On the same date, open should come before transaction, transaction before close
1747        let mut directives = vec![
1748            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1749            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1750            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1751            Directive::Balance(Balance::new(
1752                date(2024, 1, 1),
1753                "Assets:Bank",
1754                Amount::new(dec!(0), "USD"),
1755            )),
1756        ];
1757
1758        sort_directives(&mut directives);
1759
1760        assert_eq!(directives[0].type_name(), "open");
1761        assert_eq!(directives[1].type_name(), "balance");
1762        assert_eq!(directives[2].type_name(), "transaction");
1763        assert_eq!(directives[3].type_name(), "close");
1764    }
1765
1766    #[test]
1767    fn test_sort_directives_pad_before_balance() {
1768        // Pad must come before balance assertion on the same day
1769        let mut directives = vec![
1770            Directive::Balance(Balance::new(
1771                date(2024, 1, 1),
1772                "Assets:Bank",
1773                Amount::new(dec!(1000), "USD"),
1774            )),
1775            Directive::Pad(Pad::new(
1776                date(2024, 1, 1),
1777                "Assets:Bank",
1778                "Equity:Opening-Balances",
1779            )),
1780        ];
1781
1782        sort_directives(&mut directives);
1783
1784        assert_eq!(directives[0].type_name(), "pad");
1785        assert_eq!(directives[1].type_name(), "balance");
1786    }
1787
1788    #[test]
1789    fn same_date_directives_sort_in_file_order() {
1790        // #2093 / #841. These two are the transactions from #841: the
1791        // "Transfer Received" that looks like a reduction is written before
1792        // the "Transfer Sent" that creates the lot.
1793        //
1794        // The sort deliberately does NOT float the augmentation ahead. Python
1795        // books same-date entries by `lineno`, and reordering them changes
1796        // which lots an ambiguous match can see (#2093). #841's ledger is
1797        // handled by `Inventory::is_booking_reduction` instead: with
1798        // `Assets:Transit` empty, the -11.11 posting is an augmentation that
1799        // opens a negative lot, and "Transfer Sent" closes it.
1800        let looks_like_a_reduction = Directive::Transaction(
1801            Transaction::new(date(2024, 9, 1), "Transfer Received")
1802                .with_synthesized_posting(
1803                    Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1804                        CostSpec::empty()
1805                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1806                            .with_currency("EUR"),
1807                    ),
1808                )
1809                .with_synthesized_posting(
1810                    Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1811                        CostSpec::empty()
1812                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1813                            .with_currency("EUR"),
1814                    ),
1815                ),
1816        );
1817
1818        let augmentation = Directive::Transaction(
1819            Transaction::new(date(2024, 9, 1), "Transfer Sent")
1820                .with_synthesized_posting(Posting::new(
1821                    "Assets:AccountA",
1822                    Amount::new(dec!(-10.00), "EUR"),
1823                ))
1824                .with_synthesized_posting(
1825                    Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1826                        CostSpec::empty()
1827                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1828                            .with_currency("EUR"),
1829                    ),
1830                ),
1831        );
1832
1833        let mut directives = vec![looks_like_a_reduction, augmentation];
1834        sort_directives(&mut directives);
1835
1836        let narrations: Vec<&str> = directives
1837            .iter()
1838            .map(|d| match d {
1839                Directive::Transaction(t) => t.narration.as_str(),
1840                _ => unreachable!(),
1841            })
1842            .collect();
1843        assert_eq!(
1844            narrations,
1845            vec!["Transfer Received", "Transfer Sent"],
1846            "same-date directives must keep file order; floating augmentations \
1847             ahead of reductions is what #2093 reported"
1848        );
1849    }
1850
1851    #[test]
1852    fn test_transaction_flags() {
1853        let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1854
1855        // Standard flags
1856        assert!(make_txn('*').is_complete());
1857        assert!(make_txn('!').is_incomplete());
1858        assert!(make_txn('!').is_pending());
1859
1860        // Extended flags
1861        assert!(make_txn('S').is_summarization());
1862        assert!(make_txn('T').is_transfer());
1863        assert!(make_txn('C').is_conversion());
1864        assert!(make_txn('U').is_unrealized());
1865        assert!(make_txn('R').is_return());
1866        assert!(make_txn('M').is_merge());
1867        assert!(make_txn('#').is_bookmarked());
1868        assert!(make_txn('?').needs_investigation());
1869
1870        // Negative cases
1871        assert!(!make_txn('*').is_pending());
1872        assert!(!make_txn('!').is_complete());
1873    }
1874
1875    #[test]
1876    fn test_is_valid_flag() {
1877        // Valid flags
1878        for flag in [
1879            '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1880        ] {
1881            assert!(
1882                Transaction::is_valid_flag(flag),
1883                "Flag '{flag}' should be valid"
1884            );
1885        }
1886
1887        // Invalid flags
1888        for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1889            assert!(
1890                !Transaction::is_valid_flag(flag),
1891                "Flag '{flag}' should be invalid"
1892            );
1893        }
1894    }
1895
1896    #[test]
1897    fn test_transaction_display_includes_metadata() {
1898        let mut meta = Metadata::default();
1899        meta.insert(
1900            "document".to_string(),
1901            MetaValue::String("myfile.pdf".to_string()),
1902        );
1903
1904        let txn = Transaction {
1905            date: date(2026, 2, 23),
1906            flag: '*',
1907            payee: None,
1908            narration: "Example".into(),
1909            tags: vec![],
1910            links: vec![],
1911            meta,
1912            postings: vec![
1913                crate::Spanned::synthesized(Posting::new(
1914                    "Assets:Bank",
1915                    Amount::new(dec!(-2), "USD"),
1916                )),
1917                crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1918            ],
1919            trailing_comments: Vec::new(),
1920        };
1921
1922        let output = txn.to_string();
1923        assert!(
1924            output.contains("document: \"myfile.pdf\""),
1925            "Transaction Display should include metadata: {output}"
1926        );
1927        assert!(
1928            output.contains("Assets:Bank"),
1929            "Transaction Display should include postings: {output}"
1930        );
1931    }
1932
1933    #[test]
1934    fn test_posting_display_includes_metadata() {
1935        let mut meta = Metadata::default();
1936        meta.insert(
1937            "category".to_string(),
1938            MetaValue::String("groceries".to_string()),
1939        );
1940
1941        let posting = Posting {
1942            account: "Expenses:Food".into(),
1943            units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1944            cost: None,
1945            price: None,
1946            flag: None,
1947            meta,
1948            comments: Vec::new(),
1949            trailing_comments: Vec::new(),
1950        };
1951
1952        let output = posting.to_string();
1953        assert!(
1954            output.contains("category: \"groceries\""),
1955            "Posting Display should include metadata: {output}"
1956        );
1957    }
1958
1959    #[test]
1960    fn test_directive_display() {
1961        // Test that Directive enum delegates to inner type's Display
1962        let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1963        let dir = Directive::Transaction(txn.clone());
1964
1965        // Directive::Display should produce same output as Transaction::Display
1966        assert_eq!(format!("{dir}"), format!("{txn}"));
1967
1968        // Test other directive types
1969        let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1970        let dir_open = Directive::Open(open.clone());
1971        assert_eq!(format!("{dir_open}"), format!("{open}"));
1972
1973        let balance = Balance::new(
1974            date(2024, 1, 1),
1975            "Assets:Bank",
1976            Amount::new(dec!(100), "USD"),
1977        );
1978        let dir_balance = Directive::Balance(balance.clone());
1979        assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1980    }
1981
1982    // ----- parse_precision_meta (issue #991) ---------------------------------
1983
1984    #[test]
1985    fn parse_precision_meta_accepts_non_negative_integers() {
1986        // `precision: 2` parses as `Int`; `precision: 2.0` as `Number`. Both
1987        // must validate identically.
1988        assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
1989        assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
1990        assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
1991        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
1992        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
1993        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
1994        // Integer-valued decimals (e.g. `precision: 2.0` in source) must
1995        // round-trip the same as `precision: 2` — the parser will produce
1996        // `Number(dec!(2.0))` for the dotted form.
1997        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
1998        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
1999    }
2000
2001    #[test]
2002    fn parse_precision_meta_rejects_negatives() {
2003        let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
2004        assert!(err.contains("non-negative"), "got: {err}");
2005        let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
2006        assert!(err.contains("non-negative"), "got: {err}");
2007    }
2008
2009    #[test]
2010    fn parse_precision_meta_rejects_fractional() {
2011        let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
2012        assert!(err.contains("integer"), "got: {err}");
2013    }
2014
2015    #[test]
2016    fn parse_precision_meta_rejects_overflow() {
2017        // 2^33 — out of u32 range.
2018        let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
2019        assert!(err.contains("exceeds"), "got: {err}");
2020        let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
2021        assert!(err.contains("exceeds"), "got: {err}");
2022    }
2023
2024    #[test]
2025    fn meta_value_int_display_and_kind() {
2026        assert_eq!(MetaValue::Int(42).to_string(), "42");
2027        assert_eq!(MetaValue::Int(-7).to_string(), "-7");
2028        assert_eq!(
2029            crate::format::format_meta_value(
2030                &MetaValue::Int(42),
2031                &crate::format::FormatConfig::default()
2032            ),
2033            "42"
2034        );
2035        assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
2036    }
2037
2038    #[test]
2039    fn parse_precision_meta_rejects_non_number_variants() {
2040        // Cover every non-Number `MetaValue` variant so the kind-labeling
2041        // arms in `meta_value_kind` are all exercised. Each error message
2042        // names the kind ("string value", "bool value", etc.) so users
2043        // see what they actually wrote.
2044        use crate::Amount;
2045        use rust_decimal_macros::dec;
2046        let cases = [
2047            (MetaValue::String("2".into()), "string"),
2048            (MetaValue::Account("Assets:Cash".into()), "account"),
2049            (MetaValue::Currency("USD".into()), "currency"),
2050            (MetaValue::Tag("foo".into()), "tag"),
2051            (MetaValue::Link("bar".into()), "link"),
2052            (MetaValue::Date(date(2024, 1, 1)), "date"),
2053            (MetaValue::Bool(true), "bool"),
2054            (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
2055            (MetaValue::None, "none"),
2056        ];
2057        for (case, kind) in cases {
2058            let err = match parse_precision_meta(&case) {
2059                Ok(_) => panic!("should have rejected {case:?}"),
2060                Err(e) => e,
2061            };
2062            assert!(
2063                err.contains(kind),
2064                "error for {case:?} should mention kind {kind:?}, got: {err}"
2065            );
2066        }
2067    }
2068}