use crate::Directive;
use crate::visit::{visit_accounts, visit_currencies, visit_links, visit_tags};
pub const DEFAULT_CURRENCIES: &[&str] = &["USD", "EUR", "GBP"];
pub fn extract_accounts(directives: &[Directive]) -> Vec<String> {
extract_accounts_iter(directives.iter())
}
pub fn extract_accounts_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
let mut accounts = Vec::new();
for directive in directives {
visit_accounts(directive, &mut |a| accounts.push(a.to_string()));
}
accounts.sort();
accounts.dedup();
accounts
}
pub fn extract_currencies(directives: &[Directive]) -> Vec<String> {
extract_currencies_iter(directives.iter())
}
pub fn extract_currencies_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
let mut currencies = Vec::new();
for directive in directives {
visit_currencies(directive, &mut |c| currencies.push(c.to_string()));
}
for currency in DEFAULT_CURRENCIES {
currencies.push((*currency).to_string());
}
currencies.sort();
currencies.dedup();
currencies
}
pub fn extract_payees(directives: &[Directive]) -> Vec<String> {
extract_payees_iter(directives.iter())
}
pub fn extract_payees_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
let mut payees = Vec::new();
for directive in directives {
if let Directive::Transaction(txn) = directive
&& let Some(ref payee) = txn.payee
{
payees.push(payee.to_string());
}
}
payees.sort();
payees.dedup();
payees
}
pub fn extract_tags(directives: &[Directive]) -> Vec<String> {
extract_tags_iter(directives.iter())
}
pub fn extract_tags_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
let mut tags = Vec::new();
for directive in directives {
visit_tags(directive, &mut |t| tags.push(t.to_string()));
}
tags.sort();
tags.dedup();
tags
}
pub fn extract_links(directives: &[Directive]) -> Vec<String> {
extract_links_iter(directives.iter())
}
pub fn extract_links_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
let mut links = Vec::new();
for directive in directives {
visit_links(directive, &mut |l| links.push(l.to_string()));
}
links.sort();
links.dedup();
links
}
#[cfg(test)]
mod tests {
use super::*;
use crate::NaiveDate;
use crate::{Amount, Balance, Commodity, MetaValue, Metadata, Open, Pad, Posting, Transaction};
fn date(y: i32, m: u32, d: u32) -> NaiveDate {
crate::naive_date(y, m, d).unwrap()
}
fn test_directives() -> Vec<Directive> {
vec![
Directive::Open(Open {
date: date(2024, 1, 1),
account: "Assets:Cash".into(),
currencies: vec!["USD".into(), "EUR".into()],
booking: None,
meta: Default::default(),
}),
Directive::Open(Open {
date: date(2024, 1, 1),
account: "Expenses:Food".into(),
currencies: vec![],
booking: None,
meta: Default::default(),
}),
Directive::Commodity(Commodity {
date: date(2024, 1, 1),
currency: "BTC".into(),
meta: Default::default(),
}),
Directive::Pad(Pad {
date: date(2024, 1, 2),
account: "Assets:Cash".into(),
source_account: "Equity:Opening".into(),
meta: Default::default(),
}),
Directive::Balance(Balance {
date: date(2024, 1, 3),
account: "Assets:Cash".into(),
amount: Amount::new(rust_decimal_macros::dec!(100), "CHF"),
tolerance: None,
meta: Default::default(),
}),
Directive::Transaction(Transaction {
date: date(2024, 1, 4),
flag: '*',
payee: Some("Corner Store".into()),
narration: "Groceries".into(),
tags: vec![],
links: vec![],
meta: Default::default(),
postings: vec![
crate::Spanned::synthesized(Posting {
account: "Expenses:Food".into(),
units: Some(crate::IncompleteAmount::from(Amount::new(
rust_decimal_macros::dec!(25),
"USD",
))),
cost: None,
price: None,
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
}),
crate::Spanned::synthesized(Posting {
account: "Assets:Cash".into(),
units: None,
cost: None,
price: None,
flag: None,
meta: Default::default(),
comments: vec![],
trailing_comments: vec![],
}),
],
trailing_comments: vec![],
}),
Directive::Transaction(Transaction {
date: date(2024, 1, 5),
flag: '*',
payee: Some("Coffee Shop".into()),
narration: "Coffee".into(),
tags: vec![],
links: vec![],
meta: Default::default(),
postings: vec![],
trailing_comments: vec![],
}),
]
}
#[test]
fn test_empty_directives() {
let empty: Vec<Directive> = vec![];
assert!(extract_accounts(&empty).is_empty());
assert_eq!(extract_currencies(&empty).len(), DEFAULT_CURRENCIES.len());
assert!(extract_payees(&empty).is_empty());
}
#[test]
fn test_extract_accounts_from_directives() {
let directives = test_directives();
let accounts = extract_accounts(&directives);
assert_eq!(
accounts,
vec![
"Assets:Cash".to_string(),
"Equity:Opening".to_string(),
"Expenses:Food".to_string(),
]
);
}
#[test]
fn test_extract_currencies_from_directives() {
let directives = test_directives();
let currencies = extract_currencies(&directives);
assert!(currencies.contains(&"BTC".to_string()));
assert!(currencies.contains(&"CHF".to_string()));
assert!(currencies.contains(&"EUR".to_string()));
assert!(currencies.contains(&"GBP".to_string()));
assert!(currencies.contains(&"USD".to_string()));
}
#[test]
fn test_extract_payees_from_directives() {
let directives = test_directives();
let payees = extract_payees(&directives);
assert_eq!(
payees,
vec!["Coffee Shop".to_string(), "Corner Store".to_string()]
);
}
#[test]
fn test_default_currencies_not_duplicated() {
let directives = test_directives();
let currencies = extract_currencies(&directives);
assert_eq!(
currencies.iter().filter(|c| *c == "USD").count(),
1,
"USD should appear exactly once"
);
}
#[test]
fn test_iter_variant_matches_slice_variant() {
let directives = test_directives();
assert_eq!(
extract_accounts(&directives),
extract_accounts_iter(directives.iter())
);
assert_eq!(
extract_currencies(&directives),
extract_currencies_iter(directives.iter())
);
assert_eq!(
extract_payees(&directives),
extract_payees_iter(directives.iter())
);
assert_eq!(
extract_tags(&directives),
extract_tags_iter(directives.iter())
);
assert_eq!(
extract_links(&directives),
extract_links_iter(directives.iter())
);
}
#[test]
fn test_extract_tags_and_links_sorted_deduped_across_positions() {
use crate::{Document, Link, Tag, Transaction};
let directives = vec![
Directive::Transaction(Transaction {
date: date(2024, 1, 1),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![Tag::new("coffee"), Tag::new("morning")],
links: vec![Link::new("trip-2024")],
meta: Default::default(),
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("coffee")],
links: vec![Link::new("trip-2024"), Link::new("receipt")],
meta: Default::default(),
}),
];
assert_eq!(
extract_tags(&directives),
vec!["coffee".to_string(), "morning".to_string()]
);
assert_eq!(
extract_links(&directives),
vec!["receipt".to_string(), "trip-2024".to_string()]
);
}
#[test]
fn test_extract_currencies_covers_cost_price_meta_custom() {
use crate::{CostSpec, Custom, Price, PriceAnnotation};
use rust_decimal_macros::dec;
let mut txn_meta: Metadata = Default::default();
txn_meta.insert("fx_pair".to_string(), MetaValue::Currency("CAD".into()));
let mut posting_meta: Metadata = Default::default();
posting_meta.insert(
"settled".to_string(),
MetaValue::Amount(Amount::new(dec!(120000), "KRW")),
);
let directives = vec![
Directive::Transaction(Transaction {
date: date(2024, 1, 1),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![],
links: vec![],
meta: txn_meta,
postings: vec![crate::Spanned::synthesized(Posting {
account: "Assets:Stock".into(),
units: Some(crate::IncompleteAmount::from(Amount::new(dec!(10), "AAPL"))),
cost: Some(CostSpec {
number: Some(crate::CostNumber::PerUnit { value: dec!(150) }),
currency: Some("JPY".into()),
date: None,
label: None,
merge: false,
}),
price: Some(PriceAnnotation::unit(Amount::new(dec!(1.1), "CHF"))),
flag: None,
meta: posting_meta,
comments: vec![],
trailing_comments: vec![],
})],
trailing_comments: vec![],
}),
Directive::Price(Price {
date: date(2024, 1, 3),
currency: "AAPL".into(),
amount: Amount::new(dec!(200), "SGD"),
meta: Default::default(),
}),
Directive::Custom(Custom {
date: date(2024, 1, 4),
custom_type: "fx_corridors".to_string(),
values: vec![
MetaValue::Currency("MXN".into()),
MetaValue::Amount(Amount::new(dec!(30), "TWD")),
],
meta: Default::default(),
}),
];
let currencies = extract_currencies(&directives);
for expected in [
"JPY", "CHF", "SGD", "AAPL", "CAD", "KRW", "MXN", "TWD", ] {
assert!(
currencies.contains(&expected.to_string()),
"expected {expected} in extracted currencies; got {currencies:?}"
);
}
}
#[test]
fn test_extract_accounts_covers_note_document_meta_custom() {
use crate::{Custom, Document, Note};
let mut txn_meta: Metadata = Default::default();
txn_meta.insert(
"partner".to_string(),
MetaValue::Account("Assets:JointAccount".into()),
);
let directives = vec![
Directive::Note(Note {
date: date(2024, 1, 1),
account: "Assets:OldCheckingArchive".into(),
comment: "reconcile end of year".to_string(),
meta: Default::default(),
}),
Directive::Document(Document {
date: date(2024, 1, 2),
account: "Liabilities:CreditCard:CitiBank".into(),
path: "statement.pdf".to_string(),
tags: vec![],
links: vec![],
meta: Default::default(),
}),
Directive::Transaction(Transaction {
date: date(2024, 1, 3),
flag: '*',
payee: None,
narration: "".into(),
tags: vec![],
links: vec![],
meta: txn_meta,
postings: vec![],
trailing_comments: vec![],
}),
Directive::Custom(Custom {
date: date(2024, 1, 4),
custom_type: "budget".to_string(),
values: vec![MetaValue::Account("Expenses:Groceries:Whole".into())],
meta: Default::default(),
}),
];
let accounts = extract_accounts(&directives);
for expected in [
"Assets:OldCheckingArchive",
"Liabilities:CreditCard:CitiBank",
"Assets:JointAccount",
"Expenses:Groceries:Whole",
] {
assert!(
accounts.contains(&expected.to_string()),
"expected {expected} in extracted accounts (covers Note/Document/meta/Custom arms); got {accounts:?}"
);
}
}
}