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 chrono::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, AsVecInternedStr};
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(String),
40    /// Currency code
41    Currency(String),
42    /// Tag reference
43    Tag(String),
44    /// Link reference
45    Link(String),
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}
57
58impl fmt::Display for MetaValue {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        match self {
61            Self::String(s) => write!(f, "\"{s}\""),
62            Self::Account(a) => write!(f, "{a}"),
63            Self::Currency(c) => write!(f, "{c}"),
64            Self::Tag(t) => write!(f, "#{t}"),
65            Self::Link(l) => write!(f, "^{l}"),
66            Self::Date(d) => write!(f, "{d}"),
67            Self::Number(n) => write!(f, "{n}"),
68            Self::Bool(b) => write!(f, "{b}"),
69            Self::Amount(a) => write!(f, "{a}"),
70            Self::None => write!(f, "None"),
71        }
72    }
73}
74
75/// Metadata is a key-value map attached to directives and postings.
76pub type Metadata = FxHashMap<String, MetaValue>;
77
78/// A posting within a transaction.
79///
80/// Postings represent the individual legs of a transaction. Each posting
81/// specifies an account and optionally an amount, cost, and price.
82///
83/// When the units are `None`, the entire amount will be inferred by the
84/// interpolation algorithm to balance the transaction. When units is
85/// `Some(IncompleteAmount)`, it may still have missing components that
86/// need to be filled in.
87#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
88#[cfg_attr(
89    feature = "rkyv",
90    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
91)]
92pub struct Posting {
93    /// The account for this posting
94    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
95    pub account: InternedStr,
96    /// The units (may be incomplete or None for auto-calculated postings)
97    pub units: Option<IncompleteAmount>,
98    /// Cost specification for the position
99    pub cost: Option<CostSpec>,
100    /// Price annotation (@ or @@)
101    pub price: Option<PriceAnnotation>,
102    /// Whether this posting has the "!" flag
103    pub flag: Option<char>,
104    /// Posting metadata
105    pub meta: Metadata,
106    /// Comments that appear before this posting (one per line)
107    #[serde(default, skip_serializing_if = "Vec::is_empty")]
108    pub comments: Vec<String>,
109    /// Trailing comment(s) on the same line as the posting
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    pub trailing_comments: Vec<String>,
112}
113
114impl Posting {
115    /// Create a new posting with the given account and complete units.
116    #[must_use]
117    pub fn new(account: impl Into<InternedStr>, units: Amount) -> Self {
118        Self {
119            account: account.into(),
120            units: Some(IncompleteAmount::Complete(units)),
121            cost: None,
122            price: None,
123            flag: None,
124            meta: Metadata::default(),
125            comments: Vec::new(),
126            trailing_comments: Vec::new(),
127        }
128    }
129
130    /// Create a new posting with an incomplete amount.
131    #[must_use]
132    pub fn with_incomplete(account: impl Into<InternedStr>, units: IncompleteAmount) -> Self {
133        Self {
134            account: account.into(),
135            units: Some(units),
136            cost: None,
137            price: None,
138            flag: None,
139            meta: Metadata::default(),
140            comments: Vec::new(),
141            trailing_comments: Vec::new(),
142        }
143    }
144
145    /// Create a posting without any amount (to be fully interpolated).
146    #[must_use]
147    pub fn auto(account: impl Into<InternedStr>) -> Self {
148        Self {
149            account: account.into(),
150            units: None,
151            cost: None,
152            price: None,
153            flag: None,
154            meta: Metadata::default(),
155            comments: Vec::new(),
156            trailing_comments: Vec::new(),
157        }
158    }
159
160    /// Get the complete amount if available.
161    #[must_use]
162    pub fn amount(&self) -> Option<&Amount> {
163        self.units.as_ref().and_then(|u| u.as_amount())
164    }
165
166    /// Add a cost specification.
167    #[must_use]
168    pub fn with_cost(mut self, cost: CostSpec) -> Self {
169        self.cost = Some(cost);
170        self
171    }
172
173    /// Add a price annotation.
174    #[must_use]
175    pub fn with_price(mut self, price: PriceAnnotation) -> Self {
176        self.price = Some(price);
177        self
178    }
179
180    /// Add a flag.
181    #[must_use]
182    pub const fn with_flag(mut self, flag: char) -> Self {
183        self.flag = Some(flag);
184        self
185    }
186
187    /// Check if this posting has an amount.
188    #[must_use]
189    pub const fn has_units(&self) -> bool {
190        self.units.is_some()
191    }
192}
193
194impl fmt::Display for Posting {
195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
196        write!(f, "  ")?;
197        if let Some(flag) = self.flag {
198            write!(f, "{flag} ")?;
199        }
200        write!(f, "{}", self.account)?;
201        if let Some(units) = &self.units {
202            write!(f, "  {units}")?;
203        }
204        if let Some(cost) = &self.cost {
205            write!(f, " {cost}")?;
206        }
207        if let Some(price) = &self.price {
208            write!(f, " {price}")?;
209        }
210        // Posting-level metadata
211        for (key, value) in &self.meta {
212            write!(f, "\n    {key}: {value}")?;
213        }
214        Ok(())
215    }
216}
217
218/// Price annotation for a posting (@ or @@).
219///
220/// Price annotations can be incomplete (missing number or currency)
221/// before interpolation fills in the missing values.
222#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
223#[cfg_attr(
224    feature = "rkyv",
225    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
226)]
227pub enum PriceAnnotation {
228    /// Per-unit price (@) with complete amount
229    Unit(Amount),
230    /// Total price (@@) with complete amount
231    Total(Amount),
232    /// Per-unit price (@) with incomplete amount
233    UnitIncomplete(IncompleteAmount),
234    /// Total price (@@) with incomplete amount
235    TotalIncomplete(IncompleteAmount),
236    /// Empty per-unit price (@ with no amount)
237    UnitEmpty,
238    /// Empty total price (@@ with no amount)
239    TotalEmpty,
240}
241
242impl PriceAnnotation {
243    /// Get the complete amount if available.
244    #[must_use]
245    pub const fn amount(&self) -> Option<&Amount> {
246        match self {
247            Self::Unit(a) | Self::Total(a) => Some(a),
248            Self::UnitIncomplete(ia) | Self::TotalIncomplete(ia) => ia.as_amount(),
249            Self::UnitEmpty | Self::TotalEmpty => None,
250        }
251    }
252
253    /// Check if this is a per-unit price (@ vs @@).
254    #[must_use]
255    pub const fn is_unit(&self) -> bool {
256        matches!(
257            self,
258            Self::Unit(_) | Self::UnitIncomplete(_) | Self::UnitEmpty
259        )
260    }
261}
262
263impl fmt::Display for PriceAnnotation {
264    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
265        match self {
266            Self::Unit(a) => write!(f, "@ {a}"),
267            Self::Total(a) => write!(f, "@@ {a}"),
268            Self::UnitIncomplete(ia) => write!(f, "@ {ia}"),
269            Self::TotalIncomplete(ia) => write!(f, "@@ {ia}"),
270            Self::UnitEmpty => write!(f, "@"),
271            Self::TotalEmpty => write!(f, "@@"),
272        }
273    }
274}
275
276/// Directive ordering priority for sorting.
277///
278/// When directives have the same date, they are sorted by type priority
279/// to ensure proper processing order.
280#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
281pub enum DirectivePriority {
282    /// Open accounts first so they exist before use
283    Open = 0,
284    /// Commodities declared before use
285    Commodity = 1,
286    /// Padding before balance assertions
287    Pad = 2,
288    /// Balance assertions checked at start of day
289    Balance = 3,
290    /// Main entries
291    Transaction = 4,
292    /// Annotations after transactions
293    Note = 5,
294    /// Attachments after transactions
295    Document = 6,
296    /// State changes
297    Event = 7,
298    /// Queries defined after data
299    Query = 8,
300    /// Prices at end of day
301    Price = 9,
302    /// Accounts closed after all activity
303    Close = 10,
304    /// User extensions last
305    Custom = 11,
306}
307
308/// All directive types in beancount.
309#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
310#[cfg_attr(
311    feature = "rkyv",
312    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
313)]
314pub enum Directive {
315    /// Transaction directive - records transfers between accounts
316    Transaction(Transaction),
317    /// Balance assertion - asserts an account balance at a point in time
318    Balance(Balance),
319    /// Open account - opens an account for use
320    Open(Open),
321    /// Close account - closes an account
322    Close(Close),
323    /// Commodity declaration - declares a currency/commodity
324    Commodity(Commodity),
325    /// Pad directive - auto-pad an account to match a balance
326    Pad(Pad),
327    /// Event directive - records a life event
328    Event(Event),
329    /// Query directive - stores a named BQL query
330    Query(Query),
331    /// Note directive - adds a note to an account
332    Note(Note),
333    /// Document directive - links a document to an account
334    Document(Document),
335    /// Price directive - records a commodity price
336    Price(Price),
337    /// Custom directive - custom user-defined directive
338    Custom(Custom),
339}
340
341impl Directive {
342    /// Get the date of this directive.
343    #[must_use]
344    pub const fn date(&self) -> NaiveDate {
345        match self {
346            Self::Transaction(t) => t.date,
347            Self::Balance(b) => b.date,
348            Self::Open(o) => o.date,
349            Self::Close(c) => c.date,
350            Self::Commodity(c) => c.date,
351            Self::Pad(p) => p.date,
352            Self::Event(e) => e.date,
353            Self::Query(q) => q.date,
354            Self::Note(n) => n.date,
355            Self::Document(d) => d.date,
356            Self::Price(p) => p.date,
357            Self::Custom(c) => c.date,
358        }
359    }
360
361    /// Get the metadata of this directive.
362    #[must_use]
363    pub const fn meta(&self) -> &Metadata {
364        match self {
365            Self::Transaction(t) => &t.meta,
366            Self::Balance(b) => &b.meta,
367            Self::Open(o) => &o.meta,
368            Self::Close(c) => &c.meta,
369            Self::Commodity(c) => &c.meta,
370            Self::Pad(p) => &p.meta,
371            Self::Event(e) => &e.meta,
372            Self::Query(q) => &q.meta,
373            Self::Note(n) => &n.meta,
374            Self::Document(d) => &d.meta,
375            Self::Price(p) => &p.meta,
376            Self::Custom(c) => &c.meta,
377        }
378    }
379
380    /// Check if this is a transaction.
381    #[must_use]
382    pub const fn is_transaction(&self) -> bool {
383        matches!(self, Self::Transaction(_))
384    }
385
386    /// Get as a transaction, if this is one.
387    #[must_use]
388    pub const fn as_transaction(&self) -> Option<&Transaction> {
389        match self {
390            Self::Transaction(t) => Some(t),
391            _ => None,
392        }
393    }
394
395    /// Get the directive type name.
396    #[must_use]
397    pub const fn type_name(&self) -> &'static str {
398        match self {
399            Self::Transaction(_) => "transaction",
400            Self::Balance(_) => "balance",
401            Self::Open(_) => "open",
402            Self::Close(_) => "close",
403            Self::Commodity(_) => "commodity",
404            Self::Pad(_) => "pad",
405            Self::Event(_) => "event",
406            Self::Query(_) => "query",
407            Self::Note(_) => "note",
408            Self::Document(_) => "document",
409            Self::Price(_) => "price",
410            Self::Custom(_) => "custom",
411        }
412    }
413
414    /// Get the sorting priority for this directive.
415    ///
416    /// Used to determine order when directives have the same date.
417    #[must_use]
418    pub const fn priority(&self) -> DirectivePriority {
419        match self {
420            Self::Open(_) => DirectivePriority::Open,
421            Self::Commodity(_) => DirectivePriority::Commodity,
422            Self::Pad(_) => DirectivePriority::Pad,
423            Self::Balance(_) => DirectivePriority::Balance,
424            Self::Transaction(_) => DirectivePriority::Transaction,
425            Self::Note(_) => DirectivePriority::Note,
426            Self::Document(_) => DirectivePriority::Document,
427            Self::Event(_) => DirectivePriority::Event,
428            Self::Query(_) => DirectivePriority::Query,
429            Self::Price(_) => DirectivePriority::Price,
430            Self::Close(_) => DirectivePriority::Close,
431            Self::Custom(_) => DirectivePriority::Custom,
432        }
433    }
434}
435
436/// Sort directives by date, then by type priority.
437///
438/// This is a stable sort that preserves file order for directives
439/// with the same date and type.
440pub fn sort_directives(directives: &mut [Directive]) {
441    directives.sort_by(|a, b| {
442        // Primary: date ascending
443        a.date()
444            .cmp(&b.date())
445            // Secondary: type priority
446            .then_with(|| a.priority().cmp(&b.priority()))
447    });
448}
449
450/// A transaction directive.
451///
452/// Transactions are the most common directive type. They record transfers
453/// between accounts and must balance (sum of all postings equals zero).
454#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
455#[cfg_attr(
456    feature = "rkyv",
457    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
458)]
459pub struct Transaction {
460    /// Transaction date
461    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
462    pub date: NaiveDate,
463    /// Transaction flag (* or !)
464    pub flag: char,
465    /// Payee (optional)
466    #[cfg_attr(feature = "rkyv", rkyv(with = AsOptionInternedStr))]
467    pub payee: Option<InternedStr>,
468    /// Narration (description)
469    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
470    pub narration: InternedStr,
471    /// Tags attached to this transaction
472    #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
473    pub tags: Vec<InternedStr>,
474    /// Links attached to this transaction
475    #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
476    pub links: Vec<InternedStr>,
477    /// Transaction metadata
478    pub meta: Metadata,
479    /// Postings (account entries)
480    pub postings: Vec<Posting>,
481    /// Comments that appear after all postings
482    #[serde(default, skip_serializing_if = "Vec::is_empty")]
483    pub trailing_comments: Vec<String>,
484}
485
486impl Transaction {
487    /// Create a new transaction.
488    #[must_use]
489    pub fn new(date: NaiveDate, narration: impl Into<InternedStr>) -> Self {
490        Self {
491            date,
492            flag: '*',
493            payee: None,
494            narration: narration.into(),
495            tags: Vec::new(),
496            links: Vec::new(),
497            meta: Metadata::default(),
498            postings: Vec::new(),
499            trailing_comments: Vec::new(),
500        }
501    }
502
503    /// Set the flag.
504    #[must_use]
505    pub const fn with_flag(mut self, flag: char) -> Self {
506        self.flag = flag;
507        self
508    }
509
510    /// Set the payee.
511    #[must_use]
512    pub fn with_payee(mut self, payee: impl Into<InternedStr>) -> Self {
513        self.payee = Some(payee.into());
514        self
515    }
516
517    /// Add a tag.
518    #[must_use]
519    pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
520        self.tags.push(tag.into());
521        self
522    }
523
524    /// Add a link.
525    #[must_use]
526    pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
527        self.links.push(link.into());
528        self
529    }
530
531    /// Add a posting.
532    #[must_use]
533    pub fn with_posting(mut self, posting: Posting) -> Self {
534        self.postings.push(posting);
535        self
536    }
537
538    /// Check if this transaction is marked as complete (*).
539    #[must_use]
540    pub const fn is_complete(&self) -> bool {
541        self.flag == '*'
542    }
543
544    /// Check if this transaction is marked as incomplete/pending (!).
545    #[must_use]
546    pub const fn is_incomplete(&self) -> bool {
547        self.flag == '!'
548    }
549
550    /// Check if this transaction is marked as pending (!).
551    /// Alias for `is_incomplete`.
552    #[must_use]
553    pub const fn is_pending(&self) -> bool {
554        self.flag == '!'
555    }
556
557    /// Check if this transaction was generated by a pad directive (P).
558    #[must_use]
559    pub const fn is_pad_generated(&self) -> bool {
560        self.flag == 'P'
561    }
562
563    /// Check if this is a summarization transaction (S).
564    #[must_use]
565    pub const fn is_summarization(&self) -> bool {
566        self.flag == 'S'
567    }
568
569    /// Check if this is a transfer transaction (T).
570    #[must_use]
571    pub const fn is_transfer(&self) -> bool {
572        self.flag == 'T'
573    }
574
575    /// Check if this is a currency conversion transaction (C).
576    #[must_use]
577    pub const fn is_conversion(&self) -> bool {
578        self.flag == 'C'
579    }
580
581    /// Check if this is an unrealized gains transaction (U).
582    #[must_use]
583    pub const fn is_unrealized(&self) -> bool {
584        self.flag == 'U'
585    }
586
587    /// Check if this is a return/dividend transaction (R).
588    #[must_use]
589    pub const fn is_return(&self) -> bool {
590        self.flag == 'R'
591    }
592
593    /// Check if this is a merge transaction (M).
594    #[must_use]
595    pub const fn is_merge(&self) -> bool {
596        self.flag == 'M'
597    }
598
599    /// Check if this transaction is bookmarked (#).
600    #[must_use]
601    pub const fn is_bookmarked(&self) -> bool {
602        self.flag == '#'
603    }
604
605    /// Check if this transaction needs investigation (?).
606    #[must_use]
607    pub const fn needs_investigation(&self) -> bool {
608        self.flag == '?'
609    }
610
611    /// Check if the given character is a valid transaction flag.
612    #[must_use]
613    pub const fn is_valid_flag(flag: char) -> bool {
614        matches!(
615            flag,
616            '*' | '!' | 'P' | 'S' | 'T' | 'C' | 'U' | 'R' | 'M' | '#' | '?' | '%' | '&'
617        )
618    }
619}
620
621impl fmt::Display for Transaction {
622    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
623        write!(f, "{} {} ", self.date, self.flag)?;
624        if let Some(payee) = &self.payee {
625            write!(f, "\"{payee}\" ")?;
626        }
627        write!(f, "\"{}\"", self.narration)?;
628        for tag in &self.tags {
629            write!(f, " #{tag}")?;
630        }
631        for link in &self.links {
632            write!(f, " ^{link}")?;
633        }
634        // Transaction-level metadata
635        for (key, value) in &self.meta {
636            write!(f, "\n  {key}: {value}")?;
637        }
638        for posting in &self.postings {
639            write!(f, "\n{posting}")?;
640        }
641        Ok(())
642    }
643}
644
645/// A balance assertion directive.
646///
647/// Asserts that an account has a specific balance at the beginning of a date.
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 Balance {
654    /// Assertion date
655    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
656    pub date: NaiveDate,
657    /// Account to check
658    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
659    pub account: InternedStr,
660    /// Expected amount
661    pub amount: Amount,
662    /// Tolerance (if explicitly specified)
663    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsDecimal>))]
664    pub tolerance: Option<Decimal>,
665    /// Metadata
666    pub meta: Metadata,
667}
668
669impl Balance {
670    /// Create a new balance assertion.
671    #[must_use]
672    pub fn new(date: NaiveDate, account: impl Into<InternedStr>, amount: Amount) -> Self {
673        Self {
674            date,
675            account: account.into(),
676            amount,
677            tolerance: None,
678            meta: Metadata::default(),
679        }
680    }
681
682    /// Set explicit tolerance.
683    #[must_use]
684    pub const fn with_tolerance(mut self, tolerance: Decimal) -> Self {
685        self.tolerance = Some(tolerance);
686        self
687    }
688
689    /// Set metadata.
690    #[must_use]
691    pub fn with_meta(mut self, meta: Metadata) -> Self {
692        self.meta = meta;
693        self
694    }
695}
696
697impl fmt::Display for Balance {
698    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
699        write!(f, "{} balance {} {}", self.date, self.account, self.amount)?;
700        if let Some(tol) = self.tolerance {
701            write!(f, " ~ {tol}")?;
702        }
703        Ok(())
704    }
705}
706
707/// An open account directive.
708///
709/// Opens an account for use. Accounts must be opened before they can be used.
710#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
711#[cfg_attr(
712    feature = "rkyv",
713    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
714)]
715pub struct Open {
716    /// Date account was opened
717    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
718    pub date: NaiveDate,
719    /// Account name (e.g., "Assets:Bank:Checking")
720    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
721    pub account: InternedStr,
722    /// Allowed currencies (empty = any currency allowed)
723    #[cfg_attr(feature = "rkyv", rkyv(with = rkyv::with::Map<AsInternedStr>))]
724    pub currencies: Vec<InternedStr>,
725    /// Booking method for this account
726    pub booking: Option<String>,
727    /// Metadata
728    pub meta: Metadata,
729}
730
731impl Open {
732    /// Create a new open directive.
733    #[must_use]
734    pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
735        Self {
736            date,
737            account: account.into(),
738            currencies: Vec::new(),
739            booking: None,
740            meta: Metadata::default(),
741        }
742    }
743
744    /// Set allowed currencies.
745    #[must_use]
746    pub fn with_currencies(mut self, currencies: Vec<InternedStr>) -> Self {
747        self.currencies = currencies;
748        self
749    }
750
751    /// Set booking method.
752    #[must_use]
753    pub fn with_booking(mut self, booking: impl Into<String>) -> Self {
754        self.booking = Some(booking.into());
755        self
756    }
757
758    /// Set metadata.
759    #[must_use]
760    pub fn with_meta(mut self, meta: Metadata) -> Self {
761        self.meta = meta;
762        self
763    }
764}
765
766impl fmt::Display for Open {
767    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
768        write!(f, "{} open {}", self.date, self.account)?;
769        if !self.currencies.is_empty() {
770            let currencies: Vec<&str> = self.currencies.iter().map(InternedStr::as_str).collect();
771            write!(f, " {}", currencies.join(","))?;
772        }
773        if let Some(booking) = &self.booking {
774            write!(f, " \"{booking}\"")?;
775        }
776        Ok(())
777    }
778}
779
780/// A close account directive.
781///
782/// Closes an account. The account should have zero balance when closed.
783#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
784#[cfg_attr(
785    feature = "rkyv",
786    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
787)]
788pub struct Close {
789    /// Date account was closed
790    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
791    pub date: NaiveDate,
792    /// Account name
793    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
794    pub account: InternedStr,
795    /// Metadata
796    pub meta: Metadata,
797}
798
799impl Close {
800    /// Create a new close directive.
801    #[must_use]
802    pub fn new(date: NaiveDate, account: impl Into<InternedStr>) -> Self {
803        Self {
804            date,
805            account: account.into(),
806            meta: Metadata::default(),
807        }
808    }
809
810    /// Set metadata.
811    #[must_use]
812    pub fn with_meta(mut self, meta: Metadata) -> Self {
813        self.meta = meta;
814        self
815    }
816}
817
818impl fmt::Display for Close {
819    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
820        write!(f, "{} close {}", self.date, self.account)
821    }
822}
823
824/// A commodity declaration directive.
825///
826/// Declares a commodity/currency that can be used in the ledger.
827#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
828#[cfg_attr(
829    feature = "rkyv",
830    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
831)]
832pub struct Commodity {
833    /// Declaration date
834    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
835    pub date: NaiveDate,
836    /// Currency/commodity code (e.g., "USD", "AAPL")
837    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
838    pub currency: InternedStr,
839    /// Metadata
840    pub meta: Metadata,
841}
842
843impl Commodity {
844    /// Create a new commodity declaration.
845    #[must_use]
846    pub fn new(date: NaiveDate, currency: impl Into<InternedStr>) -> Self {
847        Self {
848            date,
849            currency: currency.into(),
850            meta: Metadata::default(),
851        }
852    }
853
854    /// Set metadata.
855    #[must_use]
856    pub fn with_meta(mut self, meta: Metadata) -> Self {
857        self.meta = meta;
858        self
859    }
860}
861
862impl fmt::Display for Commodity {
863    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
864        write!(f, "{} commodity {}", self.date, self.currency)
865    }
866}
867
868/// A pad directive.
869///
870/// Automatically inserts a transaction to pad an account to match
871/// a subsequent balance assertion.
872#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
873#[cfg_attr(
874    feature = "rkyv",
875    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
876)]
877pub struct Pad {
878    /// Pad date
879    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
880    pub date: NaiveDate,
881    /// Account to pad
882    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
883    pub account: InternedStr,
884    /// Source account for padding (e.g., Equity:Opening-Balances)
885    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
886    pub source_account: InternedStr,
887    /// Metadata
888    pub meta: Metadata,
889}
890
891impl Pad {
892    /// Create a new pad directive.
893    #[must_use]
894    pub fn new(
895        date: NaiveDate,
896        account: impl Into<InternedStr>,
897        source_account: impl Into<InternedStr>,
898    ) -> Self {
899        Self {
900            date,
901            account: account.into(),
902            source_account: source_account.into(),
903            meta: Metadata::default(),
904        }
905    }
906
907    /// Set metadata.
908    #[must_use]
909    pub fn with_meta(mut self, meta: Metadata) -> Self {
910        self.meta = meta;
911        self
912    }
913}
914
915impl fmt::Display for Pad {
916    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
917        write!(
918            f,
919            "{} pad {} {}",
920            self.date, self.account, self.source_account
921        )
922    }
923}
924
925/// An event directive.
926///
927/// Records a life event (e.g., location changes, employment changes).
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 Event {
934    /// Event date
935    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
936    pub date: NaiveDate,
937    /// Event type (e.g., "location", "employer")
938    pub event_type: String,
939    /// Event value
940    pub value: String,
941    /// Metadata
942    pub meta: Metadata,
943}
944
945impl Event {
946    /// Create a new event directive.
947    #[must_use]
948    pub fn new(date: NaiveDate, event_type: impl Into<String>, value: impl Into<String>) -> Self {
949        Self {
950            date,
951            event_type: event_type.into(),
952            value: value.into(),
953            meta: Metadata::default(),
954        }
955    }
956
957    /// Set metadata.
958    #[must_use]
959    pub fn with_meta(mut self, meta: Metadata) -> Self {
960        self.meta = meta;
961        self
962    }
963}
964
965impl fmt::Display for Event {
966    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
967        write!(
968            f,
969            "{} event \"{}\" \"{}\"",
970            self.date, self.event_type, self.value
971        )
972    }
973}
974
975/// A query directive.
976///
977/// Stores a named BQL query that can be referenced later.
978#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
979#[cfg_attr(
980    feature = "rkyv",
981    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
982)]
983pub struct Query {
984    /// Query date
985    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
986    pub date: NaiveDate,
987    /// Query name
988    pub name: String,
989    /// BQL query string
990    pub query: String,
991    /// Metadata
992    pub meta: Metadata,
993}
994
995impl Query {
996    /// Create a new query directive.
997    #[must_use]
998    pub fn new(date: NaiveDate, name: impl Into<String>, query: impl Into<String>) -> Self {
999        Self {
1000            date,
1001            name: name.into(),
1002            query: query.into(),
1003            meta: Metadata::default(),
1004        }
1005    }
1006
1007    /// Set metadata.
1008    #[must_use]
1009    pub fn with_meta(mut self, meta: Metadata) -> Self {
1010        self.meta = meta;
1011        self
1012    }
1013}
1014
1015impl fmt::Display for Query {
1016    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1017        write!(
1018            f,
1019            "{} query \"{}\" \"{}\"",
1020            self.date, self.name, self.query
1021        )
1022    }
1023}
1024
1025/// A note directive.
1026///
1027/// Adds a note/comment to an account on a specific date.
1028#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1029#[cfg_attr(
1030    feature = "rkyv",
1031    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1032)]
1033pub struct Note {
1034    /// Note date
1035    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1036    pub date: NaiveDate,
1037    /// Account
1038    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1039    pub account: InternedStr,
1040    /// Note text
1041    pub comment: String,
1042    /// Metadata
1043    pub meta: Metadata,
1044}
1045
1046impl Note {
1047    /// Create a new note directive.
1048    #[must_use]
1049    pub fn new(
1050        date: NaiveDate,
1051        account: impl Into<InternedStr>,
1052        comment: impl Into<String>,
1053    ) -> Self {
1054        Self {
1055            date,
1056            account: account.into(),
1057            comment: comment.into(),
1058            meta: Metadata::default(),
1059        }
1060    }
1061
1062    /// Set metadata.
1063    #[must_use]
1064    pub fn with_meta(mut self, meta: Metadata) -> Self {
1065        self.meta = meta;
1066        self
1067    }
1068}
1069
1070impl fmt::Display for Note {
1071    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1072        write!(
1073            f,
1074            "{} note {} \"{}\"",
1075            self.date, self.account, self.comment
1076        )
1077    }
1078}
1079
1080/// A document directive.
1081///
1082/// Links an external document file to an account.
1083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1084#[cfg_attr(
1085    feature = "rkyv",
1086    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1087)]
1088pub struct Document {
1089    /// Document date
1090    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1091    pub date: NaiveDate,
1092    /// Account
1093    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1094    pub account: InternedStr,
1095    /// File path to the document
1096    pub path: String,
1097    /// Tags
1098    #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
1099    pub tags: Vec<InternedStr>,
1100    /// Links
1101    #[cfg_attr(feature = "rkyv", rkyv(with = AsVecInternedStr))]
1102    pub links: Vec<InternedStr>,
1103    /// Metadata
1104    pub meta: Metadata,
1105}
1106
1107impl Document {
1108    /// Create a new document directive.
1109    #[must_use]
1110    pub fn new(date: NaiveDate, account: impl Into<InternedStr>, path: impl Into<String>) -> Self {
1111        Self {
1112            date,
1113            account: account.into(),
1114            path: path.into(),
1115            tags: Vec::new(),
1116            links: Vec::new(),
1117            meta: Metadata::default(),
1118        }
1119    }
1120
1121    /// Add a tag.
1122    #[must_use]
1123    pub fn with_tag(mut self, tag: impl Into<InternedStr>) -> Self {
1124        self.tags.push(tag.into());
1125        self
1126    }
1127
1128    /// Add a link.
1129    #[must_use]
1130    pub fn with_link(mut self, link: impl Into<InternedStr>) -> Self {
1131        self.links.push(link.into());
1132        self
1133    }
1134
1135    /// Set metadata.
1136    #[must_use]
1137    pub fn with_meta(mut self, meta: Metadata) -> Self {
1138        self.meta = meta;
1139        self
1140    }
1141}
1142
1143impl fmt::Display for Document {
1144    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1145        write!(
1146            f,
1147            "{} document {} \"{}\"",
1148            self.date, self.account, self.path
1149        )
1150    }
1151}
1152
1153/// A price directive.
1154///
1155/// Records the price of a commodity in another currency.
1156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1157#[cfg_attr(
1158    feature = "rkyv",
1159    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1160)]
1161pub struct Price {
1162    /// Price date
1163    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1164    pub date: NaiveDate,
1165    /// Currency being priced
1166    #[cfg_attr(feature = "rkyv", rkyv(with = AsInternedStr))]
1167    pub currency: InternedStr,
1168    /// Price amount (in another currency)
1169    pub amount: Amount,
1170    /// Metadata
1171    pub meta: Metadata,
1172}
1173
1174impl Price {
1175    /// Create a new price directive.
1176    #[must_use]
1177    pub fn new(date: NaiveDate, currency: impl Into<InternedStr>, amount: Amount) -> Self {
1178        Self {
1179            date,
1180            currency: currency.into(),
1181            amount,
1182            meta: Metadata::default(),
1183        }
1184    }
1185
1186    /// Set metadata.
1187    #[must_use]
1188    pub fn with_meta(mut self, meta: Metadata) -> Self {
1189        self.meta = meta;
1190        self
1191    }
1192}
1193
1194impl fmt::Display for Price {
1195    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1196        write!(f, "{} price {} {}", self.date, self.currency, self.amount)
1197    }
1198}
1199
1200/// A custom directive.
1201///
1202/// User-defined directive type for extensions.
1203#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1204#[cfg_attr(
1205    feature = "rkyv",
1206    derive(rkyv::Archive, rkyv::Serialize, rkyv::Deserialize)
1207)]
1208pub struct Custom {
1209    /// Custom directive date
1210    #[cfg_attr(feature = "rkyv", rkyv(with = AsNaiveDate))]
1211    pub date: NaiveDate,
1212    /// Custom type name (e.g., "budget", "autopay")
1213    pub custom_type: String,
1214    /// Values/arguments for this custom directive
1215    pub values: Vec<MetaValue>,
1216    /// Metadata
1217    pub meta: Metadata,
1218}
1219
1220impl Custom {
1221    /// Create a new custom directive.
1222    #[must_use]
1223    pub fn new(date: NaiveDate, custom_type: impl Into<String>) -> Self {
1224        Self {
1225            date,
1226            custom_type: custom_type.into(),
1227            values: Vec::new(),
1228            meta: Metadata::default(),
1229        }
1230    }
1231
1232    /// Add a value.
1233    #[must_use]
1234    pub fn with_value(mut self, value: MetaValue) -> Self {
1235        self.values.push(value);
1236        self
1237    }
1238
1239    /// Set metadata.
1240    #[must_use]
1241    pub fn with_meta(mut self, meta: Metadata) -> Self {
1242        self.meta = meta;
1243        self
1244    }
1245}
1246
1247impl fmt::Display for Custom {
1248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1249        write!(f, "{} custom \"{}\"", self.date, self.custom_type)?;
1250        for value in &self.values {
1251            write!(f, " {value}")?;
1252        }
1253        Ok(())
1254    }
1255}
1256
1257impl fmt::Display for Directive {
1258    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
1259        match self {
1260            Self::Transaction(t) => write!(f, "{t}"),
1261            Self::Balance(b) => write!(f, "{b}"),
1262            Self::Open(o) => write!(f, "{o}"),
1263            Self::Close(c) => write!(f, "{c}"),
1264            Self::Commodity(c) => write!(f, "{c}"),
1265            Self::Pad(p) => write!(f, "{p}"),
1266            Self::Event(e) => write!(f, "{e}"),
1267            Self::Query(q) => write!(f, "{q}"),
1268            Self::Note(n) => write!(f, "{n}"),
1269            Self::Document(d) => write!(f, "{d}"),
1270            Self::Price(p) => write!(f, "{p}"),
1271            Self::Custom(c) => write!(f, "{c}"),
1272        }
1273    }
1274}
1275
1276#[cfg(test)]
1277mod tests {
1278    use super::*;
1279    use rust_decimal_macros::dec;
1280
1281    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1282        NaiveDate::from_ymd_opt(year, month, day).unwrap()
1283    }
1284
1285    #[test]
1286    fn test_transaction() {
1287        let txn = Transaction::new(date(2024, 1, 15), "Grocery shopping")
1288            .with_payee("Whole Foods")
1289            .with_flag('*')
1290            .with_tag("food")
1291            .with_posting(Posting::new(
1292                "Expenses:Food",
1293                Amount::new(dec!(50.00), "USD"),
1294            ))
1295            .with_posting(Posting::auto("Assets:Checking"));
1296
1297        assert_eq!(txn.flag, '*');
1298        assert_eq!(txn.payee.as_deref(), Some("Whole Foods"));
1299        assert_eq!(txn.postings.len(), 2);
1300        assert!(txn.is_complete());
1301    }
1302
1303    #[test]
1304    fn test_balance() {
1305        let bal = Balance::new(
1306            date(2024, 1, 1),
1307            "Assets:Checking",
1308            Amount::new(dec!(1000.00), "USD"),
1309        );
1310
1311        assert_eq!(bal.account, "Assets:Checking");
1312        assert_eq!(bal.amount.number, dec!(1000.00));
1313    }
1314
1315    #[test]
1316    fn test_open() {
1317        let open = Open::new(date(2024, 1, 1), "Assets:Bank:Checking")
1318            .with_currencies(vec!["USD".into()])
1319            .with_booking("FIFO");
1320
1321        assert_eq!(open.currencies, vec![InternedStr::from("USD")]);
1322        assert_eq!(open.booking, Some("FIFO".to_string()));
1323    }
1324
1325    #[test]
1326    fn test_directive_date() {
1327        let txn = Transaction::new(date(2024, 1, 15), "Test");
1328        let dir = Directive::Transaction(txn);
1329
1330        assert_eq!(dir.date(), date(2024, 1, 15));
1331        assert!(dir.is_transaction());
1332        assert_eq!(dir.type_name(), "transaction");
1333    }
1334
1335    #[test]
1336    fn test_posting_display() {
1337        let posting = Posting::new("Assets:Checking", Amount::new(dec!(100.00), "USD"));
1338        let s = format!("{posting}");
1339        assert!(s.contains("Assets:Checking"));
1340        assert!(s.contains("100.00 USD"));
1341    }
1342
1343    #[test]
1344    fn test_transaction_display() {
1345        let txn = Transaction::new(date(2024, 1, 15), "Test transaction")
1346            .with_payee("Test Payee")
1347            .with_posting(Posting::new(
1348                "Expenses:Test",
1349                Amount::new(dec!(50.00), "USD"),
1350            ))
1351            .with_posting(Posting::auto("Assets:Cash"));
1352
1353        let s = format!("{txn}");
1354        assert!(s.contains("2024-01-15"));
1355        assert!(s.contains("Test Payee"));
1356        assert!(s.contains("Test transaction"));
1357    }
1358
1359    #[test]
1360    fn test_directive_priority() {
1361        // Test that priorities are ordered correctly
1362        assert!(DirectivePriority::Open < DirectivePriority::Transaction);
1363        assert!(DirectivePriority::Pad < DirectivePriority::Balance);
1364        assert!(DirectivePriority::Balance < DirectivePriority::Transaction);
1365        assert!(DirectivePriority::Transaction < DirectivePriority::Close);
1366        assert!(DirectivePriority::Price < DirectivePriority::Close);
1367    }
1368
1369    #[test]
1370    fn test_sort_directives_by_date() {
1371        let mut directives = vec![
1372            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Third")),
1373            Directive::Transaction(Transaction::new(date(2024, 1, 1), "First")),
1374            Directive::Transaction(Transaction::new(date(2024, 1, 10), "Second")),
1375        ];
1376
1377        sort_directives(&mut directives);
1378
1379        assert_eq!(directives[0].date(), date(2024, 1, 1));
1380        assert_eq!(directives[1].date(), date(2024, 1, 10));
1381        assert_eq!(directives[2].date(), date(2024, 1, 15));
1382    }
1383
1384    #[test]
1385    fn test_sort_directives_by_type_same_date() {
1386        // On the same date, open should come before transaction, transaction before close
1387        let mut directives = vec![
1388            Directive::Close(Close::new(date(2024, 1, 1), "Assets:Bank")),
1389            Directive::Transaction(Transaction::new(date(2024, 1, 1), "Payment")),
1390            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1391            Directive::Balance(Balance::new(
1392                date(2024, 1, 1),
1393                "Assets:Bank",
1394                Amount::new(dec!(0), "USD"),
1395            )),
1396        ];
1397
1398        sort_directives(&mut directives);
1399
1400        assert_eq!(directives[0].type_name(), "open");
1401        assert_eq!(directives[1].type_name(), "balance");
1402        assert_eq!(directives[2].type_name(), "transaction");
1403        assert_eq!(directives[3].type_name(), "close");
1404    }
1405
1406    #[test]
1407    fn test_sort_directives_pad_before_balance() {
1408        // Pad must come before balance assertion on the same day
1409        let mut directives = vec![
1410            Directive::Balance(Balance::new(
1411                date(2024, 1, 1),
1412                "Assets:Bank",
1413                Amount::new(dec!(1000), "USD"),
1414            )),
1415            Directive::Pad(Pad::new(
1416                date(2024, 1, 1),
1417                "Assets:Bank",
1418                "Equity:Opening-Balances",
1419            )),
1420        ];
1421
1422        sort_directives(&mut directives);
1423
1424        assert_eq!(directives[0].type_name(), "pad");
1425        assert_eq!(directives[1].type_name(), "balance");
1426    }
1427
1428    #[test]
1429    fn test_transaction_flags() {
1430        let make_txn = |flag: char| Transaction::new(date(2024, 1, 15), "Test").with_flag(flag);
1431
1432        // Standard flags
1433        assert!(make_txn('*').is_complete());
1434        assert!(make_txn('!').is_incomplete());
1435        assert!(make_txn('!').is_pending());
1436
1437        // Extended flags
1438        assert!(make_txn('P').is_pad_generated());
1439        assert!(make_txn('S').is_summarization());
1440        assert!(make_txn('T').is_transfer());
1441        assert!(make_txn('C').is_conversion());
1442        assert!(make_txn('U').is_unrealized());
1443        assert!(make_txn('R').is_return());
1444        assert!(make_txn('M').is_merge());
1445        assert!(make_txn('#').is_bookmarked());
1446        assert!(make_txn('?').needs_investigation());
1447
1448        // Negative cases
1449        assert!(!make_txn('*').is_pending());
1450        assert!(!make_txn('!').is_complete());
1451        assert!(!make_txn('*').is_pad_generated());
1452    }
1453
1454    #[test]
1455    fn test_is_valid_flag() {
1456        // Valid flags
1457        for flag in [
1458            '*', '!', 'P', 'S', 'T', 'C', 'U', 'R', 'M', '#', '?', '%', '&',
1459        ] {
1460            assert!(
1461                Transaction::is_valid_flag(flag),
1462                "Flag '{flag}' should be valid"
1463            );
1464        }
1465
1466        // Invalid flags
1467        for flag in ['x', 'X', '0', ' ', 'a', 'Z'] {
1468            assert!(
1469                !Transaction::is_valid_flag(flag),
1470                "Flag '{flag}' should be invalid"
1471            );
1472        }
1473    }
1474
1475    #[test]
1476    fn test_transaction_display_includes_metadata() {
1477        let mut meta = Metadata::default();
1478        meta.insert(
1479            "document".to_string(),
1480            MetaValue::String("myfile.pdf".to_string()),
1481        );
1482
1483        let txn = Transaction {
1484            date: date(2026, 2, 23),
1485            flag: '*',
1486            payee: None,
1487            narration: "Example".into(),
1488            tags: vec![],
1489            links: vec![],
1490            meta,
1491            postings: vec![
1492                Posting::new("Assets:Bank", Amount::new(dec!(-2), "USD")),
1493                Posting::auto("Expenses:Example"),
1494            ],
1495            trailing_comments: Vec::new(),
1496        };
1497
1498        let output = txn.to_string();
1499        assert!(
1500            output.contains("document: \"myfile.pdf\""),
1501            "Transaction Display should include metadata: {output}"
1502        );
1503        assert!(
1504            output.contains("Assets:Bank"),
1505            "Transaction Display should include postings: {output}"
1506        );
1507    }
1508
1509    #[test]
1510    fn test_posting_display_includes_metadata() {
1511        let mut meta = Metadata::default();
1512        meta.insert(
1513            "category".to_string(),
1514            MetaValue::String("groceries".to_string()),
1515        );
1516
1517        let posting = Posting {
1518            account: "Expenses:Food".into(),
1519            units: Some(IncompleteAmount::Complete(Amount::new(dec!(50), "USD"))),
1520            cost: None,
1521            price: None,
1522            flag: None,
1523            meta,
1524            comments: Vec::new(),
1525            trailing_comments: Vec::new(),
1526        };
1527
1528        let output = posting.to_string();
1529        assert!(
1530            output.contains("category: \"groceries\""),
1531            "Posting Display should include metadata: {output}"
1532        );
1533    }
1534
1535    #[test]
1536    fn test_directive_display() {
1537        // Test that Directive enum delegates to inner type's Display
1538        let txn = Transaction::new(date(2024, 1, 15), "Test transaction");
1539        let dir = Directive::Transaction(txn.clone());
1540
1541        // Directive::Display should produce same output as Transaction::Display
1542        assert_eq!(format!("{dir}"), format!("{txn}"));
1543
1544        // Test other directive types
1545        let open = Open::new(date(2024, 1, 1), "Assets:Bank");
1546        let dir_open = Directive::Open(open.clone());
1547        assert_eq!(format!("{dir_open}"), format!("{open}"));
1548
1549        let balance = Balance::new(
1550            date(2024, 1, 1),
1551            "Assets:Bank",
1552            Amount::new(dec!(100), "USD"),
1553        );
1554        let dir_balance = Directive::Balance(balance.clone());
1555        assert_eq!(format!("{dir_balance}"), format!("{balance}"));
1556    }
1557}