Skip to main content

rustledger_core/
extract.rs

1//! Extract unique accounts, currencies, and payees from directives.
2//!
3//! These functions are used by both the WASM editor and LSP for completions.
4
5use crate::Directive;
6
7/// Common default currencies included in completions.
8pub const DEFAULT_CURRENCIES: &[&str] = &["USD", "EUR", "GBP"];
9
10/// Extract unique account names from directives (sorted, deduplicated).
11pub fn extract_accounts(directives: &[Directive]) -> Vec<String> {
12    extract_accounts_iter(directives.iter())
13}
14
15/// Extract unique account names from an iterator of directive references.
16///
17/// Use this to avoid cloning when working with `Spanned<Directive>`:
18/// ```ignore
19/// extract_accounts_iter(parse_result.directives.iter().map(|s| &s.value))
20/// ```
21pub fn extract_accounts_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
22    let mut accounts = Vec::new();
23
24    for directive in directives {
25        match directive {
26            Directive::Open(open) => accounts.push(open.account.to_string()),
27            Directive::Close(close) => accounts.push(close.account.to_string()),
28            Directive::Balance(bal) => accounts.push(bal.account.to_string()),
29            Directive::Pad(pad) => {
30                accounts.push(pad.account.to_string());
31                accounts.push(pad.source_account.to_string());
32            }
33            Directive::Transaction(txn) => {
34                for posting in &txn.postings {
35                    accounts.push(posting.account.to_string());
36                }
37            }
38            _ => {}
39        }
40    }
41
42    accounts.sort();
43    accounts.dedup();
44    accounts
45}
46
47/// Extract unique currencies from directives (sorted, deduplicated).
48///
49/// Includes [`DEFAULT_CURRENCIES`] (USD, EUR, GBP) for completions.
50pub fn extract_currencies(directives: &[Directive]) -> Vec<String> {
51    extract_currencies_iter(directives.iter())
52}
53
54/// Extract unique currencies from an iterator of directive references.
55pub fn extract_currencies_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
56    let mut currencies = Vec::new();
57
58    for directive in directives {
59        match directive {
60            Directive::Open(open) => {
61                for currency in &open.currencies {
62                    currencies.push(currency.to_string());
63                }
64            }
65            Directive::Commodity(comm) => currencies.push(comm.currency.to_string()),
66            Directive::Balance(bal) => currencies.push(bal.amount.currency.to_string()),
67            Directive::Transaction(txn) => {
68                for posting in &txn.postings {
69                    if let Some(ref units) = posting.units
70                        && let Some(currency) = units.currency()
71                    {
72                        currencies.push(currency.to_string());
73                    }
74                }
75            }
76            _ => {}
77        }
78    }
79
80    for currency in DEFAULT_CURRENCIES {
81        currencies.push((*currency).to_string());
82    }
83
84    currencies.sort();
85    currencies.dedup();
86    currencies
87}
88
89/// Extract unique payees from transactions (sorted, deduplicated).
90pub fn extract_payees(directives: &[Directive]) -> Vec<String> {
91    extract_payees_iter(directives.iter())
92}
93
94/// Extract unique payees from an iterator of directive references.
95pub fn extract_payees_iter<'a>(directives: impl Iterator<Item = &'a Directive>) -> Vec<String> {
96    let mut payees = Vec::new();
97
98    for directive in directives {
99        if let Directive::Transaction(txn) = directive
100            && let Some(ref payee) = txn.payee
101        {
102            payees.push(payee.to_string());
103        }
104    }
105
106    payees.sort();
107    payees.dedup();
108    payees
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::{Amount, Balance, Commodity, Open, Pad, Posting, Transaction};
115    use chrono::NaiveDate;
116
117    fn date(y: i32, m: u32, d: u32) -> NaiveDate {
118        NaiveDate::from_ymd_opt(y, m, d).unwrap()
119    }
120
121    fn test_directives() -> Vec<Directive> {
122        vec![
123            Directive::Open(Open {
124                date: date(2024, 1, 1),
125                account: "Assets:Cash".into(),
126                currencies: vec!["USD".into(), "EUR".into()],
127                booking: None,
128                meta: Default::default(),
129            }),
130            Directive::Open(Open {
131                date: date(2024, 1, 1),
132                account: "Expenses:Food".into(),
133                currencies: vec![],
134                booking: None,
135                meta: Default::default(),
136            }),
137            Directive::Commodity(Commodity {
138                date: date(2024, 1, 1),
139                currency: "BTC".into(),
140                meta: Default::default(),
141            }),
142            Directive::Pad(Pad {
143                date: date(2024, 1, 2),
144                account: "Assets:Cash".into(),
145                source_account: "Equity:Opening".into(),
146                meta: Default::default(),
147            }),
148            Directive::Balance(Balance {
149                date: date(2024, 1, 3),
150                account: "Assets:Cash".into(),
151                amount: Amount::new(rust_decimal_macros::dec!(100), "CHF"),
152                tolerance: None,
153                meta: Default::default(),
154            }),
155            Directive::Transaction(Transaction {
156                date: date(2024, 1, 4),
157                flag: '*',
158                payee: Some("Corner Store".into()),
159                narration: "Groceries".into(),
160                tags: vec![],
161                links: vec![],
162                meta: Default::default(),
163                postings: vec![
164                    Posting {
165                        account: "Expenses:Food".into(),
166                        units: Some(crate::IncompleteAmount::from(Amount::new(
167                            rust_decimal_macros::dec!(25),
168                            "USD",
169                        ))),
170                        cost: None,
171                        price: None,
172                        flag: None,
173                        meta: Default::default(),
174                        comments: vec![],
175                        trailing_comments: vec![],
176                    },
177                    Posting {
178                        account: "Assets:Cash".into(),
179                        units: None,
180                        cost: None,
181                        price: None,
182                        flag: None,
183                        meta: Default::default(),
184                        comments: vec![],
185                        trailing_comments: vec![],
186                    },
187                ],
188                trailing_comments: vec![],
189            }),
190            Directive::Transaction(Transaction {
191                date: date(2024, 1, 5),
192                flag: '*',
193                payee: Some("Coffee Shop".into()),
194                narration: "Coffee".into(),
195                tags: vec![],
196                links: vec![],
197                meta: Default::default(),
198                postings: vec![],
199                trailing_comments: vec![],
200            }),
201        ]
202    }
203
204    #[test]
205    fn test_empty_directives() {
206        let empty: Vec<Directive> = vec![];
207        assert!(extract_accounts(&empty).is_empty());
208        assert_eq!(extract_currencies(&empty).len(), DEFAULT_CURRENCIES.len());
209        assert!(extract_payees(&empty).is_empty());
210    }
211
212    #[test]
213    fn test_extract_accounts_from_directives() {
214        let directives = test_directives();
215        let accounts = extract_accounts(&directives);
216        assert_eq!(
217            accounts,
218            vec![
219                "Assets:Cash".to_string(),
220                "Equity:Opening".to_string(),
221                "Expenses:Food".to_string(),
222            ]
223        );
224    }
225
226    #[test]
227    fn test_extract_currencies_from_directives() {
228        let directives = test_directives();
229        let currencies = extract_currencies(&directives);
230        // BTC from Commodity, CHF from Balance, EUR+USD from Open, defaults GBP
231        assert!(currencies.contains(&"BTC".to_string()));
232        assert!(currencies.contains(&"CHF".to_string()));
233        assert!(currencies.contains(&"EUR".to_string()));
234        assert!(currencies.contains(&"GBP".to_string()));
235        assert!(currencies.contains(&"USD".to_string()));
236    }
237
238    #[test]
239    fn test_extract_payees_from_directives() {
240        let directives = test_directives();
241        let payees = extract_payees(&directives);
242        assert_eq!(
243            payees,
244            vec!["Coffee Shop".to_string(), "Corner Store".to_string()]
245        );
246    }
247
248    #[test]
249    fn test_default_currencies_not_duplicated() {
250        // Directives already contain USD and EUR from Open currencies
251        let directives = test_directives();
252        let currencies = extract_currencies(&directives);
253        assert_eq!(
254            currencies.iter().filter(|c| *c == "USD").count(),
255            1,
256            "USD should appear exactly once"
257        );
258    }
259
260    #[test]
261    fn test_iter_variant_matches_slice_variant() {
262        let directives = test_directives();
263        assert_eq!(
264            extract_accounts(&directives),
265            extract_accounts_iter(directives.iter())
266        );
267        assert_eq!(
268            extract_currencies(&directives),
269            extract_currencies_iter(directives.iter())
270        );
271        assert_eq!(
272            extract_payees(&directives),
273            extract_payees_iter(directives.iter())
274        );
275    }
276}