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//! The currency and account walks delegate to [`crate::visit`] for
5//! exhaustive position coverage; that module is the single
6//! enumeration point — any new directive variant or new currency/
7//! account-bearing position is added there and every consumer
8//! (extract, hover, completion, …) benefits.
9
10use crate::Directive;
11use crate::visit::{visit_accounts, visit_currencies, visit_links, visit_tags};
12
13/// Common default currencies included in completions.
14pub const DEFAULT_CURRENCIES: &[&str] = &["USD", "EUR", "GBP"];
15
16/// Extract unique account names from directives (sorted, deduplicated).
17pub fn extract_accounts(directives: &[Directive]) -> Vec<String> {
18    extract_accounts_iter(directives.iter())
19}
20
21/// Extract unique account names from an iterator of directive references.
22///
23/// Use this to avoid cloning when working with `Spanned<Directive>`:
24/// ```ignore
25/// extract_accounts_iter(parse_result.directives.iter().map(|s| &s.value))
26/// ```
27/// Extract unique account names from an iterator of directive references.
28///
29/// Delegates to [`visit_accounts`] for exhaustive position coverage
30/// (Open / Close / Balance / Pad / Note / Document / Posting,
31/// metadata, Custom values, …). See that function for the
32/// authoritative position list.
33pub 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
43/// Extract unique currencies from directives (sorted, deduplicated).
44///
45/// Includes [`DEFAULT_CURRENCIES`] (USD, EUR, GBP) for completions.
46pub fn extract_currencies(directives: &[Directive]) -> Vec<String> {
47    extract_currencies_iter(directives.iter())
48}
49
50/// Extract unique currencies from an iterator of directive references.
51///
52/// Delegates to [`visit_currencies`] for exhaustive position coverage
53/// (Open / Commodity / Balance / Price / Posting units+cost+price,
54/// metadata `Currency`/`Amount` values, Custom values, …). See that
55/// function for the authoritative position list.
56///
57/// Always appends [`DEFAULT_CURRENCIES`] so completion can suggest
58/// the common codes even in a fresh document with nothing typed yet.
59pub 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
72/// Extract unique payees from transactions (sorted, deduplicated).
73pub fn extract_payees(directives: &[Directive]) -> Vec<String> {
74    extract_payees_iter(directives.iter())
75}
76
77/// Extract unique payees from an iterator of directive references.
78pub 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
94/// Extract unique tags from directives (sorted, deduplicated). Tag
95/// text is returned without the `#` sigil.
96pub fn extract_tags(directives: &[Directive]) -> Vec<String> {
97    extract_tags_iter(directives.iter())
98}
99
100/// Extract unique tags from an iterator of directive references.
101///
102/// Delegates to [`visit_tags`] for exhaustive position coverage
103/// (`Transaction.tags`, `Document.tags`, `MetaValue::Tag` in metadata,
104/// `Custom.values`). See that function for the authoritative list.
105pub 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
115/// Extract unique links from directives (sorted, deduplicated). Link
116/// text is returned without the `^` sigil.
117pub fn extract_links(directives: &[Directive]) -> Vec<String> {
118    extract_links_iter(directives.iter())
119}
120
121/// Extract unique links from an iterator of directive references.
122///
123/// Delegates to [`visit_links`] for exhaustive position coverage
124/// (`Transaction.links`, `Document.links`, `MetaValue::Link` in
125/// metadata, `Custom.values`). See that function for the list.
126pub 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        // BTC from Commodity, CHF from Balance, EUR+USD from Open, defaults GBP
256        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        // Directives already contain USD and EUR from Open currencies
276        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                // Duplicate `coffee` (also on the Document below) must
321                // dedup; tags returned without the `#`.
322                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    /// Regression test: currencies that reach the parser via
349    /// positions OTHER than `Open` / `Commodity` / `Balance` /
350    /// `Posting.units` must still appear in the extraction list.
351    ///
352    /// The earlier implementation walked only those four positions
353    /// and silently dropped currencies from cost specs, price
354    /// annotations, `Price` directives, metadata values, and Custom
355    /// directive arguments — which meant completion suggestions in
356    /// both the LSP and WASM editor were missing real currencies
357    /// the user had typed.
358    #[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        // CAD in transaction metadata as MetaValue::Currency.
364        // KRW in posting metadata as MetaValue::Amount.
365        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            // One transaction exercising four positions at once:
375            // cost spec (JPY), `@` annotation (CHF), txn meta (CAD),
376            // posting meta (KRW).
377            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            // Price directive carrying both AAPL (base) and SGD (amount).
404            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            // Custom directive arguments include MXN (Currency)
411            // and TWD (inside an Amount).
412            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", // cost / @ / Price directive (both halves)
427            "CAD", "KRW", // transaction-meta / posting-meta
428            "MXN", "TWD", // Custom.values (Currency + Amount)
429        ] {
430            assert!(
431                currencies.contains(&expected.to_string()),
432                "expected {expected} in extracted currencies; got {currencies:?}"
433            );
434        }
435    }
436
437    /// Regression test: account names that reach the parser via
438    /// positions OTHER than `Open` / `Close` / `Balance` / `Pad` /
439    /// `Posting.account` must still appear in the extraction list.
440    ///
441    /// The pre-fix walk missed `Note.account`, `Document.account`,
442    /// `MetaValue::Account` in metadata blocks, and `Custom.values`
443    /// account entries — meaning completion suggestions in both the
444    /// LSP and WASM editor were missing real accounts the user had
445    /// referenced.
446    #[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            // Note carrying an account the user has never opened elsewhere.
457            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            // Document carrying another fresh account.
464            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            // Transaction whose metadata carries an account reference.
473            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            // Custom directive whose values include an Account.
485            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}