ledger_rs_lib/
scanner.rs

1/*!
2 * Scanner scans the input text and returns tokens (groups of characters) back for parsing.
3 * Scans/tokenizes the journal files.
4 * There are scanner functions for every element of the journal.
5 */
6
7/// Tokens after scanning a Posting line.
8/// 
9/// `    Assets:Stocks  -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR`
10/// 
11pub(crate) struct PostTokens<'a> {
12    pub account: &'a str,
13    pub quantity: &'a str,
14    pub symbol: &'a str,
15    pub price_quantity: &'a str,
16    pub price_commodity: &'a str,
17    pub price_date: &'a str,
18    pub cost_quantity: &'a str,
19    pub cost_symbol: &'a str,
20    pub is_per_unit: bool,
21}
22
23impl PostTokens<'_> {
24    pub fn create_empty() -> Self {
25        Self {
26            account: "",
27            quantity: "",
28            symbol: "",
29            price_quantity: "",
30            price_commodity: "",
31            price_date: "",
32            cost_quantity: "",
33            cost_symbol: "",
34            is_per_unit: false,
35        }
36    }
37}
38
39/// Structure for the tokens from scanning the Amount part of the Posting.
40pub struct AmountTokens<'a> {
41    pub quantity: &'a str,
42    pub symbol: &'a str,
43}
44
45struct AnnotationTokens<'a> {
46    quantity: &'a str,
47    symbol: &'a str,
48    date: &'a str,
49}
50
51impl<'a> AnnotationTokens<'a> {
52    pub fn empty() -> Self {
53        Self { 
54            quantity: "",
55            symbol: "",
56            date: "",
57        }
58    }
59}
60
61struct CostTokens<'a> {
62    pub quantity: &'a str,
63    pub symbol: &'a str,
64    pub is_per_unit: bool,
65    pub remainder: &'a str,
66}
67
68impl<'a> CostTokens<'a> {
69    pub fn new() -> Self {
70        Self {
71            quantity: "",
72            symbol: "",
73            is_per_unit: false,
74            remainder: "",
75        }
76    }
77}
78
79/// Parse Xact header record.
80/// 2023-05-05=2023-05-01 Payee  ; Note
81///
82/// returns [date, aux_date, payee, note]
83///
84/// Check for .is_empty() after receiving the result and handle appropriately.
85///
86/// Ledger's documentation specifies the following format
87///
88/// DATE[=EDATE] [*|!] [(CODE)] DESC
89///
90/// but the DESC is not mandatory. <Unspecified Payee> is used in that case.
91/// So, the Payee/Description is mandatory in the model but not in the input.
92pub(crate) fn tokenize_xact_header(input: &str) -> [&str; 4] {
93    if input.is_empty() {
94        panic!("Invalid input for Xact record.")
95    }
96
97    // Dates.
98    // Date has to be at the beginning.
99
100    let (date, input) = scan_date(input);
101
102    // aux date
103    let (aux_date, input) = tokenize_aux_date(input);
104
105    // Payee
106
107    let (payee, input) = tokenize_payee(input);
108
109    // Note
110    let note = tokenize_note(input);
111
112    [date, aux_date, payee, note]
113}
114
115/// Parse date from the input string.
116///
117/// returns the (date string, remaining string)
118fn scan_date(input: &str) -> (&str, &str) {
119    match input.find(|c| c == '=' || c == ' ') {
120        Some(index) => {
121            // offset = index;
122            //date = &input[..index];
123            return (&input[..index], &input[index..]);
124        }
125        None => {
126            // offset = input.len();
127            // date = &input;
128            // return [date, "", "", ""];
129            return (&input, "");
130        }
131    };
132    // log::debug!("date: {:?}", date);
133    // (date, offset)
134}
135
136/// Parse auxillary date.
137/// Returns the (date_str, remains).
138fn tokenize_aux_date(input: &str) -> (&str, &str) {
139    let aux_date: &str;
140    // let mut cursor: usize = 0;
141    // skip ws
142    // let input = input.trim_start();
143
144    match input.chars().peekable().peek() {
145        Some('=') => {
146            // have aux date.
147            // skip '=' sign
148            let input = input.trim_start_matches('=');
149
150            // find the next separator
151            match input.find(' ') {
152                Some(i) => return (&input[..i], &input[i..]),
153                None => return (input, ""),
154            };
155        }
156        _ => {
157            // end of line, or character other than '='
158            return ("", input);
159        }
160    }
161}
162
163fn tokenize_note(input: &str) -> &str {
164    match input.is_empty() {
165        true => "",
166        false => &input[3..].trim(),
167    }
168    // log::debug!("note: {:?}", note);
169}
170
171/// Parse payee from the input string.
172/// Returns (payee, processed length)
173fn tokenize_payee(input: &str) -> (&str, &str) {
174    match input.find("  ;") {
175        Some(index) => (&input[..index].trim(), &input[index..]),
176        None => (input.trim(), ""),
177    }
178}
179
180/// Parse tokens from a Post line.
181///   ACCOUNT  AMOUNT  [; NOTE]
182///
183/// The possible syntax for an amount is:
184///   [-]NUM[ ]SYM [@ AMOUNT]
185///   SYM[ ][-]NUM [@ AMOUNT]
186///
187/// input: &str  Post content
188/// returns (account, quantity, symbol, cost_q, cost_s, is_per_unit)
189///
190/// Reference methods:
191/// - amount_t::parse
192///
193pub(crate) fn scan_post(input: &str) -> PostTokens {
194    // clear the initial whitespace.
195    let input = input.trim_start();
196
197    // todo: state = * cleared, ! pending
198
199    if input.is_empty() || input.chars().nth(0) == Some(';') {
200        panic!("Posting has no account")
201    }
202
203    // todo: virtual, deferred account [] () <>
204
205    // two spaces is a separator betweer the account and amount.
206    // Eventually, also support the tab as a separator:
207    // something like |p| p == "  " || p  == '\t'
208
209    let Some(sep_index) = input.find("  ") else {
210        let mut post_tokens = PostTokens::create_empty();
211        post_tokens.account = input.trim_end();
212        return post_tokens;
213    };
214
215    // there's more content
216
217    let account = &input[..sep_index];
218    let (amount_tokens, input) = scan_amount(&input[sep_index + 2..]);
219    let (annotation_tokens, input) = scan_annotations(input);
220    let cost_tokens = match input.is_empty() {
221        true => CostTokens::new(),
222        false => scan_cost(input),
223    };
224
225    // TODO: handle post comment
226    // scan_xyz(input)
227
228    return PostTokens {
229        account,
230        quantity: amount_tokens.quantity,
231        symbol: amount_tokens.symbol,
232        price_quantity: annotation_tokens.quantity,
233        price_commodity: annotation_tokens.symbol,
234        price_date: annotation_tokens.date,
235        cost_quantity: cost_tokens.quantity,
236        cost_symbol: cost_tokens.symbol,
237        is_per_unit: cost_tokens.is_per_unit,
238    };
239}
240
241/// Scans the first Amount from the input
242///
243/// returns: AmountTokens
244///
245/// The amount line can be `-10 VEUR {20 EUR} [2023-04-01] @ 25 EUR`
246/// of which, the amount is "-10 VEUR" and the rest is the cost, stored in
247/// an annotation.
248pub fn scan_amount(input: &str) -> (AmountTokens, &str) {
249    let input = input.trim_start();
250
251    // Check the next character
252    let c = *input.chars().peekable().peek().expect("A valid character");
253
254    if c.is_digit(10) || c == '-' || c == '.' || c == ',' {
255        // scan_amount_number_first(input)
256        let (quantity, input) = scan_quantity(input);
257        let (symbol, input) = scan_symbol(input);
258        (AmountTokens { quantity, symbol }, input)
259    } else {
260        // scan_amount_symbol_first(input)
261        let (symbol, input) = scan_symbol(input);
262        let (quantity, input) = scan_quantity(input);
263        (AmountTokens { quantity, symbol }, input)
264    }
265}
266
267fn scan_annotations(input: &str) -> (AnnotationTokens, &str) {
268    let mut input = input.trim_start();
269    if input.is_empty() {
270        return (AnnotationTokens::empty(), input);
271    }
272
273    let mut result = AnnotationTokens::empty();
274
275    loop {
276        let Some(next_char) = input.chars().nth(0)
277        else { break };
278        if next_char == '{' {
279            if !result.quantity.is_empty() {
280                panic!("Commodity specifies more than one price");
281            }
282
283            // todo: Is it per unit or total? {{25 EUR}}
284            // todo: is it fixated price {=xyz}
285
286            let (amount, rest) = scan_until(&input[1..], '}');
287            let (amount_tokens, _) = scan_amount(amount);
288
289            result.quantity = amount_tokens.quantity;
290            result.symbol = amount_tokens.symbol;
291
292            // Skip the closing curly brace.
293            input = &rest[1..];
294            // and the ws
295            input = input.trim_start();
296        } else if next_char == '[' {
297            if !result.date.is_empty() {
298                panic!("Commodity specifies more than one date");
299            }
300            let (date_input, rest) = scan_until(&input[1..], ']');
301            let (date, _) = scan_date(date_input);
302            
303            result.date = date;
304
305            // skip the closing ]
306            input = &rest[1..];
307            // and the ws
308            input = input.trim_start();
309        } else if next_char == '(' {
310            // Commodity specifies more than one valuation expression
311            
312            todo!("valuation expression")
313        } else {
314            break;
315        }
316    }
317
318    (result, input)
319}
320
321/// Scans until the given separator is found
322fn scan_until<'a>(input: &'a str, separator: char) -> (&'a str, &'a str) {
323    let Some(i) = input.find(separator)
324        else { panic!("work-out the correct return values") };
325
326    (&input[..i], &input[i..])
327}
328
329/// Reads the quantity string.
330/// Returns [quantity, remainder]
331fn scan_quantity(input: &str) -> (&str, &str) {
332    for (i, c) in input.char_indices() {
333        // stop if an invalid number character encountered.
334        if c.is_digit(10) || c == '-' || c == '.' || c == ',' {
335            // continue
336        } else {
337            return (&input[..i], &input[i..].trim_start());
338        }
339    }
340    // else, return the full input.
341    (input, "")
342}
343
344/// Scans the symbol in the input string.
345/// Returns (symbol, remainder)
346fn scan_symbol(input: &str) -> (&str, &str) {
347    let input = input.trim_start();
348
349    // TODO: check for valid double quotes
350
351    for (i, c) in input.char_indices() {
352        // Return when a separator or a number is found.
353        if c.is_whitespace() || c == '@' || c.is_digit(10) || c == '-' {
354            return (&input[..i], &input[i..].trim_start());
355        }
356    }
357    // else return the whole input.
358    (input, "")
359}
360
361/// Scans the cost
362///
363/// @ AMOUNT or @@ AMOUNT
364///
365/// The first is per-unit cost and the second is the total cost.
366/// Returns
367/// [quantity, symbol, remainder, is_per_unit]
368fn scan_cost(input: &str) -> CostTokens {
369    // @ or () or @@
370    if input.chars().peekable().peek() != Some(&'@') {
371        return CostTokens {
372            quantity: "",
373            symbol: "",
374            is_per_unit: false,
375            remainder: "",
376        };
377    }
378
379    // We have a price.
380    // () is a virtual cost. Ignore for now.
381
382    let (first_char, is_per_unit) = if input.chars().nth(1) != Some('@') {
383        // per-unit cost
384        (2, true)
385    } else {
386        // total cost
387        (3, false)
388    };
389    let input = &input[first_char..].trim_start();
390    let (amount_tokens, input) = scan_amount(input);
391
392    CostTokens {
393        quantity: amount_tokens.quantity,
394        symbol: amount_tokens.symbol,
395        is_per_unit,
396        remainder: input,
397    }
398}
399
400/// Scans the Price directive
401///
402/// i.e.
403/// P 2022-03-03 13:00:00 EUR 1.12 USD
404///
405/// returns [date, time, commodity, quantity, price_commodity]
406pub(crate) fn scan_price_directive(input: &str) -> [&str; 5] {
407    // Skip the starting P and whitespace.
408    let input = input[1..].trim_start();
409
410    // date
411    let (date, input) = scan_price_element(input);
412
413    // time
414    let input = input.trim_start();
415    let (time, input) = match input.chars().peekable().peek().unwrap().is_digit(10) {
416        // time
417        true => scan_price_element(input),
418        // no time
419        false => ("", input),
420    };
421
422    // commodity
423    let input = input.trim_start();
424    let (commodity, input) = scan_price_element(input);
425
426    // price, quantity
427    let input = input.trim_start();
428    let (quantity, input) = scan_price_element(input);
429
430    // price, commodity
431    let input = input.trim_start();
432    let (price_commodity, _input) = scan_price_element(input);
433
434    [date, time, commodity, quantity, price_commodity]
435}
436
437fn find_next_separator(input: &str) -> Option<usize> {
438    input.find(|c| c == ' ' || c == '\t')
439}
440
441fn scan_price_element(input: &str) -> (&str, &str) {
442    let Some(separator_index) = find_next_separator(input)
443        else {
444            return (input, "")
445        };
446
447    // date, rest
448    (&input[..separator_index], &input[separator_index..])
449}
450
451/// identifies the next element, the content until the next separator.
452fn next_element(input: &str) -> Option<&str> {
453    // assuming the element starts at the beginning, no whitespace.
454    if let Some(next_sep) = find_next_separator(input) {
455        Some(&input[..next_sep])
456    } else {
457        None
458    }
459}
460
461#[cfg(test)]
462mod scanner_tests_xact {
463    use super::{scan_date, tokenize_xact_header};
464
465    #[test]
466    fn test_parsing_xact_header() {
467        std::env::set_var("RUST_LOG", "trace");
468
469        let input = "2023-05-01 Payee  ; Note";
470
471        let mut iter = tokenize_xact_header(input).into_iter();
472        // let [date, aux_date, payee, note] = iter.as_slice();
473
474        assert_eq!("2023-05-01", iter.next().unwrap());
475        assert_eq!("", iter.next().unwrap());
476        assert_eq!("Payee", iter.next().unwrap());
477        assert_eq!("Note", iter.next().unwrap());
478    }
479
480    #[test]
481    fn test_parsing_xact_header_aux_dates() {
482        let input = "2023-05-02=2023-05-01 Payee  ; Note";
483
484        let mut iter = tokenize_xact_header(input).into_iter();
485
486        assert_eq!("2023-05-02", iter.next().unwrap());
487        assert_eq!("2023-05-01", iter.next().unwrap());
488        assert_eq!("Payee", iter.next().unwrap());
489        assert_eq!("Note", iter.next().unwrap());
490    }
491
492    #[test]
493    fn test_parsing_xact_header_no_note() {
494        let input = "2023-05-01 Payee";
495
496        let mut iter = tokenize_xact_header(input).into_iter();
497
498        assert_eq!("2023-05-01", iter.next().unwrap());
499        assert_eq!("", iter.next().unwrap());
500        assert_eq!("Payee", iter.next().unwrap());
501        assert_eq!("", iter.next().unwrap());
502    }
503
504    #[test]
505    fn test_parsing_xact_header_no_payee_w_note() {
506        let input = "2023-05-01  ; Note";
507
508        let mut iter = tokenize_xact_header(input).into_iter();
509
510        assert_eq!("2023-05-01", iter.next().unwrap());
511        assert_eq!("", iter.next().unwrap());
512        assert_eq!("", iter.next().unwrap());
513        assert_eq!("Note", iter.next().unwrap());
514    }
515
516    #[test]
517    fn test_parsing_xact_header_date_only() {
518        let input = "2023-05-01";
519
520        let mut iter = tokenize_xact_header(input).into_iter();
521
522        assert_eq!(input, iter.next().unwrap());
523        assert_eq!("", iter.next().unwrap());
524        assert_eq!("", iter.next().unwrap());
525        assert_eq!("", iter.next().unwrap());
526    }
527
528    #[test]
529    fn test_date_w_aux() {
530        let input = "2023-05-01=2023";
531
532        let (date, remains) = scan_date(input);
533
534        assert_eq!("2023-05-01", date);
535        assert_eq!("=2023", remains);
536    }
537
538    // test built-in ws removal with .trim()
539    #[test]
540    fn test_ws_skip() {
541        // see if trim removes tabs
542        let input = "\t \t Text \t";
543        let actual = input.trim();
544
545        assert_eq!("Text", actual);
546
547        // This confirms that .trim() and variants can be used for skipping whitespace.
548    }
549}
550
551#[cfg(test)]
552mod scanner_tests_post {
553    use super::{scan_post, scan_symbol};
554    use crate::scanner::scan_amount;
555
556    #[test]
557    fn test_tokenize_post_full() {
558        let input = "  Assets  20 VEUR @ 25.6 EUR";
559
560        // Act
561        let tokens = scan_post(input);
562
563        // Assert
564        assert_eq!("Assets", tokens.account);
565        assert_eq!("20", tokens.quantity);
566        assert_eq!("VEUR", tokens.symbol);
567        assert_eq!("25.6", tokens.cost_quantity);
568        assert_eq!("EUR", tokens.cost_symbol);
569    }
570
571    #[test]
572    fn test_tokenize_post_w_amount() {
573        let input = "Assets  20 EUR";
574
575        // Act
576        let tokens = scan_post(input);
577
578        // Assert
579        assert_eq!("Assets", tokens.account);
580        assert_eq!("20", tokens.quantity);
581        assert_eq!("EUR", tokens.symbol);
582        assert_eq!("", tokens.cost_quantity);
583        assert_eq!("", tokens.cost_symbol);
584        assert_eq!(false, tokens.is_per_unit);
585    }
586
587    #[test]
588    fn test_tokenize_post_quantity_only() {
589        let input = "Assets  20";
590
591        // Act
592        let tokens = scan_post(input);
593
594        // Assert
595        assert_eq!("Assets", tokens.account);
596        assert_eq!("20", tokens.quantity);
597    }
598
599    #[test]
600    fn test_tokenize_post_account() {
601        let input = "  Assets";
602
603        // Act
604        let tokens = scan_post(input);
605
606        // Assert
607        assert_eq!("Assets", tokens.account);
608        assert_eq!("", tokens.quantity);
609    }
610
611    #[test]
612    fn test_tokenize_amount() {
613        let input = "  Assets  25 EUR";
614
615        let tokens = scan_post(input);
616
617        assert_eq!("25", tokens.quantity);
618        assert_eq!("EUR", tokens.symbol);
619        assert_eq!("", tokens.cost_quantity);
620        assert_eq!("", tokens.cost_symbol);
621    }
622
623    #[test]
624    fn test_tokenize_neg_amount() {
625        let input = "  Expenses  -25 EUR";
626
627        let actual = scan_post(input);
628
629        assert_eq!("-25", actual.quantity);
630        assert_eq!("EUR", actual.symbol);
631    }
632
633    #[test]
634    fn test_tokenize_amount_dec_sep() {
635        let input = "  Expenses  25.0 EUR";
636
637        let actual = scan_post(input);
638
639        assert_eq!("25.0", actual.quantity);
640        assert_eq!("EUR", actual.symbol);
641    }
642
643    #[test]
644    fn test_tokenize_amount_th_sep() {
645        let input = "  Expenses  25,00 EUR";
646
647        let actual = scan_post(input);
648
649        assert_eq!("25,00", actual.quantity);
650        assert_eq!("EUR", actual.symbol);
651    }
652
653    #[test]
654    fn test_tokenize_amount_all_sep() {
655        let input = "  Expenses  25,0.01 EUR";
656
657        let actual = scan_post(input);
658
659        assert_eq!("25,0.01", actual.quantity);
660        assert_eq!("EUR", actual.symbol);
661    }
662
663    #[test]
664    fn test_tokenize_amount_symbol_first() {
665        let input = "  Expenses  €25";
666
667        let actual = scan_post(input);
668
669        assert_eq!("25", actual.quantity);
670        assert_eq!("€", actual.symbol);
671    }
672
673    #[test]
674    fn test_scan_amount_number_first_ws() {
675        let input = "  Expenses  25,0.01 EUR";
676
677        let actual = scan_post(input);
678
679        assert_eq!("Expenses", actual.account);
680        assert_eq!("25,0.01", actual.quantity);
681        assert_eq!("EUR", actual.symbol);
682        assert_eq!("", actual.cost_quantity);
683        assert_eq!("", actual.cost_symbol);
684    }
685
686    #[test]
687    fn test_scan_amount_number_first() {
688        let input = "  Expenses  25,0.01EUR";
689
690        let tokens = scan_post(input);
691
692        assert_eq!("Expenses", tokens.account);
693        assert_eq!("25,0.01", tokens.quantity);
694        assert_eq!("EUR", tokens.symbol);
695        assert_eq!("", tokens.cost_quantity);
696        assert_eq!("", tokens.cost_symbol);
697    }
698
699    #[test]
700    fn test_scan_amount_symbol_first_ws() {
701        let input = "EUR 25,0.01";
702
703        let (tokens, _) = scan_amount(input);
704
705        assert_eq!("25,0.01", tokens.quantity);
706        assert_eq!("EUR", tokens.symbol);
707    }
708
709    #[test]
710    fn test_scan_amount_symbol_first() {
711        let input = "EUR25,0.01";
712
713        let (tokens, _rest) = scan_amount(input);
714
715        assert_eq!("25,0.01", tokens.quantity);
716        assert_eq!("EUR", tokens.symbol);
717    }
718
719    #[test]
720    fn test_scan_amount_symbol_first_neg() {
721        let input = "EUR-25,0.01";
722
723        let (tokens, _rest) = scan_amount(input);
724
725        assert_eq!("-25,0.01", tokens.quantity);
726        assert_eq!("EUR", tokens.symbol);
727        // assert_eq!("", actual[2]);
728        // assert_eq!("", actual[3]);
729    }
730
731    #[test]
732    fn test_scan_quantity_full() {
733        let input = "5 VECP @ 13.68 EUR";
734
735        let (tokens, rest) = scan_amount(input);
736
737        assert_eq!("5", tokens.quantity);
738        assert_eq!("VECP", tokens.symbol);
739        assert_eq!("@ 13.68 EUR", rest);
740    }
741
742    #[test]
743    fn test_scan_symbol_quotes() {
744        let input = " \"VECP\" @ 13.68 EUR";
745
746        let (actual, remainder) = scan_symbol(input);
747
748        assert_eq!("\"VECP\"", actual);
749        assert_eq!("@ 13.68 EUR", remainder);
750    }
751
752    #[test]
753    fn test_scan_symbol() {
754        let input = " VECP @ 13.68 EUR";
755
756        let (actual, remainder) = scan_symbol(input);
757
758        assert_eq!("VECP", actual);
759        assert_eq!("@ 13.68 EUR", remainder);
760    }
761
762    #[test]
763    fn test_scan_symbol_only() {
764        let input = " VECP ";
765
766        let (actual, remainder) = scan_symbol(input);
767
768        assert_eq!("VECP", actual);
769        assert_eq!("", remainder);
770    }
771
772    #[test]
773    fn test_scanning_cost() {
774        let input = "  Account  5 VAS @ 13.21 AUD";
775
776        let tokens = scan_post(input);
777
778        // Check that the cost has been scanned
779        assert_eq!("Account", tokens.account);
780        assert_eq!("5", tokens.quantity);
781        assert_eq!("VAS", tokens.symbol);
782        assert_eq!("13.21", tokens.cost_quantity);
783        assert_eq!("AUD", tokens.cost_symbol);
784        assert_eq!(true, tokens.is_per_unit);
785    }
786
787    #[test]
788    fn test_scanning_total_cost() {
789        let input = "  Account  5 VAS @@ 10 AUD";
790
791        let tokens = scan_post(input);
792
793        // Check that the cost has been scanned
794        assert_eq!("Account", tokens.account);
795        assert_eq!("5", tokens.quantity);
796        assert_eq!("VAS", tokens.symbol);
797        assert_eq!("10", tokens.cost_quantity);
798        assert_eq!("AUD", tokens.cost_symbol);
799    }
800}
801
802#[cfg(test)]
803mod scan_annotations_tests {
804    use crate::scanner::scan_post;
805    use super::scan_annotations;
806
807    #[test]
808    fn test_scan_annotation_price() {
809        let input = "{20 EUR}";
810
811        let (tokens, rest) = scan_annotations(input);
812
813        assert_eq!("20", tokens.quantity);
814        assert_eq!("EUR", tokens.symbol);
815        assert_eq!("", rest);
816    }
817
818    #[test]
819    fn test_scan_annotation_date() {
820        let input = "[2023-11-07]";
821
822        let (tokens, rest) = scan_annotations(input);
823
824        assert_eq!("2023-11-07", tokens.date);
825        assert_eq!("", rest);
826    }
827
828    #[test]
829    fn test_scan_annotation_price_and_date() {
830        let input = "{20 EUR} [2023-11-07]";
831
832        let (tokens, rest) = scan_annotations(input);
833
834        assert_eq!("20", tokens.quantity);
835        assert_eq!("EUR", tokens.symbol);
836        assert_eq!("2023-11-07", tokens.date);
837        assert_eq!("", rest);
838    }
839
840    #[test]
841    fn test_scan_sale_lot() {
842        let input = "    Assets:Stocks  -10 VEUR {20 EUR} [2023-04-01] @ 25 EUR";
843
844        let tokens = scan_post(input);
845
846        // Assert
847        assert_eq!("Assets:Stocks", tokens.account);
848        assert_eq!("-10", tokens.quantity);
849        assert_eq!("VEUR", tokens.symbol);
850        // annotations
851        assert_eq!("20", tokens.price_quantity);
852        assert_eq!("EUR", tokens.price_commodity);
853        assert_eq!("2023-04-01", tokens.price_date);
854        // price
855        assert_eq!("25", tokens.cost_quantity);
856        assert_eq!("EUR", tokens.cost_symbol);
857    }
858}
859
860#[cfg(test)]
861mod scanner_tests_amount {
862    use super::scan_cost;
863
864    #[test]
865    fn test_scanning_costs() {
866        let input = "@ 25.86 EUR";
867
868        let tokens = scan_cost(input);
869
870        assert_eq!("25.86", tokens.quantity);
871        assert_eq!("EUR", tokens.symbol);
872        assert_eq!(true, tokens.is_per_unit);
873        assert_eq!("", tokens.remainder);
874    }
875
876    #[test]
877    fn test_scanning_cost_full() {
878        let input = "@@ 25.86 EUR";
879
880        let tokens = scan_cost(input);
881
882        assert_eq!("25.86", tokens.quantity);
883        assert_eq!("EUR", tokens.symbol);
884        assert_eq!(false, tokens.is_per_unit);
885        assert_eq!("", tokens.remainder);
886    }
887}
888
889#[cfg(test)]
890mod scanner_tests_price_directive {
891    use super::scan_price_directive;
892
893    #[test]
894    fn test_scan_price_directive() {
895        let line = "P 2022-03-03 13:00:00 EUR 1.12 USD";
896
897        let actual = scan_price_directive(line);
898
899        assert_eq!("2022-03-03", actual[0]);
900        assert_eq!("13:00:00", actual[1]);
901        assert_eq!("EUR", actual[2]);
902        assert_eq!("1.12", actual[3]);
903        assert_eq!("USD", actual[4]);
904    }
905}