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    /// Balance assertions are checked at the START of the day, so they
491    /// precede a same-date pad -- which is what makes such a pad UNUSED
492    /// rather than applied (#2150).
493    ///
494    /// beancount's `entry_sortkey` gives Balance `-1` and Pad the default
495    /// `0`. Ours had Pad first, with a comment asserting "padding before
496    /// balance assertions"; the effect was that a `pad` and the `balance`
497    /// it targets on the same date synthesized a padding transaction
498    /// beancount does not, leaving the account richer by the difference
499    /// while our own validator reported the pad as unused in the same run.
500    Balance = 2,
501    /// Padding, after the same-date balance that would have consumed it.
502    Pad = 3,
503    /// Main entries
504    Transaction = 4,
505    /// Annotations after transactions
506    Note = 5,
507    /// Attachments after transactions
508    Document = 6,
509    /// State changes
510    Event = 7,
511    /// Queries defined after data
512    Query = 8,
513    /// Prices at end of day
514    Price = 9,
515    /// Accounts closed after all activity
516    Close = 10,
517    /// User extensions last
518    Custom = 11,
519}
520
521/// All directive types in beancount.
522#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
523#[cfg_attr(
524    feature = "rkyv",
525    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
526)]
527pub enum Directive {
528    /// Transaction directive - records transfers between accounts
529    Transaction(Transaction),
530    /// Balance assertion - asserts an account balance at a point in time
531    Balance(Balance),
532    /// Open account - opens an account for use
533    Open(Open),
534    /// Close account - closes an account
535    Close(Close),
536    /// Commodity declaration - declares a currency/commodity
537    Commodity(Commodity),
538    /// Pad directive - auto-pad an account to match a balance
539    Pad(Pad),
540    /// Event directive - records a life event
541    Event(Event),
542    /// Query directive - stores a named BQL query
543    Query(Query),
544    /// Note directive - adds a note to an account
545    Note(Note),
546    /// Document directive - links a document to an account
547    Document(Document),
548    /// Price directive - records a commodity price
549    Price(Price),
550    /// Custom directive - custom user-defined directive
551    Custom(Custom),
552}
553
554impl Directive {
555    /// Get the date of this directive.
556    #[must_use]
557    pub const fn date(&self) -> NaiveDate {
558        match self {
559            Self::Transaction(t) => t.date,
560            Self::Balance(b) => b.date,
561            Self::Open(o) => o.date,
562            Self::Close(c) => c.date,
563            Self::Commodity(c) => c.date,
564            Self::Pad(p) => p.date,
565            Self::Event(e) => e.date,
566            Self::Query(q) => q.date,
567            Self::Note(n) => n.date,
568            Self::Document(d) => d.date,
569            Self::Price(p) => p.date,
570            Self::Custom(c) => c.date,
571        }
572    }
573
574    /// Get the metadata of this directive.
575    #[must_use]
576    pub const fn meta(&self) -> &Metadata {
577        match self {
578            Self::Transaction(t) => &t.meta,
579            Self::Balance(b) => &b.meta,
580            Self::Open(o) => &o.meta,
581            Self::Close(c) => &c.meta,
582            Self::Commodity(c) => &c.meta,
583            Self::Pad(p) => &p.meta,
584            Self::Event(e) => &e.meta,
585            Self::Query(q) => &q.meta,
586            Self::Note(n) => &n.meta,
587            Self::Document(d) => &d.meta,
588            Self::Price(p) => &p.meta,
589            Self::Custom(c) => &c.meta,
590        }
591    }
592
593    /// Check if this is a transaction.
594    #[must_use]
595    pub const fn is_transaction(&self) -> bool {
596        matches!(self, Self::Transaction(_))
597    }
598
599    /// Get as a transaction, if this is one.
600    #[must_use]
601    pub const fn as_transaction(&self) -> Option<&Transaction> {
602        match self {
603            Self::Transaction(t) => Some(t),
604            _ => None,
605        }
606    }
607
608    /// Get the directive type name.
609    #[must_use]
610    pub const fn type_name(&self) -> &'static str {
611        match self {
612            Self::Transaction(_) => "transaction",
613            Self::Balance(_) => "balance",
614            Self::Open(_) => "open",
615            Self::Close(_) => "close",
616            Self::Commodity(_) => "commodity",
617            Self::Pad(_) => "pad",
618            Self::Event(_) => "event",
619            Self::Query(_) => "query",
620            Self::Note(_) => "note",
621            Self::Document(_) => "document",
622            Self::Price(_) => "price",
623            Self::Custom(_) => "custom",
624        }
625    }
626
627    /// Get the sorting priority for this directive.
628    ///
629    /// Used to determine order when directives have the same date.
630    #[must_use]
631    pub const fn priority(&self) -> DirectivePriority {
632        match self {
633            Self::Open(_) => DirectivePriority::Open,
634            Self::Commodity(_) => DirectivePriority::Commodity,
635            Self::Pad(_) => DirectivePriority::Pad,
636            Self::Balance(_) => DirectivePriority::Balance,
637            Self::Transaction(_) => DirectivePriority::Transaction,
638            Self::Note(_) => DirectivePriority::Note,
639            Self::Document(_) => DirectivePriority::Document,
640            Self::Event(_) => DirectivePriority::Event,
641            Self::Query(_) => DirectivePriority::Query,
642            Self::Price(_) => DirectivePriority::Price,
643            Self::Close(_) => DirectivePriority::Close,
644            Self::Custom(_) => DirectivePriority::Custom,
645        }
646    }
647}
648
649/// Sort directives by date, then type priority.
650///
651/// This is a stable sort, so directives sharing a date and type keep the
652/// order they were parsed in, which is the order they are booked in.
653///
654/// **Within one file that matches Python's `(date, type_priority, lineno)`.
655/// Across `include`s it deliberately does not**, and the difference is
656/// observable in reported gains rather than merely cosmetic.
657///
658/// Python compares `lineno` values taken from different files, so a directive
659/// on line 1 of a file included second sorts ahead of one on line 5 of a file
660/// included first. This sort keeps include order: everything from the first
661/// included file, in its own order, then the second.
662///
663/// Two same-date lots therefore enter the inventory in different orders under
664/// the two rules, and a FIFO sale whose lot-date comparison ties falls through
665/// to that order. Measured against beancount 3.2.3 on the fixture in
666/// `tests/fixtures/cross-file-order/` — one buy at 10.00 on line 5 of the
667/// first included file, one at 20.00 on line 1 of the second — selling one
668/// unit reports a 2x difference in realized gain, with no error either side.
669///
670/// Include order is kept on purpose. Ordering two directives by comparing a
671/// line number from one file against a line number from a different file is
672/// not a fact about the ledger: adding a comment to one file silently changes
673/// which lot a sale in another file consumes. Include order at least reflects
674/// how the author assembled the ledger. Beancount's own rule is not purely
675/// `lineno` driven either — directives sharing a line number across files
676/// fall back to include order through its stable sort.
677///
678/// Pinned by `cross_file_same_date_directives_keep_include_order` and recorded
679/// in `docs/reference/compatibility.md` (#2149).
680pub fn sort_directives(directives: &mut [Directive]) {
681    directives.sort_by_cached_key(booking_sort_key);
682}
683
684/// The canonical booking-order sort key: `(date, priority)`.
685///
686/// Deliberately WITHOUT a reduction tiebreak. Same-date directives book in
687/// parse order (the sorts through this key are stable), and Python likewise
688/// has no notion of augmentations going first — its `entry_sortkey` is
689/// `(date, type_priority, lineno)`.
690///
691/// That equivalence holds within a file and NOT across `include`s; see
692/// [`sort_directives`] for what differs and why include order is kept.
693///
694/// This key used to carry a third component, `has_cost_reduction`, which
695/// floated cost augmentations ahead of the reductions matching against them
696/// so that lots would exist when matched (#841). That was a workaround for a
697/// missing behavior rather than an ordering problem, and the behavior has
698/// since landed: a cost-bearing posting is a REDUCTION only when the account
699/// already holds the opposite sign in that currency
700/// ([`crate::Inventory::is_booking_reduction`], #1560, mirroring Python's
701/// `balance.is_reduced_by`). Otherwise it is an augmentation and simply opens
702/// a negative lot for a later posting to close. #841's ledger passes on that
703/// path alone, with no reordering — and reordering actively broke ledgers
704/// Python accepts (#2093), because moving a same-date augmentation ahead of a
705/// reduction changes which lots an ambiguous match can see.
706///
707/// This is the SINGLE source of the booking order. [`sort_directives`] sorts a
708/// slice in place by it, and both `book()` (rustledger-booking) and
709/// `run_booking()` (rustledger-loader) key an index permutation through it
710/// (they preserve the input order for reassembly, so they cannot sort the slice
711/// directly). Change a booking-order tiebreak here only.
712#[must_use]
713pub const fn booking_sort_key(d: &Directive) -> (NaiveDate, DirectivePriority) {
714    (d.date(), d.priority())
715}
716
717/// A transaction directive.
718///
719/// Transactions are the most common directive type. They record transfers
720/// between accounts and must balance (sum of all postings equals zero).
721#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
722#[cfg_attr(
723    feature = "rkyv",
724    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
725)]
726pub struct Transaction {
727    /// Transaction date
728    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
729    pub date: NaiveDate,
730    /// Transaction flag (* or !)
731    pub flag: char,
732    /// Payee (optional)
733    #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
734    pub payee: Option<InternedStr>,
735    /// Narration (description)
736    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
737    pub narration: InternedStr,
738    /// Tags attached to this transaction
739    pub tags: Vec<crate::Tag>,
740    /// Links attached to this transaction
741    pub links: Vec<crate::Link>,
742    /// Transaction metadata
743    pub meta: Metadata,
744    /// Postings (account entries), each wrapped with its source span and
745    /// file ID. Parser-emitted postings carry the byte range of the
746    /// posting line (from leading indent through trailing same-line
747    /// comment, not including following metadata lines); programmatically
748    /// constructed postings use [`crate::Spanned::synthesized`] which
749    /// pairs [`crate::Span::ZERO`] with [`crate::SYNTHESIZED_FILE_ID`].
750    pub postings: Vec<crate::Spanned<Posting>>,
751    /// Comments that appear after all postings
752    #[serde(default, skip_serializing_if = "Vec::is_empty")]
753    pub trailing_comments: Vec<String>,
754}
755
756impl Transaction {
757    /// Create a new transaction.
758    #[must_use]
759    pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
760        Self {
761            date,
762            flag: '*',
763            payee: None,
764            narration: narration.into(),
765            tags: Vec::new(),
766            links: Vec::new(),
767            meta: Metadata::default(),
768            postings: Vec::new(),
769            trailing_comments: Vec::new(),
770        }
771    }
772
773    /// Set the flag.
774    #[must_use]
775    pub const fn with_flag(mut self, flag: char) -> Self {
776        self.flag = flag;
777        self
778    }
779
780    /// Set the payee.
781    #[must_use]
782    pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
783        self.payee = Some(payee.into());
784        self
785    }
786
787    /// Add a tag.
788    #[must_use]
789    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
790        self.tags.push(tag.into());
791        self
792    }
793
794    /// Add a link.
795    #[must_use]
796    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
797        self.links.push(link.into());
798        self
799    }
800
801    /// Add a posting that already carries source location metadata.
802    /// Use this when constructing transactions from source (e.g.
803    /// a parser implementation) or when round-tripping through a
804    /// plugin that preserves spans.
805    #[must_use]
806    pub fn with_posting(mut self, posting: crate::Spanned<Posting>) -> Self {
807        self.postings.push(posting);
808        self
809    }
810
811    /// Add a programmatically-built posting (no source representation),
812    /// wrapping it with [`crate::Spanned::synthesized`]. Use this from
813    /// test fixtures, CLI commands, and importers that build directives
814    /// in memory rather than parsing them. The name is intentionally
815    /// longer than [`Self::with_posting`] so that synthesis is visible
816    /// at the call site — silently dropping a real span is the foot-gun
817    /// this rename prevents.
818    #[must_use]
819    pub fn with_synthesized_posting(mut self, posting: Posting) -> Self {
820        self.postings.push(crate::Spanned::synthesized(posting));
821        self
822    }
823
824    /// Check if this transaction is marked as complete (*).
825    #[must_use]
826    pub const fn is_complete(&self) -> bool {
827        self.flag == '*'
828    }
829
830    /// Check if this transaction is marked as incomplete/pending (!).
831    #[must_use]
832    pub const fn is_incomplete(&self) -> bool {
833        self.flag == '!'
834    }
835
836    /// Check if this transaction is marked as pending (!).
837    /// Alias for `is_incomplete`.
838    #[must_use]
839    pub const fn is_pending(&self) -> bool {
840        self.flag == '!'
841    }
842
843    /// Check if this is a summarization transaction (S).
844    #[must_use]
845    pub const fn is_summarization(&self) -> bool {
846        self.flag == 'S'
847    }
848
849    /// Check if this is a transfer transaction (T).
850    #[must_use]
851    pub const fn is_transfer(&self) -> bool {
852        self.flag == 'T'
853    }
854
855    /// Check if this is a currency conversion transaction (C).
856    #[must_use]
857    pub const fn is_conversion(&self) -> bool {
858        self.flag == 'C'
859    }
860
861    /// Check if this is an unrealized gains transaction (U).
862    #[must_use]
863    pub const fn is_unrealized(&self) -> bool {
864        self.flag == 'U'
865    }
866
867    /// Check if this is a return/dividend transaction (R).
868    #[must_use]
869    pub const fn is_return(&self) -> bool {
870        self.flag == 'R'
871    }
872
873    /// Check if this is a merge transaction (M).
874    #[must_use]
875    pub const fn is_merge(&self) -> bool {
876        self.flag == 'M'
877    }
878
879    /// Check if this transaction is bookmarked (#).
880    #[must_use]
881    pub const fn is_bookmarked(&self) -> bool {
882        self.flag == '#'
883    }
884
885    /// Check if this transaction needs investigation (?).
886    #[must_use]
887    pub const fn needs_investigation(&self) -> bool {
888        self.flag == '?'
889    }
890
891    /// Check if the given character is a valid transaction flag.
892    #[must_use]
893    pub const fn is_valid_flag(flag: char) -> bool {
894        matches!(
895            flag,
896            '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
897        )
898    }
899}
900
901impl fmt::Display for Transaction {
902    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
903        write!(f, "{} {} ", self.date, self.flag)?;
904        if let Some(payee) = &self.payee {
905            write!(f, "\"{}\" ", crate::format::escape_string(payee))?;
906        }
907        write!(f, "\"{}\"", crate::format::escape_string(&self.narration))?;
908        for tag in &self.tags {
909            write!(f, " #{tag}")?;
910        }
911        for link in &self.links {
912            write!(f, " ^{link}")?;
913        }
914        // Transaction-level metadata
915        for (key, value) in &self.meta {
916            write!(f, "\n  {key}: {value}")?;
917        }
918        for posting in &self.postings {
919            write!(f, "\n{posting}")?;
920        }
921        Ok(())
922    }
923}
924
925/// A balance assertion directive.
926///
927/// Asserts that an account has a specific balance at the beginning of a date.
928#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
929#[cfg_attr(
930    feature = "rkyv",
931    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
932)]
933pub struct Balance {
934    /// Assertion date
935    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
936    pub date: NaiveDate,
937    /// Account to check
938    pub account: crate::Account,
939    /// Expected amount
940    pub amount: Amount,
941    /// Tolerance (if explicitly specified)
942    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
943    pub tolerance: Option<Decimal>,
944    /// Metadata
945    pub meta: Metadata,
946}
947
948impl Balance {
949    /// Create a new balance assertion.
950    #[must_use]
951    pub fn new(date: NaiveDate, account: impl Into<crate::Account>, amount: Amount) -> Self {
952        Self {
953            date,
954            account: account.into(),
955            amount,
956            tolerance: None,
957            meta: Metadata::default(),
958        }
959    }
960
961    /// Set explicit tolerance.
962    #[must_use]
963    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
964        self.tolerance = Some(tolerance);
965        self
966    }
967
968    /// Set metadata.
969    #[must_use]
970    pub fn with_meta(mut self, meta: Metadata) -> Self {
971        self.meta = meta;
972        self
973    }
974}
975
976impl fmt::Display for Balance {
977    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
978        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
979        if let Some(tol) = self.tolerance {
980            write!(f, " ~ {tol}")?;
981        }
982        Ok(())
983    }
984}
985
986/// An open account directive.
987///
988/// Opens an account for use. Accounts must be opened before they can be used.
989#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
990#[cfg_attr(
991    feature = "rkyv",
992    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
993)]
994pub struct Open {
995    /// Date account was opened
996    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
997    pub date: NaiveDate,
998    /// Account name (e.g., "Assets:Bank:Checking")
999    pub account: crate::Account,
1000    /// Allowed currencies (empty = any currency allowed)
1001    pub currencies: Vec<crate::Currency>,
1002    /// Booking method for this account
1003    pub booking: Option<String>,
1004    /// Metadata
1005    pub meta: Metadata,
1006}
1007
1008impl Open {
1009    /// Create a new open directive.
1010    #[must_use]
1011    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1012        Self {
1013            date,
1014            account: account.into(),
1015            currencies: Vec::new(),
1016            booking: None,
1017            meta: Metadata::default(),
1018        }
1019    }
1020
1021    /// Set allowed currencies.
1022    #[must_use]
1023    pub fn with_currencies(mut self, currencies: Vec<crate::Currency>) -> Self {
1024        self.currencies = currencies;
1025        self
1026    }
1027
1028    /// Set booking method.
1029    #[must_use]
1030    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
1031        self.booking = Some(booking.into());
1032        self
1033    }
1034
1035    /// Set metadata.
1036    #[must_use]
1037    pub fn with_meta(mut self, meta: Metadata) -> Self {
1038        self.meta = meta;
1039        self
1040    }
1041}
1042
1043impl fmt::Display for Open {
1044    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1045        write!(f, "{} open {}", self.date, self.account)?;
1046        if !self.currencies.is_empty() {
1047            let currencies: Vec<&str> = self
1048                .currencies
1049                .iter()
1050                .map(crate::Currency::as_str)
1051                .collect();
1052            write!(f, " {}", currencies.join(","))?;
1053        }
1054        if let Some(booking) = &self.booking {
1055            write!(f, " \"{}\"", crate::format::escape_string(booking))?;
1056        }
1057        Ok(())
1058    }
1059}
1060
1061/// A close account directive.
1062///
1063/// Closes an account. The account should have zero balance when closed.
1064#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1065#[cfg_attr(
1066    feature = "rkyv",
1067    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1068)]
1069pub struct Close {
1070    /// Date account was closed
1071    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1072    pub date: NaiveDate,
1073    /// Account name
1074    pub account: crate::Account,
1075    /// Metadata
1076    pub meta: Metadata,
1077}
1078
1079impl Close {
1080    /// Create a new close directive.
1081    #[must_use]
1082    pub fn new(date: NaiveDate, account: impl Into<crate::Account>) -> Self {
1083        Self {
1084            date,
1085            account: account.into(),
1086            meta: Metadata::default(),
1087        }
1088    }
1089
1090    /// Set metadata.
1091    #[must_use]
1092    pub fn with_meta(mut self, meta: Metadata) -> Self {
1093        self.meta = meta;
1094        self
1095    }
1096}
1097
1098impl fmt::Display for Close {
1099    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1100        write!(f, "{} close {}", self.date, self.account)
1101    }
1102}
1103
1104/// A commodity declaration directive.
1105///
1106/// Declares a commodity/currency that can be used in the ledger.
1107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1108#[cfg_attr(
1109    feature = "rkyv",
1110    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1111)]
1112pub struct Commodity {
1113    /// Declaration date
1114    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1115    pub date: NaiveDate,
1116    /// Currency/commodity code (e.g., "USD", "AAPL")
1117    pub currency: crate::Currency,
1118    /// Metadata
1119    pub meta: Metadata,
1120}
1121
1122impl Commodity {
1123    /// Create a new commodity declaration.
1124    #[must_use]
1125    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>) -> Self {
1126        Self {
1127            date,
1128            currency: currency.into(),
1129            meta: Metadata::default(),
1130        }
1131    }
1132
1133    /// Set metadata.
1134    #[must_use]
1135    pub fn with_meta(mut self, meta: Metadata) -> Self {
1136        self.meta = meta;
1137        self
1138    }
1139}
1140
1141impl fmt::Display for Commodity {
1142    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1143        write!(f, "{} commodity {}", self.date, self.currency)
1144    }
1145}
1146
1147/// A pad directive.
1148///
1149/// Automatically inserts a transaction to pad an account to match
1150/// a subsequent balance assertion.
1151#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1152#[cfg_attr(
1153    feature = "rkyv",
1154    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1155)]
1156pub struct Pad {
1157    /// Pad date
1158    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1159    pub date: NaiveDate,
1160    /// Account to pad
1161    pub account: crate::Account,
1162    /// Source account for padding (e.g., Equity:Opening-Balances)
1163    pub source_account: crate::Account,
1164    /// Metadata
1165    pub meta: Metadata,
1166}
1167
1168impl Pad {
1169    /// Create a new pad directive.
1170    #[must_use]
1171    pub fn new(
1172        date: NaiveDate,
1173        account: impl Into<crate::Account>,
1174        source_account: impl Into<crate::Account>,
1175    ) -> Self {
1176        Self {
1177            date,
1178            account: account.into(),
1179            source_account: source_account.into(),
1180            meta: Metadata::default(),
1181        }
1182    }
1183
1184    /// Set metadata.
1185    #[must_use]
1186    pub fn with_meta(mut self, meta: Metadata) -> Self {
1187        self.meta = meta;
1188        self
1189    }
1190}
1191
1192impl fmt::Display for Pad {
1193    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1194        write!(
1195            f,
1196            "{} pad {} {}",
1197            self.date, self.account, self.source_account
1198        )
1199    }
1200}
1201
1202/// An event directive.
1203///
1204/// Records a life event (e.g., location changes, employment changes).
1205#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1206#[cfg_attr(
1207    feature = "rkyv",
1208    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1209)]
1210pub struct Event {
1211    /// Event date
1212    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1213    pub date: NaiveDate,
1214    /// Event type (e.g., "location", "employer")
1215    pub event_type: String,
1216    /// Event value
1217    pub value: String,
1218    /// Metadata
1219    pub meta: Metadata,
1220}
1221
1222impl Event {
1223    /// Create a new event directive.
1224    #[must_use]
1225    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
1226        Self {
1227            date,
1228            event_type: event_type.into(),
1229            value: value.into(),
1230            meta: Metadata::default(),
1231        }
1232    }
1233
1234    /// Set metadata.
1235    #[must_use]
1236    pub fn with_meta(mut self, meta: Metadata) -> Self {
1237        self.meta = meta;
1238        self
1239    }
1240}
1241
1242impl fmt::Display for Event {
1243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1244        write!(
1245            f,
1246            "{} event \"{}\" \"{}\"",
1247            self.date,
1248            crate::format::escape_string(&self.event_type),
1249            crate::format::escape_string(&self.value)
1250        )
1251    }
1252}
1253
1254/// A query directive.
1255///
1256/// Stores a named BQL query that can be referenced later.
1257#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1258#[cfg_attr(
1259    feature = "rkyv",
1260    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1261)]
1262pub struct Query {
1263    /// Query date
1264    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1265    pub date: NaiveDate,
1266    /// Query name
1267    pub name: String,
1268    /// BQL query string
1269    pub query: String,
1270    /// Metadata
1271    pub meta: Metadata,
1272}
1273
1274impl Query {
1275    /// Create a new query directive.
1276    #[must_use]
1277    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
1278        Self {
1279            date,
1280            name: name.into(),
1281            query: query.into(),
1282            meta: Metadata::default(),
1283        }
1284    }
1285
1286    /// Set metadata.
1287    #[must_use]
1288    pub fn with_meta(mut self, meta: Metadata) -> Self {
1289        self.meta = meta;
1290        self
1291    }
1292}
1293
1294impl fmt::Display for Query {
1295    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1296        write!(
1297            f,
1298            "{} query \"{}\" \"{}\"",
1299            self.date,
1300            crate::format::escape_string(&self.name),
1301            crate::format::escape_string(&self.query)
1302        )
1303    }
1304}
1305
1306/// A note directive.
1307///
1308/// Adds a note/comment to an account on a specific date.
1309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1310#[cfg_attr(
1311    feature = "rkyv",
1312    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1313)]
1314pub struct Note {
1315    /// Note date
1316    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1317    pub date: NaiveDate,
1318    /// Account
1319    pub account: crate::Account,
1320    /// Note text
1321    pub comment: String,
1322    /// Tags, from `#tag` on the note header.
1323    ///
1324    /// beancount v3 accepts these on a `note`, and so did our parser -- it
1325    /// just had nowhere to put them, so they were dropped after parsing
1326    /// (#2160). `Document` has carried them all along; this mirrors it.
1327    pub tags: Vec<crate::Tag>,
1328    /// Links, from `^link` on the note header. See `tags`.
1329    pub links: Vec<crate::Link>,
1330    /// Metadata
1331    pub meta: Metadata,
1332}
1333
1334impl Note {
1335    /// Create a new note directive.
1336    #[must_use]
1337    pub fn new(
1338        date: NaiveDate,
1339        account: impl Into<crate::Account>,
1340        comment: impl Into<String>,
1341    ) -> Self {
1342        Self {
1343            date,
1344            account: account.into(),
1345            comment: comment.into(),
1346            tags: Vec::new(),
1347            links: Vec::new(),
1348            meta: Metadata::default(),
1349        }
1350    }
1351
1352    /// Set tags.
1353    #[must_use]
1354    pub fn with_tags(mut self, tags: Vec<crate::Tag>) -> Self {
1355        self.tags = tags;
1356        self
1357    }
1358
1359    /// Set links.
1360    #[must_use]
1361    pub fn with_links(mut self, links: Vec<crate::Link>) -> Self {
1362        self.links = links;
1363        self
1364    }
1365
1366    /// Set metadata.
1367    #[must_use]
1368    pub fn with_meta(mut self, meta: Metadata) -> Self {
1369        self.meta = meta;
1370        self
1371    }
1372}
1373
1374impl fmt::Display for Note {
1375    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1376        write!(
1377            f,
1378            "{} note {} \"{}\"",
1379            self.date,
1380            self.account,
1381            crate::format::escape_string(&self.comment)
1382        )
1383    }
1384}
1385
1386/// A document directive.
1387///
1388/// Links an external document file to an account.
1389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1390#[cfg_attr(
1391    feature = "rkyv",
1392    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1393)]
1394pub struct Document {
1395    /// Document date
1396    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1397    pub date: NaiveDate,
1398    /// Account
1399    pub account: crate::Account,
1400    /// File path to the document
1401    pub path: String,
1402    /// Tags
1403    pub tags: Vec<crate::Tag>,
1404    /// Links
1405    pub links: Vec<crate::Link>,
1406    /// Metadata
1407    pub meta: Metadata,
1408}
1409
1410impl Document {
1411    /// Create a new document directive.
1412    #[must_use]
1413    pub fn new(
1414        date: NaiveDate,
1415        account: impl Into<crate::Account>,
1416        path: impl Into<String>,
1417    ) -> Self {
1418        Self {
1419            date,
1420            account: account.into(),
1421            path: path.into(),
1422            tags: Vec::new(),
1423            links: Vec::new(),
1424            meta: Metadata::default(),
1425        }
1426    }
1427
1428    /// Add a tag.
1429    #[must_use]
1430    pub fn with_tag(mut self, tag: impl Into<crate::Tag>) -> Self {
1431        self.tags.push(tag.into());
1432        self
1433    }
1434
1435    /// Add a link.
1436    #[must_use]
1437    pub fn with_link(mut self, link: impl Into<crate::Link>) -> Self {
1438        self.links.push(link.into());
1439        self
1440    }
1441
1442    /// Set metadata.
1443    #[must_use]
1444    pub fn with_meta(mut self, meta: Metadata) -> Self {
1445        self.meta = meta;
1446        self
1447    }
1448}
1449
1450impl fmt::Display for Document {
1451    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1452        write!(
1453            f,
1454            "{} document {} \"{}\"",
1455            self.date,
1456            self.account,
1457            crate::format::escape_string(&self.path)
1458        )
1459    }
1460}
1461
1462/// A price directive.
1463///
1464/// Records the price of a commodity in another currency.
1465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1466#[cfg_attr(
1467    feature = "rkyv",
1468    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1469)]
1470pub struct Price {
1471    /// Price date
1472    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1473    pub date: NaiveDate,
1474    /// Currency being priced
1475    pub currency: crate::Currency,
1476    /// Price amount (in another currency)
1477    pub amount: Amount,
1478    /// Metadata
1479    pub meta: Metadata,
1480}
1481
1482impl Price {
1483    /// Create a new price directive.
1484    #[must_use]
1485    pub fn new(date: NaiveDate, currency: impl Into<crate::Currency>, amount: Amount) -> Self {
1486        Self {
1487            date,
1488            currency: currency.into(),
1489            amount,
1490            meta: Metadata::default(),
1491        }
1492    }
1493
1494    /// Set metadata.
1495    #[must_use]
1496    pub fn with_meta(mut self, meta: Metadata) -> Self {
1497        self.meta = meta;
1498        self
1499    }
1500}
1501
1502impl fmt::Display for Price {
1503    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1504        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1505    }
1506}
1507
1508/// A custom directive.
1509///
1510/// User-defined directive type for extensions.
1511#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1512#[cfg_attr(
1513    feature = "rkyv",
1514    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1515)]
1516pub struct Custom {
1517    /// Custom directive date
1518    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1519    pub date: NaiveDate,
1520    /// Custom type name (e.g., "budget", "autopay")
1521    pub custom_type: String,
1522    /// Values/arguments for this custom directive
1523    pub values: Vec<MetaValue>,
1524    /// Metadata
1525    pub meta: Metadata,
1526}
1527
1528impl Custom {
1529    /// Create a new custom directive.
1530    #[must_use]
1531    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1532        Self {
1533            date,
1534            custom_type: custom_type.into(),
1535            values: Vec::new(),
1536            meta: Metadata::default(),
1537        }
1538    }
1539
1540    /// Add a value.
1541    #[must_use]
1542    pub fn with_value(mut self, value: MetaValue) -> Self {
1543        self.values.push(value);
1544        self
1545    }
1546
1547    /// Set metadata.
1548    #[must_use]
1549    pub fn with_meta(mut self, meta: Metadata) -> Self {
1550        self.meta = meta;
1551        self
1552    }
1553}
1554
1555impl fmt::Display for Custom {
1556    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1557        write!(
1558            f,
1559            "{} custom \"{}\"",
1560            self.date,
1561            crate::format::escape_string(&self.custom_type)
1562        )?;
1563        for value in &self.values {
1564            write!(f, " {value}")?;
1565        }
1566        Ok(())
1567    }
1568}
1569
1570impl fmt::Display for Directive {
1571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1572        match self {
1573            Self::Transaction(t) => write!(f, "{t}"),
1574            Self::Balance(b) => write!(f, "{b}"),
1575            Self::Open(o) => write!(f, "{o}"),
1576            Self::Close(c) => write!(f, "{c}"),
1577            Self::Commodity(c) => write!(f, "{c}"),
1578            Self::Pad(p) => write!(f, "{p}"),
1579            Self::Event(e) => write!(f, "{e}"),
1580            Self::Query(q) => write!(f, "{q}"),
1581            Self::Note(n) => write!(f, "{n}"),
1582            Self::Document(d) => write!(f, "{d}"),
1583            Self::Price(p) => write!(f, "{p}"),
1584            Self::Custom(c) => write!(f, "{c}"),
1585        }
1586    }
1587}
1588
1589#[cfg(test)]
1590mod bool_vocabulary_tests {
1591    use super::*;
1592
1593    /// One concept, one vocabulary: what `option "x" "V"` accepts is exactly
1594    /// what `x: V` metadata accepts.
1595    ///
1596    /// The loader's option parser calls [`parse_bool_word`] directly, so this
1597    /// pins the shared set rather than a copy of it. Before they were unified,
1598    /// metadata took `YES`/`T` while the option warned on them.
1599    #[test]
1600    fn options_and_metadata_accept_the_same_spellings() {
1601        for word in ["TRUE", "true", "True", "1"] {
1602            assert_eq!(parse_bool_word(word), Some(true), "{word}");
1603        }
1604        for word in ["FALSE", "false", "False", "0"] {
1605            assert_eq!(parse_bool_word(word), Some(false), "{word}");
1606        }
1607        for word in ["YES", "NO", "T", "F", "on", "", "2", "maybe"] {
1608            assert_eq!(
1609                parse_bool_word(word),
1610                None,
1611                "{word} is not accepted by `option`, so metadata must not take \
1612                 it either — the option parser warns (E7002) and metadata \
1613                 leaves the default"
1614            );
1615        }
1616    }
1617
1618    /// An uppercase bare word's token classification depends on context, so
1619    /// the same declaration can arrive as three different `MetaValue`s.
1620    #[test]
1621    fn a_bare_word_is_read_however_it_lexed() {
1622        assert_eq!(meta_value_as_bool(&MetaValue::Bool(true)), Some(true));
1623        assert_eq!(
1624            meta_value_as_bool(&MetaValue::String("TRUE".into())),
1625            Some(true)
1626        );
1627        assert_eq!(
1628            meta_value_as_bool(&MetaValue::Currency("TRUE".into())),
1629            Some(true)
1630        );
1631        assert_eq!(
1632            meta_value_as_bool(&MetaValue::Currency("FALSE".into())),
1633            Some(false)
1634        );
1635        assert_eq!(meta_value_as_bool(&MetaValue::Currency("USD".into())), None);
1636    }
1637}
1638
1639#[cfg(test)]
1640mod tests {
1641    use super::*;
1642    use rust_decimal_macros::dec;
1643
1644    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1645        crate::naive_date(year, month, day).unwrap()
1646    }
1647
1648    /// Layout/distinctness snapshot for [`MetaValue`] — the rkyv companion to
1649    /// `CostNumber`'s `cost_number_archived_bytes_snapshot`.
1650    ///
1651    /// The cache archives the whole `Directive` graph, including metadata, but the
1652    /// only frozen-byte tripwire pinned `CostNumber`. A `MetaValue` variant
1653    /// reorder or discriminant collision changes the on-disk bytes while
1654    /// `CostNumber` stays identical, so a stale cache would deserialize old bytes
1655    /// into the new layout. This pins that every variant archives non-empty and
1656    /// PAIRWISE-DISTINCT — including same-payload pairs like `Number(42)`/`Int(42)`
1657    /// and `String("USD")`/`Currency("USD")` whose only difference is the
1658    /// discriminant. A collision or reorder trips here; the exact frozen bytes
1659    /// that also catch a *uniform* encoding shift live in `rustledger-loader::cache`.
1660    #[cfg(feature = "rkyv")]
1661    #[test]
1662    fn meta_value_archived_bytes_snapshot() {
1663        let archive = |mv: &MetaValue| rkyv::to_bytes::<rkyv::rancor::Error>(mv).unwrap().to_vec();
1664
1665        // Same-payload pairs are deliberate so pairwise distinctness exercises the
1666        // discriminant, not the payload.
1667        let cases: &[(&str, MetaValue)] = &[
1668            ("String", MetaValue::String("USD".to_string())),
1669            (
1670                "Account",
1671                MetaValue::Account(crate::Account::from("Assets:Bank")),
1672            ),
1673            (
1674                "Currency",
1675                MetaValue::Currency(crate::Currency::from("USD")),
1676            ),
1677            ("Tag", MetaValue::Tag(crate::Tag::from("t"))),
1678            ("Link", MetaValue::Link(crate::Link::from("t"))),
1679            ("Date", MetaValue::Date(date(2024, 1, 15))),
1680            ("Number", MetaValue::Number(dec!(42))),
1681            ("Bool", MetaValue::Bool(true)),
1682            ("Amount", MetaValue::Amount(Amount::new(dec!(10), "USD"))),
1683            ("None", MetaValue::None),
1684            ("Int", MetaValue::Int(42)),
1685        ];
1686
1687        let archived: Vec<(&str, Vec<u8>)> =
1688            cases.iter().map(|(n, mv)| (*n, archive(mv))).collect();
1689
1690        for (name, bytes) in &archived {
1691            assert!(
1692                !bytes.is_empty(),
1693                "MetaValue::{name} archived to empty bytes"
1694            );
1695        }
1696        for (i, (na, a)) in archived.iter().enumerate() {
1697            for (nb, b) in archived.iter().skip(i + 1) {
1698                assert_ne!(
1699                    a, b,
1700                    "MetaValue::{na} and MetaValue::{nb} archive identically — a \
1701                     discriminant collision (variant reorder?) the cache can't tell apart"
1702                );
1703            }
1704        }
1705    }
1706
1707    #[test]
1708    fn test_transaction() {
1709        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1710            .with_payee("Whole Foods")
1711            .with_flag('*')
1712            .with_tag("food")
1713            .with_synthesized_posting(Posting::new(
1714                "Expenses:Food",
1715                Amount::new(dec!(50.00), "USD"),
1716            ))
1717            .with_synthesized_posting(Posting::auto("Assets:Checking"));
1718
1719        assert_eq!(txn.flag, '*');
1720        assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1721        assert_eq!(txn.postings.len(), 2);
1722        assert!(txn.is_complete());
1723    }
1724
1725    #[test]
1726    fn test_balance() {
1727        let bal = Balance::new(
1728            date(2024, 1, 1),
1729            "Assets:Checking",
1730            Amount::new(dec!(1000.00), "USD"),
1731        );
1732
1733        assert_eq!(bal.account, "Assets:Checking");
1734        assert_eq!(bal.amount.number, dec!(1000.00));
1735    }
1736
1737    #[test]
1738    fn test_open() {
1739        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1740            .with_currencies(vec!["USD".into()])
1741            .with_booking("FIFO");
1742
1743        assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1744        assert_eq!(open.booking, Some("FIFO".to_string()));
1745    }
1746
1747    #[test]
1748    fn test_directive_date() {
1749        let txn = Transaction::new(date(2024, 1, 15), "Test");
1750        let dir = Directive::Transaction(txn);
1751
1752        assert_eq!(dir.date(), date(2024, 1, 15));
1753        assert!(dir.is_transaction());
1754        assert_eq!(dir.type_name(), "transaction");
1755    }
1756
1757    #[test]
1758    fn test_posting_display() {
1759        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1760        let s = format!("{posting}");
1761        assert!(s.contains("Assets:Checking"));
1762        assert!(s.contains("100.00 USD"));
1763    }
1764
1765    #[test]
1766    fn test_transaction_display() {
1767        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1768            .with_payee("Test Payee")
1769            .with_synthesized_posting(Posting::new(
1770                "Expenses:Test",
1771                Amount::new(dec!(50.00), "USD"),
1772            ))
1773            .with_synthesized_posting(Posting::auto("Assets:Cash"));
1774
1775        let s = format!("{txn}");
1776        assert!(s.contains("2024-01-15"));
1777        assert!(s.contains("Test Payee"));
1778        assert!(s.contains("Test transaction"));
1779    }
1780
1781    #[test]
1782    fn test_directive_priority() {
1783        // Test that priorities are ordered correctly
1784        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1785        // Balance BEFORE Pad: a balance is checked at the start of the day,
1786        // so a same-date pad has nothing left to satisfy and is unused
1787        // (#2150). This assertion previously ran the other way.
1788        assert!(DirectivePriority::Balance < DirectivePriority::Pad);
1789        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1790        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1791        assert!(DirectivePriority::Price < DirectivePriority::Close);
1792    }
1793
1794    #[test]
1795    fn test_sort_directives_by_date() {
1796        let mut directives = vec![
1797            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1798            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1799            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1800        ];
1801
1802        sort_directives(&mut directives);
1803
1804        assert_eq!(directives[0].date(), date(2024, 1, 1));
1805        assert_eq!(directives[1].date(), date(2024, 1, 10));
1806        assert_eq!(directives[2].date(), date(2024, 1, 15));
1807    }
1808
1809    #[test]
1810    fn test_sort_directives_by_type_same_date() {
1811        // On the same date, open should come before transaction, transaction before close
1812        let mut directives = vec![
1813            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1814            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1815            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1816            Directive::Balance(Balance::new(
1817                date(2024, 1, 1),
1818                "Assets:Bank",
1819                Amount::new(dec!(0), "USD"),
1820            )),
1821        ];
1822
1823        sort_directives(&mut directives);
1824
1825        assert_eq!(directives[0].type_name(), "open");
1826        assert_eq!(directives[1].type_name(), "balance");
1827        assert_eq!(directives[2].type_name(), "transaction");
1828        assert_eq!(directives[3].type_name(), "close");
1829    }
1830
1831    #[test]
1832    fn test_sort_directives_balance_before_pad() {
1833        // A balance is checked at the START of its day, so it precedes a
1834        // same-date pad -- and that is exactly why such a pad has nothing left
1835        // to satisfy and is reported unused (#2150).
1836        //
1837        // This test asserted the reverse, down to its name. Under that order a
1838        // pad and the balance it targets on one date synthesized a padding
1839        // transaction beancount does not: on the issue's fixture beancount
1840        // reports 40.00 USD for the account and we reported 100.00.
1841        let mut directives = vec![
1842            Directive::Balance(Balance::new(
1843                date(2024, 1, 1),
1844                "Assets:Bank",
1845                Amount::new(dec!(1000), "USD"),
1846            )),
1847            Directive::Pad(Pad::new(
1848                date(2024, 1, 1),
1849                "Assets:Bank",
1850                "Equity:Opening-Balances",
1851            )),
1852        ];
1853
1854        sort_directives(&mut directives);
1855
1856        assert_eq!(directives[0].type_name(), "balance");
1857        assert_eq!(directives[1].type_name(), "pad");
1858    }
1859
1860    #[test]
1861    fn same_date_directives_sort_in_file_order() {
1862        // #2093 / #841. These two are the transactions from #841: the
1863        // "Transfer Received" that looks like a reduction is written before
1864        // the "Transfer Sent" that creates the lot.
1865        //
1866        // The sort deliberately does NOT float the augmentation ahead. Python
1867        // books same-date entries by `lineno`, and reordering them changes
1868        // which lots an ambiguous match can see (#2093). #841's ledger is
1869        // handled by `Inventory::is_booking_reduction` instead: with
1870        // `Assets:Transit` empty, the -11.11 posting is an augmentation that
1871        // opens a negative lot, and "Transfer Sent" closes it.
1872        let looks_like_a_reduction = Directive::Transaction(
1873            Transaction::new(date(2024, 9, 1), "Transfer Received")
1874                .with_synthesized_posting(
1875                    Posting::new("Assets:AccountB", Amount::new(dec!(11.11), "USD")).with_cost(
1876                        CostSpec::empty()
1877                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1878                            .with_currency("EUR"),
1879                    ),
1880                )
1881                .with_synthesized_posting(
1882                    Posting::new("Assets:Transit", Amount::new(dec!(-11.11), "USD")).with_cost(
1883                        CostSpec::empty()
1884                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1885                            .with_currency("EUR"),
1886                    ),
1887                ),
1888        );
1889
1890        let augmentation = Directive::Transaction(
1891            Transaction::new(date(2024, 9, 1), "Transfer Sent")
1892                .with_synthesized_posting(Posting::new(
1893                    "Assets:AccountA",
1894                    Amount::new(dec!(-10.00), "EUR"),
1895                ))
1896                .with_synthesized_posting(
1897                    Posting::new("Assets:Transit", Amount::new(dec!(11.11), "USD")).with_cost(
1898                        CostSpec::empty()
1899                            .with_number(crate::CostNumber::PerUnit { value: dec!(0.90) })
1900                            .with_currency("EUR"),
1901                    ),
1902                ),
1903        );
1904
1905        let mut directives = vec![looks_like_a_reduction, augmentation];
1906        sort_directives(&mut directives);
1907
1908        let narrations: Vec<&str> = directives
1909            .iter()
1910            .map(|d| match d {
1911                Directive::Transaction(t) => t.narration.as_str(),
1912                _ => unreachable!(),
1913            })
1914            .collect();
1915        assert_eq!(
1916            narrations,
1917            vec!["Transfer Received", "Transfer Sent"],
1918            "same-date directives must keep file order; floating augmentations \
1919             ahead of reductions is what #2093 reported"
1920        );
1921    }
1922
1923    #[test]
1924    fn test_transaction_flags() {
1925        let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1926
1927        // Standard flags
1928        assert!(make_txn('*').is_complete());
1929        assert!(make_txn('!').is_incomplete());
1930        assert!(make_txn('!').is_pending());
1931
1932        // Extended flags
1933        assert!(make_txn('S').is_summarization());
1934        assert!(make_txn('T').is_transfer());
1935        assert!(make_txn('C').is_conversion());
1936        assert!(make_txn('U').is_unrealized());
1937        assert!(make_txn('R').is_return());
1938        assert!(make_txn('M').is_merge());
1939        assert!(make_txn('#').is_bookmarked());
1940        assert!(make_txn('?').needs_investigation());
1941
1942        // Negative cases
1943        assert!(!make_txn('*').is_pending());
1944        assert!(!make_txn('!').is_complete());
1945    }
1946
1947    #[test]
1948    fn test_is_valid_flag() {
1949        // Valid flags
1950        for flag in [
1951            '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1952        ] {
1953            assert!(
1954                Transaction::is_valid_flag(flag),
1955                "Flag '{flag}' should be valid"
1956            );
1957        }
1958
1959        // Invalid flags
1960        for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1961            assert!(
1962                !Transaction::is_valid_flag(flag),
1963                "Flag '{flag}' should be invalid"
1964            );
1965        }
1966    }
1967
1968    #[test]
1969    fn test_transaction_display_includes_metadata() {
1970        let mut meta = Metadata::default();
1971        meta.insert(
1972            "document".to_string(),
1973            MetaValue::String("myfile.pdf".to_string()),
1974        );
1975
1976        let txn = Transaction {
1977            date: date(2026, 2, 23),
1978            flag: '*',
1979            payee: None,
1980            narration: "Example".into(),
1981            tags: vec![],
1982            links: vec![],
1983            meta,
1984            postings: vec![
1985                crate::Spanned::synthesized(Posting::new(
1986                    "Assets:Bank",
1987                    Amount::new(dec!(-2), "USD"),
1988                )),
1989                crate::Spanned::synthesized(Posting::auto("Expenses:Example")),
1990            ],
1991            trailing_comments: Vec::new(),
1992        };
1993
1994        let output = txn.to_string();
1995        assert!(
1996            output.contains("document: \"myfile.pdf\""),
1997            "Transaction Display should include metadata: {output}"
1998        );
1999        assert!(
2000            output.contains("Assets:Bank"),
2001            "Transaction Display should include postings: {output}"
2002        );
2003    }
2004
2005    #[test]
2006    fn test_posting_display_includes_metadata() {
2007        let mut meta = Metadata::default();
2008        meta.insert(
2009            "category".to_string(),
2010            MetaValue::String("groceries".to_string()),
2011        );
2012
2013        let posting = Posting {
2014            account: "Expenses:Food".into(),
2015            units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
2016            cost: None,
2017            price: None,
2018            flag: None,
2019            meta,
2020            comments: Vec::new(),
2021            trailing_comments: Vec::new(),
2022        };
2023
2024        let output = posting.to_string();
2025        assert!(
2026            output.contains("category: \"groceries\""),
2027            "Posting Display should include metadata: {output}"
2028        );
2029    }
2030
2031    #[test]
2032    fn test_directive_display() {
2033        // Test that Directive enum delegates to inner type's Display
2034        let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
2035        let dir = Directive::Transaction(txn.clone());
2036
2037        // Directive::Display should produce same output as Transaction::Display
2038        assert_eq!(format!("{dir}"), format!("{txn}"));
2039
2040        // Test other directive types
2041        let open = Open::new(date(2024, 1, 1), "Assets:Bank");
2042        let dir_open = Directive::Open(open.clone());
2043        assert_eq!(format!("{dir_open}"), format!("{open}"));
2044
2045        let balance = Balance::new(
2046            date(2024, 1, 1),
2047            "Assets:Bank",
2048            Amount::new(dec!(100), "USD"),
2049        );
2050        let dir_balance = Directive::Balance(balance.clone());
2051        assert_eq!(format!("{dir_balance}"), format!("{balance}"));
2052    }
2053
2054    // ----- parse_precision_meta (issue #991) ---------------------------------
2055
2056    #[test]
2057    fn parse_precision_meta_accepts_non_negative_integers() {
2058        // `precision: 2` parses as `Int`; `precision: 2.0` as `Number`. Both
2059        // must validate identically.
2060        assert_eq!(parse_precision_meta(&MetaValue::Int(0)), Ok(0));
2061        assert_eq!(parse_precision_meta(&MetaValue::Int(2)), Ok(2));
2062        assert_eq!(parse_precision_meta(&MetaValue::Int(28)), Ok(28));
2063        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0))), Ok(0));
2064        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2))), Ok(2));
2065        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(28))), Ok(28));
2066        // Integer-valued decimals (e.g. `precision: 2.0` in source) must
2067        // round-trip the same as `precision: 2` — the parser will produce
2068        // `Number(dec!(2.0))` for the dotted form.
2069        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(2.0))), Ok(2));
2070        assert_eq!(parse_precision_meta(&MetaValue::Number(dec!(0.000))), Ok(0));
2071    }
2072
2073    #[test]
2074    fn parse_precision_meta_rejects_negatives() {
2075        let err = parse_precision_meta(&MetaValue::Number(dec!(-1))).unwrap_err();
2076        assert!(err.contains("non-negative"), "got: {err}");
2077        let err = parse_precision_meta(&MetaValue::Int(-1)).unwrap_err();
2078        assert!(err.contains("non-negative"), "got: {err}");
2079    }
2080
2081    #[test]
2082    fn parse_precision_meta_rejects_fractional() {
2083        let err = parse_precision_meta(&MetaValue::Number(dec!(2.5))).unwrap_err();
2084        assert!(err.contains("integer"), "got: {err}");
2085    }
2086
2087    #[test]
2088    fn parse_precision_meta_rejects_overflow() {
2089        // 2^33 — out of u32 range.
2090        let err = parse_precision_meta(&MetaValue::Number(dec!(8589934592))).unwrap_err();
2091        assert!(err.contains("exceeds"), "got: {err}");
2092        let err = parse_precision_meta(&MetaValue::Int(8_589_934_592)).unwrap_err();
2093        assert!(err.contains("exceeds"), "got: {err}");
2094    }
2095
2096    #[test]
2097    fn meta_value_int_display_and_kind() {
2098        assert_eq!(MetaValue::Int(42).to_string(), "42");
2099        assert_eq!(MetaValue::Int(-7).to_string(), "-7");
2100        assert_eq!(
2101            crate::format::format_meta_value(
2102                &MetaValue::Int(42),
2103                &crate::format::FormatConfig::default()
2104            ),
2105            "42"
2106        );
2107        assert_eq!(meta_value_kind(&MetaValue::Int(0)), "int");
2108    }
2109
2110    #[test]
2111    fn parse_precision_meta_rejects_non_number_variants() {
2112        // Cover every non-Number `MetaValue` variant so the kind-labeling
2113        // arms in `meta_value_kind` are all exercised. Each error message
2114        // names the kind ("string value", "bool value", etc.) so users
2115        // see what they actually wrote.
2116        use crate::Amount;
2117        use rust_decimal_macros::dec;
2118        let cases = [
2119            (MetaValue::String("2".into()), "string"),
2120            (MetaValue::Account("Assets:Cash".into()), "account"),
2121            (MetaValue::Currency("USD".into()), "currency"),
2122            (MetaValue::Tag("foo".into()), "tag"),
2123            (MetaValue::Link("bar".into()), "link"),
2124            (MetaValue::Date(date(2024, 1, 1)), "date"),
2125            (MetaValue::Bool(true), "bool"),
2126            (MetaValue::Amount(Amount::new(dec!(2), "USD")), "amount"),
2127            (MetaValue::None, "none"),
2128        ];
2129        for (case, kind) in cases {
2130            let err = match parse_precision_meta(&case) {
2131                Ok(_) => panic!("should have rejected {case:?}"),
2132                Err(e) => e,
2133            };
2134            assert!(
2135                err.contains(kind),
2136                "error for {case:?} should mention kind {kind:?}, got: {err}"
2137            );
2138        }
2139    }
2140}