1pub(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
39pub 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
79pub(crate) fn tokenize_xact_header(input: &str) -> [&str; 4] {
93 if input.is_empty() {
94 panic!("Invalid input for Xact record.")
95 }
96
97 let (date, input) = scan_date(input);
101
102 let (aux_date, input) = tokenize_aux_date(input);
104
105 let (payee, input) = tokenize_payee(input);
108
109 let note = tokenize_note(input);
111
112 [date, aux_date, payee, note]
113}
114
115fn scan_date(input: &str) -> (&str, &str) {
119 match input.find(|c| c == '=' || c == ' ') {
120 Some(index) => {
121 return (&input[..index], &input[index..]);
124 }
125 None => {
126 return (&input, "");
130 }
131 };
132 }
135
136fn tokenize_aux_date(input: &str) -> (&str, &str) {
139 let aux_date: &str;
140 match input.chars().peekable().peek() {
145 Some('=') => {
146 let input = input.trim_start_matches('=');
149
150 match input.find(' ') {
152 Some(i) => return (&input[..i], &input[i..]),
153 None => return (input, ""),
154 };
155 }
156 _ => {
157 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 }
170
171fn 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
180pub(crate) fn scan_post(input: &str) -> PostTokens {
194 let input = input.trim_start();
196
197 if input.is_empty() || input.chars().nth(0) == Some(';') {
200 panic!("Posting has no account")
201 }
202
203 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 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 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
241pub fn scan_amount(input: &str) -> (AmountTokens, &str) {
249 let input = input.trim_start();
250
251 let c = *input.chars().peekable().peek().expect("A valid character");
253
254 if c.is_digit(10) || c == '-' || c == '.' || c == ',' {
255 let (quantity, input) = scan_quantity(input);
257 let (symbol, input) = scan_symbol(input);
258 (AmountTokens { quantity, symbol }, input)
259 } else {
260 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 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 input = &rest[1..];
294 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 input = &rest[1..];
307 input = input.trim_start();
309 } else if next_char == '(' {
310 todo!("valuation expression")
313 } else {
314 break;
315 }
316 }
317
318 (result, input)
319}
320
321fn 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
329fn scan_quantity(input: &str) -> (&str, &str) {
332 for (i, c) in input.char_indices() {
333 if c.is_digit(10) || c == '-' || c == '.' || c == ',' {
335 } else {
337 return (&input[..i], &input[i..].trim_start());
338 }
339 }
340 (input, "")
342}
343
344fn scan_symbol(input: &str) -> (&str, &str) {
347 let input = input.trim_start();
348
349 for (i, c) in input.char_indices() {
352 if c.is_whitespace() || c == '@' || c.is_digit(10) || c == '-' {
354 return (&input[..i], &input[i..].trim_start());
355 }
356 }
357 (input, "")
359}
360
361fn scan_cost(input: &str) -> CostTokens {
369 if input.chars().peekable().peek() != Some(&'@') {
371 return CostTokens {
372 quantity: "",
373 symbol: "",
374 is_per_unit: false,
375 remainder: "",
376 };
377 }
378
379 let (first_char, is_per_unit) = if input.chars().nth(1) != Some('@') {
383 (2, true)
385 } else {
386 (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
400pub(crate) fn scan_price_directive(input: &str) -> [&str; 5] {
407 let input = input[1..].trim_start();
409
410 let (date, input) = scan_price_element(input);
412
413 let input = input.trim_start();
415 let (time, input) = match input.chars().peekable().peek().unwrap().is_digit(10) {
416 true => scan_price_element(input),
418 false => ("", input),
420 };
421
422 let input = input.trim_start();
424 let (commodity, input) = scan_price_element(input);
425
426 let input = input.trim_start();
428 let (quantity, input) = scan_price_element(input);
429
430 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 (&input[..separator_index], &input[separator_index..])
449}
450
451fn next_element(input: &str) -> Option<&str> {
453 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 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]
540 fn test_ws_skip() {
541 let input = "\t \t Text \t";
543 let actual = input.trim();
544
545 assert_eq!("Text", actual);
546
547 }
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 let tokens = scan_post(input);
562
563 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 let tokens = scan_post(input);
577
578 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 let tokens = scan_post(input);
593
594 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 let tokens = scan_post(input);
605
606 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 }
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 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 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_eq!("Assets:Stocks", tokens.account);
848 assert_eq!("-10", tokens.quantity);
849 assert_eq!("VEUR", tokens.symbol);
850 assert_eq!("20", tokens.price_quantity);
852 assert_eq!("EUR", tokens.price_commodity);
853 assert_eq!("2023-04-01", tokens.price_date);
854 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}