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