1use crate::{Directive, IncompleteAmount, MetaValue, Metadata, PriceAnnotation};
41
42pub 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(¬e.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
116pub 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(¬e.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
177pub 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(¬e.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
223pub 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(¬e.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
288fn 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 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
314fn 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 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
333fn 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 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
352fn 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 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 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 #[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 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 Directive::Commodity(Commodity {
457 date: date(2024, 1, 2),
458 currency: "USD".into(),
459 meta: commodity_meta,
460 }),
461 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 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 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 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 assert_eq!(
533 usd_count, 12,
534 "expected USD visited 12 times across all positions; got {usd_count} in {currencies:?}"
535 );
536 }
537
538 #[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 assert_eq!(
621 count, 11,
622 "expected `Assets:X` visited 11 times; got {count} in {accounts:?}"
623 );
624 }
625
626 #[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 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 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 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 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 #[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 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 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 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 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}