1use crate::Directive;
11use crate::visit::{visit_accounts, visit_currencies, visit_links, visit_tags};
12
13pub const DEFAULT_CURRENCIES: &[&str] = &["USD", "EUR", "GBP"];
15
16pub fn extract_accounts(directives: &[Directive]) -> Vec<String> {
18 extract_accounts_iter(directives.iter())
19}
20
21pub fn extract_accounts_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
34 let mut accounts = Vec::new();
35 for directive in directives {
36 visit_accounts(directive, &mut |a| accounts.push(a.to_string()));
37 }
38 accounts.sort();
39 accounts.dedup();
40 accounts
41}
42
43pub fn extract_currencies(directives: &[Directive]) -> Vec<String> {
47 extract_currencies_iter(directives.iter())
48}
49
50pub fn extract_currencies_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
60 let mut currencies = Vec::new();
61 for directive in directives {
62 visit_currencies(directive, &mut |c| currencies.push(c.to_string()));
63 }
64 for currency in DEFAULT_CURRENCIES {
65 currencies.push((*currency).to_string());
66 }
67 currencies.sort();
68 currencies.dedup();
69 currencies
70}
71
72pub fn extract_payees(directives: &[Directive]) -> Vec<String> {
74 extract_payees_iter(directives.iter())
75}
76
77pub fn extract_payees_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
79 let mut payees = Vec::new();
80
81 for directive in directives {
82 if let Directive::Transaction(txn) = directive
83 && let Some(ref payee) = txn.payee
84 {
85 payees.push(payee.to_string());
86 }
87 }
88
89 payees.sort();
90 payees.dedup();
91 payees
92}
93
94pub fn extract_tags(directives: &[Directive]) -> Vec<String> {
97 extract_tags_iter(directives.iter())
98}
99
100pub fn extract_tags_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
106 let mut tags = Vec::new();
107 for directive in directives {
108 visit_tags(directive, &mut |t| tags.push(t.to_string()));
109 }
110 tags.sort();
111 tags.dedup();
112 tags
113}
114
115pub fn extract_links(directives: &[Directive]) -> Vec<String> {
118 extract_links_iter(directives.iter())
119}
120
121pub fn extract_links_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
127 let mut links = Vec::new();
128 for directive in directives {
129 visit_links(directive, &mut |l| links.push(l.to_string()));
130 }
131 links.sort();
132 links.dedup();
133 links
134}
135
136#[cfg(test)]
137mod tests {
138 use super::*;
139 use crate::NaiveDate;
140 use crate::{Amount, Balance, Commodity, MetaValue, Metadata, Open, Pad, Posting, Transaction};
141
142 fn date(y: i32, m: u32, d: u32) -> NaiveDate {
143 crate::naive_date(y, m, d).unwrap()
144 }
145
146 fn test_directives() -> Vec<Directive> {
147 vec![
148 Directive::Open(Open {
149 date: date(2024, 1, 1),
150 account: "Assets:Cash".into(),
151 currencies: vec!["USD".into(), "EUR".into()],
152 booking: None,
153 meta: Default::default(),
154 }),
155 Directive::Open(Open {
156 date: date(2024, 1, 1),
157 account: "Expenses:Food".into(),
158 currencies: vec![],
159 booking: None,
160 meta: Default::default(),
161 }),
162 Directive::Commodity(Commodity {
163 date: date(2024, 1, 1),
164 currency: "BTC".into(),
165 meta: Default::default(),
166 }),
167 Directive::Pad(Pad {
168 date: date(2024, 1, 2),
169 account: "Assets:Cash".into(),
170 source_account: "Equity:Opening".into(),
171 meta: Default::default(),
172 }),
173 Directive::Balance(Balance {
174 date: date(2024, 1, 3),
175 account: "Assets:Cash".into(),
176 amount: Amount::new(rust_decimal_macros::dec!(100), "CHF"),
177 tolerance: None,
178 meta: Default::default(),
179 }),
180 Directive::Transaction(Transaction {
181 date: date(2024, 1, 4),
182 flag: '*',
183 payee: Some("Corner Store".into()),
184 narration: "Groceries".into(),
185 tags: vec![],
186 links: vec![],
187 meta: Default::default(),
188 postings: vec![
189 crate::Spanned::synthesized(Posting {
190 account: "Expenses:Food".into(),
191 units: Some(crate::IncompleteAmount::from(Amount::new(
192 rust_decimal_macros::dec!(25),
193 "USD",
194 ))),
195 cost: None,
196 price: None,
197 flag: None,
198 meta: Default::default(),
199 comments: vec![],
200 trailing_comments: vec![],
201 }),
202 crate::Spanned::synthesized(Posting {
203 account: "Assets:Cash".into(),
204 units: None,
205 cost: None,
206 price: None,
207 flag: None,
208 meta: Default::default(),
209 comments: vec![],
210 trailing_comments: vec![],
211 }),
212 ],
213 trailing_comments: vec![],
214 }),
215 Directive::Transaction(Transaction {
216 date: date(2024, 1, 5),
217 flag: '*',
218 payee: Some("Coffee Shop".into()),
219 narration: "Coffee".into(),
220 tags: vec![],
221 links: vec![],
222 meta: Default::default(),
223 postings: vec![],
224 trailing_comments: vec![],
225 }),
226 ]
227 }
228
229 #[test]
230 fn test_empty_directives() {
231 let empty: Vec<Directive> = vec![];
232 assert!(extract_accounts(&empty).is_empty());
233 assert_eq!(extract_currencies(&empty).len(), DEFAULT_CURRENCIES.len());
234 assert!(extract_payees(&empty).is_empty());
235 }
236
237 #[test]
238 fn test_extract_accounts_from_directives() {
239 let directives = test_directives();
240 let accounts = extract_accounts(&directives);
241 assert_eq!(
242 accounts,
243 vec![
244 "Assets:Cash".to_string(),
245 "Equity:Opening".to_string(),
246 "Expenses:Food".to_string(),
247 ]
248 );
249 }
250
251 #[test]
252 fn test_extract_currencies_from_directives() {
253 let directives = test_directives();
254 let currencies = extract_currencies(&directives);
255 assert!(currencies.contains(&"BTC".to_string()));
257 assert!(currencies.contains(&"CHF".to_string()));
258 assert!(currencies.contains(&"EUR".to_string()));
259 assert!(currencies.contains(&"GBP".to_string()));
260 assert!(currencies.contains(&"USD".to_string()));
261 }
262
263 #[test]
264 fn test_extract_payees_from_directives() {
265 let directives = test_directives();
266 let payees = extract_payees(&directives);
267 assert_eq!(
268 payees,
269 vec!["Coffee Shop".to_string(), "Corner Store".to_string()]
270 );
271 }
272
273 #[test]
274 fn test_default_currencies_not_duplicated() {
275 let directives = test_directives();
277 let currencies = extract_currencies(&directives);
278 assert_eq!(
279 currencies.iter().filter(|c| *c == "USD").count(),
280 1,
281 "USD should appear exactly once"
282 );
283 }
284
285 #[test]
286 fn test_iter_variant_matches_slice_variant() {
287 let directives = test_directives();
288 assert_eq!(
289 extract_accounts(&directives),
290 extract_accounts_iter(directives.iter())
291 );
292 assert_eq!(
293 extract_currencies(&directives),
294 extract_currencies_iter(directives.iter())
295 );
296 assert_eq!(
297 extract_payees(&directives),
298 extract_payees_iter(directives.iter())
299 );
300 assert_eq!(
301 extract_tags(&directives),
302 extract_tags_iter(directives.iter())
303 );
304 assert_eq!(
305 extract_links(&directives),
306 extract_links_iter(directives.iter())
307 );
308 }
309
310 #[test]
311 fn test_extract_tags_and_links_sorted_deduped_across_positions() {
312 use crate::{Document, Link, Tag, Transaction};
313
314 let directives = vec![
315 Directive::Transaction(Transaction {
316 date: date(2024, 1, 1),
317 flag: '*',
318 payee: None,
319 narration: "".into(),
320 tags: vec![Tag::new("coffee"), Tag::new("morning")],
323 links: vec![Link::new("trip-2024")],
324 meta: Default::default(),
325 postings: vec![],
326 trailing_comments: vec![],
327 }),
328 Directive::Document(Document {
329 date: date(2024, 1, 2),
330 account: "Assets:Cash".into(),
331 path: "x.pdf".into(),
332 tags: vec![Tag::new("coffee")],
333 links: vec![Link::new("trip-2024"), Link::new("receipt")],
334 meta: Default::default(),
335 }),
336 ];
337
338 assert_eq!(
339 extract_tags(&directives),
340 vec!["coffee".to_string(), "morning".to_string()]
341 );
342 assert_eq!(
343 extract_links(&directives),
344 vec!["receipt".to_string(), "trip-2024".to_string()]
345 );
346 }
347
348 #[test]
359 fn test_extract_currencies_covers_cost_price_meta_custom() {
360 use crate::{CostSpec, Custom, Price, PriceAnnotation};
361 use rust_decimal_macros::dec;
362
363 let mut txn_meta: Metadata = Default::default();
366 txn_meta.insert("fx_pair".to_string(), MetaValue::Currency("CAD".into()));
367 let mut posting_meta: Metadata = Default::default();
368 posting_meta.insert(
369 "settled".to_string(),
370 MetaValue::Amount(Amount::new(dec!(120000), "KRW")),
371 );
372
373 let directives = vec![
374 Directive::Transaction(Transaction {
378 date: date(2024, 1, 1),
379 flag: '*',
380 payee: None,
381 narration: "".into(),
382 tags: vec![],
383 links: vec![],
384 meta: txn_meta,
385 postings: vec![crate::Spanned::synthesized(Posting {
386 account: "Assets:Stock".into(),
387 units: Some(crate::IncompleteAmount::from(Amount::new(dec!(10), "AAPL"))),
388 cost: Some(CostSpec {
389 number: Some(crate::CostNumber::PerUnit { value: dec!(150) }),
390 currency: Some("JPY".into()),
391 date: None,
392 label: None,
393 merge: false,
394 }),
395 price: Some(PriceAnnotation::unit(Amount::new(dec!(1.1), "CHF"))),
396 flag: None,
397 meta: posting_meta,
398 comments: vec![],
399 trailing_comments: vec![],
400 })],
401 trailing_comments: vec![],
402 }),
403 Directive::Price(Price {
405 date: date(2024, 1, 3),
406 currency: "AAPL".into(),
407 amount: Amount::new(dec!(200), "SGD"),
408 meta: Default::default(),
409 }),
410 Directive::Custom(Custom {
413 date: date(2024, 1, 4),
414 custom_type: "fx_corridors".to_string(),
415 values: vec![
416 MetaValue::Currency("MXN".into()),
417 MetaValue::Amount(Amount::new(dec!(30), "TWD")),
418 ],
419 meta: Default::default(),
420 }),
421 ];
422
423 let currencies = extract_currencies(&directives);
424
425 for expected in [
426 "JPY", "CHF", "SGD", "AAPL", "CAD", "KRW", "MXN", "TWD", ] {
430 assert!(
431 currencies.contains(&expected.to_string()),
432 "expected {expected} in extracted currencies; got {currencies:?}"
433 );
434 }
435 }
436
437 #[test]
447 fn test_extract_accounts_covers_note_document_meta_custom() {
448 use crate::{Custom, Document, Note};
449 let mut txn_meta: Metadata = Default::default();
450 txn_meta.insert(
451 "partner".to_string(),
452 MetaValue::Account("Assets:JointAccount".into()),
453 );
454
455 let directives = vec![
456 Directive::Note(Note {
458 date: date(2024, 1, 1),
459 account: "Assets:OldCheckingArchive".into(),
460 comment: "reconcile end of year".to_string(),
461 meta: Default::default(),
462 }),
463 Directive::Document(Document {
465 date: date(2024, 1, 2),
466 account: "Liabilities:CreditCard:CitiBank".into(),
467 path: "statement.pdf".to_string(),
468 tags: vec![],
469 links: vec![],
470 meta: Default::default(),
471 }),
472 Directive::Transaction(Transaction {
474 date: date(2024, 1, 3),
475 flag: '*',
476 payee: None,
477 narration: "".into(),
478 tags: vec![],
479 links: vec![],
480 meta: txn_meta,
481 postings: vec![],
482 trailing_comments: vec![],
483 }),
484 Directive::Custom(Custom {
486 date: date(2024, 1, 4),
487 custom_type: "budget".to_string(),
488 values: vec![MetaValue::Account("Expenses:Groceries:Whole".into())],
489 meta: Default::default(),
490 }),
491 ];
492
493 let accounts = extract_accounts(&directives);
494
495 for expected in [
496 "Assets:OldCheckingArchive",
497 "Liabilities:CreditCard:CitiBank",
498 "Assets:JointAccount",
499 "Expenses:Groceries:Whole",
500 ] {
501 assert!(
502 accounts.contains(&expected.to_string()),
503 "expected {expected} in extracted accounts (covers Note/Document/meta/Custom arms); got {accounts:?}"
504 );
505 }
506 }
507}