Skip to main content

rustledger_core/
visit.rs

1//! Visitors for AST positions that carry a currency or an account name.
2//!
3//! These exist because hand-rolled "for each currency / for each
4//! account" walks across the AST kept missing positions. The earlier
5//! pattern was: every consumer (LSP completion, hover usage count,
6//! WASM editor extraction, etc.) wrote its own walk over `Directive`
7//! variants, and silently dropped any position the author happened
8//! not to remember (`Note.account`, `Posting.cost.currency`,
9//! `MetaValue::Account` metadata values, `Custom.values`, …).
10//! Fixing it in five different files left the underlying problem
11//! intact: the next contributor writing a "for each X" walk will
12//! reach for the same shape and reintroduce the same bug class.
13//!
14//! These visitors are the canonical answer. Every position that
15//! carries a currency or an account name is enumerated exactly once
16//! here, with no `_ => {}` catch-all — at any layer of the match.
17//! That means:
18//!
19//! - A future `Directive` variant added to the enum forces a
20//!   compile error at the top-level `visit_*` match.
21//! - A future `MetaValue` variant forces a compile error at
22//!   `visit_meta_value_currency` and `visit_meta_value_account`.
23//! - A future `PriceAnnotation` variant forces a compile error at
24//!   `visit_price_currency`.
25//!
26//! This file is the ONE place that needs to be updated when new
27//! variants are added to any of those enums. Downstream consumers
28//! calling `visit_*` benefit automatically.
29//!
30//! Spans of source tokens are intentionally NOT exposed here:
31//! source positions are parser-only metadata published via
32//! `ParseResult::currency_occurrences` (the parser is the canonical
33//! owner of source-position data). Value-level consumers (extract,
34//! hover usage count, completion suggestions) want the strings;
35//! source-position consumers (LSP rename / references /
36//! document-highlight / linked-editing / goto-definition) consume
37//! the parser's index. Separating the concerns keeps the AST
38//! value types pure.
39
40use crate::{Directive, IncompleteAmount, MetaValue, Metadata, PriceAnnotation};
41
42/// Walk every position a currency name can appear in this directive,
43/// invoking `visit` once per occurrence.
44///
45/// Positions covered:
46/// - `Open.currencies` (each constraint entry)
47/// - `Commodity.currency` (the declared currency)
48/// - `Balance.amount.currency`
49/// - `Price` directive (base `currency` and `amount.currency`)
50/// - `Posting.units.currency()` (units side, including `CurrencyOnly`)
51/// - `Posting.cost.currency` (cost spec)
52/// - `Posting.price` (any `PriceAnnotation`, regardless of `kind`)
53/// - `MetaValue::Currency` / `MetaValue::Amount` in any directive's
54///   or posting's metadata block
55/// - `Custom.values` entries that are `MetaValue::Currency` or
56///   `MetaValue::Amount`
57///
58/// The visit order within a directive is the order tokens appear in
59/// source for the parser-generated AST, except for metadata (which
60/// is a `HashMap` and has unspecified iteration order).
61pub fn visit_currencies<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
62    match directive {
63        Directive::Open(open) => {
64            for currency in &open.currencies {
65                visit(currency.as_str());
66            }
67            visit_meta_currencies(&open.meta, visit);
68        }
69        Directive::Commodity(comm) => {
70            visit(comm.currency.as_str());
71            visit_meta_currencies(&comm.meta, visit);
72        }
73        Directive::Balance(bal) => {
74            visit(bal.amount.currency.as_str());
75            visit_meta_currencies(&bal.meta, visit);
76        }
77        Directive::Price(price) => {
78            visit(price.currency.as_str());
79            visit(price.amount.currency.as_str());
80            visit_meta_currencies(&price.meta, visit);
81        }
82        Directive::Transaction(txn) => {
83            visit_meta_currencies(&txn.meta, visit);
84            for posting in &txn.postings {
85                if let Some(units) = &posting.units
86                    && let Some(c) = units.currency()
87                {
88                    visit(c);
89                }
90                if let Some(cost) = &posting.cost
91                    && let Some(c) = &cost.currency
92                {
93                    visit(c.as_str());
94                }
95                if let Some(price) = &posting.price {
96                    visit_price_currency(price, visit);
97                }
98                visit_meta_currencies(&posting.meta, visit);
99            }
100        }
101        Directive::Custom(custom) => {
102            for v in &custom.values {
103                visit_meta_value_currency(v, visit);
104            }
105            visit_meta_currencies(&custom.meta, visit);
106        }
107        Directive::Note(note) => visit_meta_currencies(&note.meta, visit),
108        Directive::Document(doc) => visit_meta_currencies(&doc.meta, visit),
109        Directive::Close(close) => visit_meta_currencies(&close.meta, visit),
110        Directive::Pad(pad) => visit_meta_currencies(&pad.meta, visit),
111        Directive::Event(event) => visit_meta_currencies(&event.meta, visit),
112        Directive::Query(query) => visit_meta_currencies(&query.meta, visit),
113    }
114}
115
116/// Walk every position an account name can appear in this directive,
117/// invoking `visit` once per occurrence.
118///
119/// Positions covered:
120/// - `Open.account`, `Close.account`
121/// - `Balance.account`
122/// - `Pad.account`, `Pad.source_account`
123/// - `Note.account`, `Document.account`
124/// - `Posting.account` (transactions)
125/// - `MetaValue::Account` in any directive's or posting's metadata
126/// - `Custom.values` entries that are `MetaValue::Account`
127///
128/// Visit order matches the source order of the parser-generated
129/// AST, except for metadata (unspecified iteration order).
130pub fn visit_accounts<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
131    match directive {
132        Directive::Open(open) => {
133            visit(open.account.as_str());
134            visit_meta_accounts(&open.meta, visit);
135        }
136        Directive::Close(close) => {
137            visit(close.account.as_str());
138            visit_meta_accounts(&close.meta, visit);
139        }
140        Directive::Balance(bal) => {
141            visit(bal.account.as_str());
142            visit_meta_accounts(&bal.meta, visit);
143        }
144        Directive::Pad(pad) => {
145            visit(pad.account.as_str());
146            visit(pad.source_account.as_str());
147            visit_meta_accounts(&pad.meta, visit);
148        }
149        Directive::Note(note) => {
150            visit(note.account.as_str());
151            visit_meta_accounts(&note.meta, visit);
152        }
153        Directive::Document(doc) => {
154            visit(doc.account.as_str());
155            visit_meta_accounts(&doc.meta, visit);
156        }
157        Directive::Transaction(txn) => {
158            visit_meta_accounts(&txn.meta, visit);
159            for posting in &txn.postings {
160                visit(posting.account.as_str());
161                visit_meta_accounts(&posting.meta, visit);
162            }
163        }
164        Directive::Custom(custom) => {
165            for v in &custom.values {
166                visit_meta_value_account(v, visit);
167            }
168            visit_meta_accounts(&custom.meta, visit);
169        }
170        Directive::Commodity(comm) => visit_meta_accounts(&comm.meta, visit),
171        Directive::Price(price) => visit_meta_accounts(&price.meta, visit),
172        Directive::Event(event) => visit_meta_accounts(&event.meta, visit),
173        Directive::Query(query) => visit_meta_accounts(&query.meta, visit),
174    }
175}
176
177/// Walk every position a tag can appear in this directive, invoking
178/// `visit` once per occurrence (tag text without the `#` sigil).
179///
180/// Positions covered:
181/// - `Transaction.tags` (including tags folded in from `pushtag`)
182/// - `Document.tags`
183/// - `MetaValue::Tag` in any directive's or posting's metadata
184/// - `Custom.values` entries that are `MetaValue::Tag`
185///
186/// Visit order matches the source order of the parser-generated AST,
187/// except for metadata (unspecified iteration order).
188pub fn visit_tags<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
189    match directive {
190        Directive::Transaction(txn) => {
191            for tag in &txn.tags {
192                visit(tag.as_str());
193            }
194            visit_meta_tags(&txn.meta, visit);
195            for posting in &txn.postings {
196                visit_meta_tags(&posting.meta, visit);
197            }
198        }
199        Directive::Document(doc) => {
200            for tag in &doc.tags {
201                visit(tag.as_str());
202            }
203            visit_meta_tags(&doc.meta, visit);
204        }
205        Directive::Custom(custom) => {
206            for v in &custom.values {
207                visit_meta_value_tag(v, visit);
208            }
209            visit_meta_tags(&custom.meta, visit);
210        }
211        Directive::Open(open) => visit_meta_tags(&open.meta, visit),
212        Directive::Close(close) => visit_meta_tags(&close.meta, visit),
213        Directive::Commodity(comm) => visit_meta_tags(&comm.meta, visit),
214        Directive::Balance(bal) => visit_meta_tags(&bal.meta, visit),
215        Directive::Pad(pad) => visit_meta_tags(&pad.meta, visit),
216        Directive::Note(note) => visit_meta_tags(&note.meta, visit),
217        Directive::Price(price) => visit_meta_tags(&price.meta, visit),
218        Directive::Event(event) => visit_meta_tags(&event.meta, visit),
219        Directive::Query(query) => visit_meta_tags(&query.meta, visit),
220    }
221}
222
223/// Walk every position a link can appear in this directive, invoking
224/// `visit` once per occurrence (link text without the `^` sigil).
225///
226/// Positions covered mirror [`visit_tags`], with `Link` in place of
227/// `Tag`: `Transaction.links`, `Document.links`, `MetaValue::Link` in
228/// metadata, and `Custom.values` link entries.
229pub fn visit_links<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
230    match directive {
231        Directive::Transaction(txn) => {
232            for link in &txn.links {
233                visit(link.as_str());
234            }
235            visit_meta_links(&txn.meta, visit);
236            for posting in &txn.postings {
237                visit_meta_links(&posting.meta, visit);
238            }
239        }
240        Directive::Document(doc) => {
241            for link in &doc.links {
242                visit(link.as_str());
243            }
244            visit_meta_links(&doc.meta, visit);
245        }
246        Directive::Custom(custom) => {
247            for v in &custom.values {
248                visit_meta_value_link(v, visit);
249            }
250            visit_meta_links(&custom.meta, visit);
251        }
252        Directive::Open(open) => visit_meta_links(&open.meta, visit),
253        Directive::Close(close) => visit_meta_links(&close.meta, visit),
254        Directive::Commodity(comm) => visit_meta_links(&comm.meta, visit),
255        Directive::Balance(bal) => visit_meta_links(&bal.meta, visit),
256        Directive::Pad(pad) => visit_meta_links(&pad.meta, visit),
257        Directive::Note(note) => visit_meta_links(&note.meta, visit),
258        Directive::Price(price) => visit_meta_links(&price.meta, visit),
259        Directive::Event(event) => visit_meta_links(&event.meta, visit),
260        Directive::Query(query) => visit_meta_links(&query.meta, visit),
261    }
262}
263
264fn visit_meta_currencies<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
265    for v in meta.values() {
266        visit_meta_value_currency(v, visit);
267    }
268}
269
270fn visit_meta_tags<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
271    for v in meta.values() {
272        visit_meta_value_tag(v, visit);
273    }
274}
275
276fn visit_meta_links<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
277    for v in meta.values() {
278        visit_meta_value_link(v, visit);
279    }
280}
281
282fn visit_meta_accounts<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
283    for v in meta.values() {
284        visit_meta_value_account(v, visit);
285    }
286}
287
288/// Per-`MetaValue` currency extractor. Used both by metadata-block
289/// walks and by `Custom.values` walks (Custom directives carry a
290/// `Vec<MetaValue>` as positional args; the same per-variant logic
291/// applies). The match is exhaustive with no `_ => {}` catch-all —
292/// a future `MetaValue` variant added to the enum forces a compile
293/// error here and at `visit_meta_value_account`, so the visitor
294/// stays in lockstep with the type definition. Mirrors the no-
295/// catch-all guarantee already enforced on the `Directive` match
296/// at the top of this module.
297fn visit_meta_value_currency<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
298    match v {
299        MetaValue::Currency(s) => visit(s.as_str()),
300        MetaValue::Amount(a) => visit(a.currency.as_str()),
301        // Variants that cannot carry a currency.
302        MetaValue::String(_)
303        | MetaValue::Account(_)
304        | MetaValue::Tag(_)
305        | MetaValue::Link(_)
306        | MetaValue::Date(_)
307        | MetaValue::Number(_)
308        | MetaValue::Bool(_)
309        | MetaValue::Int(_)
310        | MetaValue::None => {}
311    }
312}
313
314/// Per-`MetaValue` account extractor. See `visit_meta_value_currency`
315/// for the no-`_ => {}`-catch-all rationale.
316fn visit_meta_value_account<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
317    match v {
318        MetaValue::Account(a) => visit(a.as_str()),
319        // Variants that cannot carry an account name.
320        MetaValue::String(_)
321        | MetaValue::Currency(_)
322        | MetaValue::Tag(_)
323        | MetaValue::Link(_)
324        | MetaValue::Date(_)
325        | MetaValue::Number(_)
326        | MetaValue::Bool(_)
327        | MetaValue::Amount(_)
328        | MetaValue::Int(_)
329        | MetaValue::None => {}
330    }
331}
332
333/// Per-`MetaValue` tag extractor (tag text without the `#`). See
334/// `visit_meta_value_currency` for the no-`_ => {}`-catch-all rationale.
335fn visit_meta_value_tag<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
336    match v {
337        MetaValue::Tag(t) => visit(t.as_str()),
338        // Variants that cannot carry a tag.
339        MetaValue::String(_)
340        | MetaValue::Account(_)
341        | MetaValue::Currency(_)
342        | MetaValue::Link(_)
343        | MetaValue::Date(_)
344        | MetaValue::Number(_)
345        | MetaValue::Bool(_)
346        | MetaValue::Amount(_)
347        | MetaValue::Int(_)
348        | MetaValue::None => {}
349    }
350}
351
352/// Per-`MetaValue` link extractor (link text without the `^`). See
353/// `visit_meta_value_currency` for the no-`_ => {}`-catch-all rationale.
354fn visit_meta_value_link<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
355    match v {
356        MetaValue::Link(l) => visit(l.as_str()),
357        // Variants that cannot carry a link.
358        MetaValue::String(_)
359        | MetaValue::Account(_)
360        | MetaValue::Currency(_)
361        | MetaValue::Tag(_)
362        | MetaValue::Date(_)
363        | MetaValue::Number(_)
364        | MetaValue::Bool(_)
365        | MetaValue::Amount(_)
366        | MetaValue::Int(_)
367        | MetaValue::None => {}
368    }
369}
370
371fn visit_price_currency<'a>(price: &'a PriceAnnotation, visit: &mut impl FnMut(&'a str)) {
372    // Post-#1167: PriceAnnotation factors into orthogonal kind+amount
373    // axes, so the six pre-#1167 arms collapse to one inspection of
374    // `amount`. The `kind` (Unit vs Total) is irrelevant for currency
375    // extraction.
376    match &price.amount {
377        Some(IncompleteAmount::Complete(amt)) => visit(amt.currency.as_str()),
378        Some(inc) => {
379            if let Some(c) = inc.currency() {
380                visit(c);
381            }
382        }
383        None => {}
384    }
385}
386
387#[cfg(test)]
388mod tests {
389    use super::*;
390    use crate::{
391        Amount, Balance, Close, Commodity, CostSpec, Custom, Document, MetaValue, Metadata,
392        NaiveDate, Note, Open, Pad, Posting, Price, Spanned, Transaction,
393    };
394    use rust_decimal_macros::dec;
395
396    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
397        crate::naive_date(y, m, d).unwrap()
398    }
399
400    fn collect_currencies(directives: &[Directive]) -> Vec<String> {
401        let mut out = Vec::new();
402        for d in directives {
403            visit_currencies(d, &mut |c| out.push(c.to_string()));
404        }
405        out
406    }
407
408    fn collect_accounts(directives: &[Directive]) -> Vec<String> {
409        let mut out = Vec::new();
410        for d in directives {
411            visit_accounts(d, &mut |a| out.push(a.to_string()));
412        }
413        out
414    }
415
416    fn collect_tags(directives: &[Directive]) -> Vec<String> {
417        let mut out = Vec::new();
418        for d in directives {
419            visit_tags(d, &mut |t| out.push(t.to_string()));
420        }
421        out
422    }
423
424    fn collect_links(directives: &[Directive]) -> Vec<String> {
425        let mut out = Vec::new();
426        for d in directives {
427            visit_links(d, &mut |l| out.push(l.to_string()));
428        }
429        out
430    }
431
432    /// `visit_currencies` must surface every Currency-bearing
433    /// position. This test seeds USD into all 11 positions and
434    /// asserts the visitor reaches each one.
435    #[test]
436    fn test_visit_currencies_reaches_every_position() {
437        let mut commodity_meta: Metadata = Default::default();
438        commodity_meta.insert("note".into(), MetaValue::Currency("USD".into()));
439
440        let mut txn_meta: Metadata = Default::default();
441        txn_meta.insert(
442            "settled".into(),
443            MetaValue::Amount(Amount::new(dec!(1), "USD")),
444        );
445
446        let directives = vec![
447            // Open.currencies + meta
448            Directive::Open(Open {
449                date: date(2024, 1, 1),
450                account: "Assets:Cash".into(),
451                currencies: vec!["USD".into()],
452                booking: None,
453                meta: Default::default(),
454            }),
455            // Commodity.currency + meta
456            Directive::Commodity(Commodity {
457                date: date(2024, 1, 2),
458                currency: "USD".into(),
459                meta: commodity_meta,
460            }),
461            // Balance.amount.currency
462            Directive::Balance(Balance {
463                date: date(2024, 1, 3),
464                account: "Assets:Cash".into(),
465                amount: Amount::new(dec!(100), "USD"),
466                tolerance: None,
467                meta: Default::default(),
468            }),
469            // Price (both halves)
470            Directive::Price(Price {
471                date: date(2024, 1, 4),
472                currency: "USD".into(),
473                amount: Amount::new(dec!(1), "USD"),
474                meta: Default::default(),
475            }),
476            // Transaction with: txn meta (Amount::USD), posting units,
477            // cost, price annotation, posting meta.
478            Directive::Transaction(Transaction {
479                date: date(2024, 1, 5),
480                flag: '*',
481                payee: None,
482                narration: "".into(),
483                tags: vec![],
484                links: vec![],
485                meta: txn_meta,
486                postings: vec![Spanned::synthesized(Posting {
487                    account: "Assets:Stock".into(),
488                    units: Some(crate::IncompleteAmount::from(Amount::new(dec!(10), "USD"))),
489                    cost: Some(CostSpec {
490                        number: Some(crate::CostNumber::PerUnit { value: dec!(1) }),
491                        currency: Some("USD".into()),
492                        date: None,
493                        label: None,
494                        merge: false,
495                    }),
496                    price: Some(PriceAnnotation::unit(Amount::new(dec!(1), "USD"))),
497                    flag: None,
498                    meta: Default::default(),
499                    comments: vec![],
500                    trailing_comments: vec![],
501                })],
502                trailing_comments: vec![],
503            }),
504            // Custom.values with Currency and Amount variants.
505            Directive::Custom(Custom {
506                date: date(2024, 1, 6),
507                custom_type: "test".into(),
508                values: vec![
509                    MetaValue::Currency("USD".into()),
510                    MetaValue::Amount(Amount::new(dec!(1), "USD")),
511                ],
512                meta: Default::default(),
513            }),
514        ];
515
516        let currencies = collect_currencies(&directives);
517        let usd_count = currencies.iter().filter(|c| *c == "USD").count();
518
519        // Expected USD visits:
520        //   1. Open.currencies[0]
521        //   2. Commodity.currency
522        //   3. Commodity.meta (`note: USD`)
523        //   4. Balance.amount.currency
524        //   5. Price.currency
525        //   6. Price.amount.currency
526        //   7. Transaction.meta (`settled: 1 USD` → Amount.currency)
527        //   8. Posting.units (Amount.currency)
528        //   9. Posting.cost.currency
529        //  10. Posting.price.amount.currency
530        //  11. Custom.values[0] (Currency variant)
531        //  12. Custom.values[1] (Amount variant)
532        assert_eq!(
533            usd_count, 12,
534            "expected USD visited 12 times across all positions; got {usd_count} in {currencies:?}"
535        );
536    }
537
538    /// `visit_accounts` must surface every Account-bearing position.
539    /// Seeds `Assets:X` into all reachable positions and asserts the
540    /// visitor reaches each one.
541    #[test]
542    fn test_visit_accounts_reaches_every_position() {
543        let mut meta_with_account: Metadata = Default::default();
544        meta_with_account.insert("see_also".into(), MetaValue::Account("Assets:X".into()));
545
546        let directives = vec![
547            Directive::Open(Open {
548                date: date(2024, 1, 1),
549                account: "Assets:X".into(),
550                currencies: vec![],
551                booking: None,
552                meta: meta_with_account.clone(),
553            }),
554            Directive::Close(Close {
555                date: date(2024, 1, 2),
556                account: "Assets:X".into(),
557                meta: Default::default(),
558            }),
559            Directive::Balance(Balance {
560                date: date(2024, 1, 3),
561                account: "Assets:X".into(),
562                amount: Amount::new(dec!(0), "USD"),
563                tolerance: None,
564                meta: Default::default(),
565            }),
566            Directive::Pad(Pad {
567                date: date(2024, 1, 4),
568                account: "Assets:X".into(),
569                source_account: "Assets:X".into(),
570                meta: Default::default(),
571            }),
572            Directive::Note(Note {
573                date: date(2024, 1, 5),
574                account: "Assets:X".into(),
575                comment: String::new(),
576                meta: Default::default(),
577            }),
578            Directive::Document(Document {
579                date: date(2024, 1, 6),
580                account: "Assets:X".into(),
581                path: String::new(),
582                tags: vec![],
583                links: vec![],
584                meta: Default::default(),
585            }),
586            Directive::Transaction(Transaction {
587                date: date(2024, 1, 7),
588                flag: '*',
589                payee: None,
590                narration: "".into(),
591                tags: vec![],
592                links: vec![],
593                meta: meta_with_account,
594                postings: vec![Spanned::synthesized(Posting::auto("Assets:X"))],
595                trailing_comments: vec![],
596            }),
597            Directive::Custom(Custom {
598                date: date(2024, 1, 8),
599                custom_type: "test".into(),
600                values: vec![MetaValue::Account("Assets:X".into())],
601                meta: Default::default(),
602            }),
603        ];
604
605        let accounts = collect_accounts(&directives);
606        let count = accounts.iter().filter(|a| *a == "Assets:X").count();
607
608        // Expected `Assets:X` visits:
609        //   1. Open.account
610        //   2. Open.meta (`see_also: Assets:X`)
611        //   3. Close.account
612        //   4. Balance.account
613        //   5. Pad.account
614        //   6. Pad.source_account
615        //   7. Note.account
616        //   8. Document.account
617        //   9. Transaction.meta (`see_also: Assets:X`)
618        //  10. Posting.account
619        //  11. Custom.values[0]
620        assert_eq!(
621            count, 11,
622            "expected `Assets:X` visited 11 times; got {count} in {accounts:?}"
623        );
624    }
625
626    /// `visit_tags` / `visit_links` must surface every Tag/Link-bearing
627    /// position. Seeds `proj` (tag) and `inv-1` (link) into all four
628    /// reachable positions each and asserts the visitor reaches each.
629    #[test]
630    fn test_visit_tags_and_links_reach_every_position() {
631        use crate::{Link, Tag};
632
633        let mut txn_meta: Metadata = Default::default();
634        txn_meta.insert("ref".into(), MetaValue::Tag(Tag::new("proj")));
635        txn_meta.insert("see".into(), MetaValue::Link(Link::new("inv-1")));
636
637        let directives = vec![
638            // Transaction.tags / Transaction.links + metadata.
639            Directive::Transaction(Transaction {
640                date: date(2024, 1, 1),
641                flag: '*',
642                payee: None,
643                narration: "".into(),
644                tags: vec![Tag::new("proj")],
645                links: vec![Link::new("inv-1")],
646                meta: txn_meta,
647                postings: vec![],
648                trailing_comments: vec![],
649            }),
650            // Document.tags / Document.links.
651            Directive::Document(Document {
652                date: date(2024, 1, 2),
653                account: "Assets:Cash".into(),
654                path: "x.pdf".into(),
655                tags: vec![Tag::new("proj")],
656                links: vec![Link::new("inv-1")],
657                meta: Default::default(),
658            }),
659            // Custom.values carrying a Tag and a Link.
660            Directive::Custom(Custom {
661                date: date(2024, 1, 3),
662                custom_type: "test".into(),
663                values: vec![
664                    MetaValue::Tag(Tag::new("proj")),
665                    MetaValue::Link(Link::new("inv-1")),
666                ],
667                meta: Default::default(),
668            }),
669        ];
670
671        // Expected `proj` tag visits: Transaction.tags, Transaction.meta,
672        // Document.tags, Custom.values = 4. Same shape for `inv-1` link.
673        assert_eq!(
674            collect_tags(&directives)
675                .iter()
676                .filter(|t| *t == "proj")
677                .count(),
678            4,
679            "tag `proj` should be visited in all 4 positions"
680        );
681        assert_eq!(
682            collect_links(&directives)
683                .iter()
684                .filter(|l| *l == "inv-1")
685                .count(),
686            4,
687            "link `inv-1` should be visited in all 4 positions"
688        );
689    }
690
691    /// `PriceAnnotation` has six variants. The visitor must handle
692    /// all of them: Unit / Total emit one currency, the Incomplete
693    /// variants emit one if their `IncompleteAmount` has a currency
694    /// (`Complete` or `CurrencyOnly`), the Empty variants emit nothing.
695    #[test]
696    fn test_visit_currencies_handles_all_price_annotation_variants() {
697        let txn = |price| Transaction {
698            date: date(2024, 1, 1),
699            flag: '*',
700            payee: None,
701            narration: "".into(),
702            tags: vec![],
703            links: vec![],
704            meta: Default::default(),
705            postings: vec![Spanned::synthesized(Posting {
706                account: "Assets:X".into(),
707                units: Some(crate::IncompleteAmount::from(Amount::new(dec!(1), "AAPL"))),
708                cost: None,
709                price: Some(price),
710                flag: None,
711                meta: Default::default(),
712                comments: vec![],
713                trailing_comments: vec![],
714            })],
715            trailing_comments: vec![],
716        };
717
718        // Unit + Total: each surface one currency.
719        let unit = Directive::Transaction(txn(PriceAnnotation::unit(Amount::new(dec!(1), "USD"))));
720        let total =
721            Directive::Transaction(txn(PriceAnnotation::total(Amount::new(dec!(1), "EUR"))));
722        // Incomplete-Complete + Incomplete-CurrencyOnly both have a
723        // currency; Incomplete-NumberOnly does not.
724        let inc_complete = Directive::Transaction(txn(PriceAnnotation::unit_incomplete(
725            crate::IncompleteAmount::Complete(Amount::new(dec!(1), "GBP")),
726        )));
727        let inc_curr = Directive::Transaction(txn(PriceAnnotation::total_incomplete(
728            crate::IncompleteAmount::CurrencyOnly("JPY".into()),
729        )));
730        let inc_num = Directive::Transaction(txn(PriceAnnotation::unit_incomplete(
731            crate::IncompleteAmount::NumberOnly(dec!(1)),
732        )));
733        // Empty variants surface nothing.
734        let unit_empty = Directive::Transaction(txn(PriceAnnotation::unit_empty()));
735        let total_empty = Directive::Transaction(txn(PriceAnnotation::total_empty()));
736
737        let directives = vec![
738            unit,
739            total,
740            inc_complete,
741            inc_curr,
742            inc_num,
743            unit_empty,
744            total_empty,
745        ];
746
747        let currencies = collect_currencies(&directives);
748
749        // Each transaction's units side contributes AAPL, so 7×AAPL.
750        // Price annotation adds USD/EUR/GBP/JPY for the four non-
751        // empty variants; UnitEmpty/TotalEmpty add nothing;
752        // NumberOnly adds nothing for its price side.
753        let by_curr = |code: &str| currencies.iter().filter(|c| *c == code).count();
754        assert_eq!(by_curr("AAPL"), 7);
755        assert_eq!(by_curr("USD"), 1);
756        assert_eq!(by_curr("EUR"), 1);
757        assert_eq!(by_curr("GBP"), 1);
758        assert_eq!(by_curr("JPY"), 1);
759    }
760}