use crate::{Directive, IncompleteAmount, MetaValue, Metadata, PriceAnnotation};
pub fn visit_currencies<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
match directive {
Directive::Open(open) => {
for currency in &open.currencies {
visit(currency.as_str());
}
visit_meta_currencies(&open.meta, visit);
}
Directive::Commodity(comm) => {
visit(comm.currency.as_str());
visit_meta_currencies(&comm.meta, visit);
}
Directive::Balance(bal) => {
visit(bal.amount.currency.as_str());
visit_meta_currencies(&bal.meta, visit);
}
Directive::Price(price) => {
visit(price.currency.as_str());
visit(price.amount.currency.as_str());
visit_meta_currencies(&price.meta, visit);
}
Directive::Transaction(txn) => {
visit_meta_currencies(&txn.meta, visit);
for posting in &txn.postings {
if let Some(units) = &posting.units
&& let Some(c) = units.currency()
{
visit(c);
}
if let Some(cost) = &posting.cost
&& let Some(c) = &cost.currency
{
visit(c.as_str());
}
if let Some(price) = &posting.price {
visit_price_currency(price, visit);
}
visit_meta_currencies(&posting.meta, visit);
}
}
Directive::Custom(custom) => {
for v in &custom.values {
visit_meta_value_currency(v, visit);
}
visit_meta_currencies(&custom.meta, visit);
}
Directive::Note(note) => visit_meta_currencies(¬e.meta, visit),
Directive::Document(doc) => visit_meta_currencies(&doc.meta, visit),
Directive::Close(close) => visit_meta_currencies(&close.meta, visit),
Directive::Pad(pad) => visit_meta_currencies(&pad.meta, visit),
Directive::Event(event) => visit_meta_currencies(&event.meta, visit),
Directive::Query(query) => visit_meta_currencies(&query.meta, visit),
}
}
pub fn visit_accounts<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
match directive {
Directive::Open(open) => {
visit(open.account.as_str());
visit_meta_accounts(&open.meta, visit);
}
Directive::Close(close) => {
visit(close.account.as_str());
visit_meta_accounts(&close.meta, visit);
}
Directive::Balance(bal) => {
visit(bal.account.as_str());
visit_meta_accounts(&bal.meta, visit);
}
Directive::Pad(pad) => {
visit(pad.account.as_str());
visit(pad.source_account.as_str());
visit_meta_accounts(&pad.meta, visit);
}
Directive::Note(note) => {
visit(note.account.as_str());
visit_meta_accounts(¬e.meta, visit);
}
Directive::Document(doc) => {
visit(doc.account.as_str());
visit_meta_accounts(&doc.meta, visit);
}
Directive::Transaction(txn) => {
visit_meta_accounts(&txn.meta, visit);
for posting in &txn.postings {
visit(posting.account.as_str());
visit_meta_accounts(&posting.meta, visit);
}
}
Directive::Custom(custom) => {
for v in &custom.values {
visit_meta_value_account(v, visit);
}
visit_meta_accounts(&custom.meta, visit);
}
Directive::Commodity(comm) => visit_meta_accounts(&comm.meta, visit),
Directive::Price(price) => visit_meta_accounts(&price.meta, visit),
Directive::Event(event) => visit_meta_accounts(&event.meta, visit),
Directive::Query(query) => visit_meta_accounts(&query.meta, visit),
}
}
pub fn visit_tags<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
match directive {
Directive::Transaction(txn) => {
for tag in &txn.tags {
visit(tag.as_str());
}
visit_meta_tags(&txn.meta, visit);
for posting in &txn.postings {
visit_meta_tags(&posting.meta, visit);
}
}
Directive::Document(doc) => {
for tag in &doc.tags {
visit(tag.as_str());
}
visit_meta_tags(&doc.meta, visit);
}
Directive::Custom(custom) => {
for v in &custom.values {
visit_meta_value_tag(v, visit);
}
visit_meta_tags(&custom.meta, visit);
}
Directive::Open(open) => visit_meta_tags(&open.meta, visit),
Directive::Close(close) => visit_meta_tags(&close.meta, visit),
Directive::Commodity(comm) => visit_meta_tags(&comm.meta, visit),
Directive::Balance(bal) => visit_meta_tags(&bal.meta, visit),
Directive::Pad(pad) => visit_meta_tags(&pad.meta, visit),
Directive::Note(note) => visit_meta_tags(¬e.meta, visit),
Directive::Price(price) => visit_meta_tags(&price.meta, visit),
Directive::Event(event) => visit_meta_tags(&event.meta, visit),
Directive::Query(query) => visit_meta_tags(&query.meta, visit),
}
}
pub fn visit_links<'a>(directive: &'a Directive, visit: &mut impl FnMut(&'a str)) {
match directive {
Directive::Transaction(txn) => {
for link in &txn.links {
visit(link.as_str());
}
visit_meta_links(&txn.meta, visit);
for posting in &txn.postings {
visit_meta_links(&posting.meta, visit);
}
}
Directive::Document(doc) => {
for link in &doc.links {
visit(link.as_str());
}
visit_meta_links(&doc.meta, visit);
}
Directive::Custom(custom) => {
for v in &custom.values {
visit_meta_value_link(v, visit);
}
visit_meta_links(&custom.meta, visit);
}
Directive::Open(open) => visit_meta_links(&open.meta, visit),
Directive::Close(close) => visit_meta_links(&close.meta, visit),
Directive::Commodity(comm) => visit_meta_links(&comm.meta, visit),
Directive::Balance(bal) => visit_meta_links(&bal.meta, visit),
Directive::Pad(pad) => visit_meta_links(&pad.meta, visit),
Directive::Note(note) => visit_meta_links(¬e.meta, visit),
Directive::Price(price) => visit_meta_links(&price.meta, visit),
Directive::Event(event) => visit_meta_links(&event.meta, visit),
Directive::Query(query) => visit_meta_links(&query.meta, visit),
}
}
fn visit_meta_currencies<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
for v in meta.values() {
visit_meta_value_currency(v, visit);
}
}
fn visit_meta_tags<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
for v in meta.values() {
visit_meta_value_tag(v, visit);
}
}
fn visit_meta_links<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
for v in meta.values() {
visit_meta_value_link(v, visit);
}
}
fn visit_meta_accounts<'a>(meta: &'a Metadata, visit: &mut impl FnMut(&'a str)) {
for v in meta.values() {
visit_meta_value_account(v, visit);
}
}
fn visit_meta_value_currency<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
match v {
MetaValue::Currency(s) => visit(s.as_str()),
MetaValue::Amount(a) => visit(a.currency.as_str()),
MetaValue::String(_)
| MetaValue::Account(_)
| MetaValue::Tag(_)
| MetaValue::Link(_)
| MetaValue::Date(_)
| MetaValue::Number(_)
| MetaValue::Bool(_)
| MetaValue::None => {}
}
}
fn visit_meta_value_account<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
match v {
MetaValue::Account(a) => visit(a.as_str()),
MetaValue::String(_)
| MetaValue::Currency(_)
| MetaValue::Tag(_)
| MetaValue::Link(_)
| MetaValue::Date(_)
| MetaValue::Number(_)
| MetaValue::Bool(_)
| MetaValue::Amount(_)
| MetaValue::None => {}
}
}
fn visit_meta_value_tag<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
match v {
MetaValue::Tag(t) => visit(t.as_str()),
MetaValue::String(_)
| MetaValue::Account(_)
| MetaValue::Currency(_)
| MetaValue::Link(_)
| MetaValue::Date(_)
| MetaValue::Number(_)
| MetaValue::Bool(_)
| MetaValue::Amount(_)
| MetaValue::None => {}
}
}
fn visit_meta_value_link<'a>(v: &'a MetaValue, visit: &mut impl FnMut(&'a str)) {
match v {
MetaValue::Link(l) => visit(l.as_str()),
MetaValue::String(_)
| MetaValue::Account(_)
| MetaValue::Currency(_)
| MetaValue::Tag(_)
| MetaValue::Date(_)
| MetaValue::Number(_)
| MetaValue::Bool(_)
| MetaValue::Amount(_)
| MetaValue::None => {}
}
}
fn visit_price_currency<'a>(price: &'a PriceAnnotation, visit: &mut impl FnMut(&'a str)) {
match &price.amount {
Some(IncompleteAmount::Complete(amt)) => visit(amt.currency.as_str()),
Some(inc) => {
if let Some(c) = inc.currency() {
visit(c);
}
}
None => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
Amount, Balance, Close, Commodity, CostSpec, Custom, Document, MetaValue, Metadata,
NaiveDate, Note, Open, Pad, Posting, Price, Spanned, Transaction,
};
use rust_decimal_macros::dec;
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
crate::naive_date(y, m, d).unwrap()
}
fn collect_currencies(directives: &[Directive]) -> Vec<String> {
let mut out = Vec::new();
for d in directives {
visit_currencies(d, &mut |c| out.push(c.to_string()));
}
out
}
fn collect_accounts(directives: &[Directive]) -> Vec<String> {
let mut out = Vec::new();
for d in directives {
visit_accounts(d, &mut |a| out.push(a.to_string()));
}
out
}
fn collect_tags(directives: &[Directive]) -> Vec<String> {
let mut out = Vec::new();
for d in directives {
visit_tags(d, &mut |t| out.push(t.to_string()));
}
out
}
fn collect_links(directives: &[Directive]) -> Vec<String> {
let mut out = Vec::new();
for d in directives {
visit_links(d, &mut |l| out.push(l.to_string()));
}
out
}
#[test]
fn test_visit_currencies_reaches_every_position() {
let mut commodity_meta: Metadata = Default::default();
commodity_meta.insert("note".into(), MetaValue::Currency("USD".into()));
let mut txn_meta: Metadata = Default::default();
txn_meta.insert(
"settled".into(),
MetaValue::Amount(Amount::new(dec!(1), "USD")),
);
let directives = vec![
Directive::Open(Open {
date: date(2024, 1, 1),
account: "Assets:Cash".into(),
currencies: vec!["USD".into()],
booking: None,
meta: Default::default(),
}),
Directive::Commodity(Commodity {
date: date(2024, 1, 2),
currency: "USD".into(),
meta: commodity_meta,
}),
Directive::Balance(Balance {
date: date(2024, 1, 3),
account: "Assets:Cash".into(),
amount: Amount::new(dec!(100), "USD"),
tolerance: None,
meta: Default::default(),
}),
Directive::Price(Price {
date: date(2024, 1, 4),
currency: "USD".into(),
amount: Amount::new(dec!(1), "USD"),
meta: Default::default(),
}),
Directive::Transaction(Transaction {
date: date(2024, 1, 5),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![],
links: vec![],
meta: txn_meta,
postings: vec![Spanned::synthesized(Posting {
account: "Assets:Stock".into(),
units: Some(crate::IncompleteAmount::from(Amount::new(dec!(10), "USD"))),
cost: Some(CostSpec {
number: Some(crate::CostNumber::PerUnit { value: dec!(1) }),
currency: Some("USD".into()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotation::unit(Amount::new(dec!(1), "USD"))),
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
})],
trailing_comments: vec![],
}),
Directive::Custom(Custom {
date: date(2024, 1, 6),
custom_type: "test".into(),
values: vec![
MetaValue::Currency("USD".into()),
MetaValue::Amount(Amount::new(dec!(1), "USD")),
],
meta: Default::default(),
}),
];
let currencies = collect_currencies(&directives);
let usd_count = currencies.iter().filter(|c| *c == "USD").count();
assert_eq!(
usd_count, 12,
"expected USD visited 12 times across all positions; got {usd_count} in {currencies:?}"
);
}
#[test]
fn test_visit_accounts_reaches_every_position() {
let mut meta_with_account: Metadata = Default::default();
meta_with_account.insert("see_also".into(), MetaValue::Account("Assets:X".into()));
let directives = vec![
Directive::Open(Open {
date: date(2024, 1, 1),
account: "Assets:X".into(),
currencies: vec![],
booking: None,
meta: meta_with_account.clone(),
}),
Directive::Close(Close {
date: date(2024, 1, 2),
account: "Assets:X".into(),
meta: Default::default(),
}),
Directive::Balance(Balance {
date: date(2024, 1, 3),
account: "Assets:X".into(),
amount: Amount::new(dec!(0), "USD"),
tolerance: None,
meta: Default::default(),
}),
Directive::Pad(Pad {
date: date(2024, 1, 4),
account: "Assets:X".into(),
source_account: "Assets:X".into(),
meta: Default::default(),
}),
Directive::Note(Note {
date: date(2024, 1, 5),
account: "Assets:X".into(),
comment: String::new(),
meta: Default::default(),
}),
Directive::Document(Document {
date: date(2024, 1, 6),
account: "Assets:X".into(),
path: String::new(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
Directive::Transaction(Transaction {
date: date(2024, 1, 7),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![],
links: vec![],
meta: meta_with_account,
postings: vec![Spanned::synthesized(Posting::auto("Assets:X"))],
trailing_comments: vec![],
}),
Directive::Custom(Custom {
date: date(2024, 1, 8),
custom_type: "test".into(),
values: vec![MetaValue::Account("Assets:X".into())],
meta: Default::default(),
}),
];
let accounts = collect_accounts(&directives);
let count = accounts.iter().filter(|a| *a == "Assets:X").count();
assert_eq!(
count, 11,
"expected `Assets:X` visited 11 times; got {count} in {accounts:?}"
);
}
#[test]
fn test_visit_tags_and_links_reach_every_position() {
use crate::{Link, Tag};
let mut txn_meta: Metadata = Default::default();
txn_meta.insert("ref".into(), MetaValue::Tag(Tag::new("proj")));
txn_meta.insert("see".into(), MetaValue::Link(Link::new("inv-1")));
let directives = vec![
Directive::Transaction(Transaction {
date: date(2024, 1, 1),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![Tag::new("proj")],
links: vec![Link::new("inv-1")],
meta: txn_meta,
postings: vec![],
trailing_comments: vec![],
}),
Directive::Document(Document {
date: date(2024, 1, 2),
account: "Assets:Cash".into(),
path: "x.pdf".into(),
tags: vec![Tag::new("proj")],
links: vec![Link::new("inv-1")],
meta: Default::default(),
}),
Directive::Custom(Custom {
date: date(2024, 1, 3),
custom_type: "test".into(),
values: vec![
MetaValue::Tag(Tag::new("proj")),
MetaValue::Link(Link::new("inv-1")),
],
meta: Default::default(),
}),
];
assert_eq!(
collect_tags(&directives)
.iter()
.filter(|t| *t == "proj")
.count(),
4,
"tag `proj` should be visited in all 4 positions"
);
assert_eq!(
collect_links(&directives)
.iter()
.filter(|l| *l == "inv-1")
.count(),
4,
"link `inv-1` should be visited in all 4 positions"
);
}
#[test]
fn test_visit_currencies_handles_all_price_annotation_variants() {
let txn = |price| Transaction {
date: date(2024, 1, 1),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![],
links: vec![],
meta: Default::default(),
postings: vec![Spanned::synthesized(Posting {
account: "Assets:X".into(),
units: Some(crate::IncompleteAmount::from(Amount::new(dec!(1), "AAPL"))),
cost: None,
price: Some(price),
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
})],
trailing_comments: vec![],
};
let unit = Directive::Transaction(txn(PriceAnnotation::unit(Amount::new(dec!(1), "USD"))));
let total =
Directive::Transaction(txn(PriceAnnotation::total(Amount::new(dec!(1), "EUR"))));
let inc_complete = Directive::Transaction(txn(PriceAnnotation::unit_incomplete(
crate::IncompleteAmount::Complete(Amount::new(dec!(1), "GBP")),
)));
let inc_curr = Directive::Transaction(txn(PriceAnnotation::total_incomplete(
crate::IncompleteAmount::CurrencyOnly("JPY".into()),
)));
let inc_num = Directive::Transaction(txn(PriceAnnotation::unit_incomplete(
crate::IncompleteAmount::NumberOnly(dec!(1)),
)));
let unit_empty = Directive::Transaction(txn(PriceAnnotation::unit_empty()));
let total_empty = Directive::Transaction(txn(PriceAnnotation::total_empty()));
let directives = vec![
unit,
total,
inc_complete,
inc_curr,
inc_num,
unit_empty,
total_empty,
];
let currencies = collect_currencies(&directives);
let by_curr = |code: &str| currencies.iter().filter(|c| *c == code).count();
assert_eq!(by_curr("AAPL"), 7);
assert_eq!(by_curr("USD"), 1);
assert_eq!(by_curr("EUR"), 1);
assert_eq!(by_curr("GBP"), 1);
assert_eq!(by_curr("JPY"), 1);
}
}