rustledger-parser 0.15.0

Beancount parser with error recovery and full syntax support
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
//! Integration tests for the parser crate.
//!
//! Tests cover all directive types, error recovery, edge cases, and real-world scenarios.

use rustledger_core::Directive;
use rustledger_parser::{ParseError, ParseErrorKind, ParseResult, parse, parse_directives};

// ============================================================================
// Helper Functions
// ============================================================================

fn parse_ok(source: &str) -> ParseResult {
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "expected no errors, got: {:?}",
        result.errors
    );
    result
}

fn count_directive_type(result: &ParseResult, type_name: &str) -> usize {
    result
        .directives
        .iter()
        .filter(|d| match &d.value {
            Directive::Open(_) => type_name == "open",
            Directive::Close(_) => type_name == "close",
            Directive::Transaction(_) => type_name == "transaction",
            Directive::Balance(_) => type_name == "balance",
            Directive::Pad(_) => type_name == "pad",
            Directive::Price(_) => type_name == "price",
            Directive::Event(_) => type_name == "event",
            Directive::Note(_) => type_name == "note",
            Directive::Document(_) => type_name == "document",
            Directive::Commodity(_) => type_name == "commodity",
            Directive::Query(_) => type_name == "query",
            Directive::Custom(_) => type_name == "custom",
        })
        .count()
}

// ============================================================================
// Basic Directive Parsing
// ============================================================================

#[test]
fn test_parse_open_directive() {
    let source = r"2024-01-01 open Assets:Bank:Checking USD, EUR";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "open"), 1);

    if let Directive::Open(open) = &result.directives[0].value {
        assert_eq!(open.account, "Assets:Bank:Checking");
        assert_eq!(open.currencies, vec!["USD", "EUR"]);
    } else {
        panic!("expected open directive");
    }
}

#[test]
fn test_parse_close_directive() {
    let source = r"2024-12-31 close Assets:Bank:OldAccount";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "close"), 1);

    if let Directive::Close(close) = &result.directives[0].value {
        assert_eq!(close.account, "Assets:Bank:OldAccount");
    } else {
        panic!("expected close directive");
    }
}

#[test]
fn test_parse_simple_transaction() {
    let source = r#"
2024-01-15 * "Coffee Shop" "Morning coffee"
  Expenses:Food:Coffee  5.00 USD
  Assets:Cash
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        assert_eq!(txn.payee.as_deref(), Some("Coffee Shop"));
        assert_eq!(txn.narration.as_str(), "Morning coffee");
        assert_eq!(txn.postings.len(), 2);
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_transaction_with_tags_and_links() {
    let source = r#"
2024-01-15 * "Dinner" #food #restaurant ^receipt-123
  Expenses:Food  45.00 USD
  Assets:Cash
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        assert!(txn.tags.iter().any(|t| t.as_str() == "food"));
        assert!(txn.tags.iter().any(|t| t.as_str() == "restaurant"));
        assert!(txn.links.iter().any(|l| l.as_str() == "receipt-123"));
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_balance_directive() {
    let source = r"2024-01-31 balance Assets:Bank:Checking 1000.00 USD";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "balance"), 1);

    if let Directive::Balance(bal) = &result.directives[0].value {
        assert_eq!(bal.account, "Assets:Bank:Checking");
        assert_eq!(bal.amount.number.to_string(), "1000.00");
        assert_eq!(bal.amount.currency, "USD");
    } else {
        panic!("expected balance");
    }
}

#[test]
fn test_parse_pad_directive() {
    let source = r"2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "pad"), 1);

    if let Directive::Pad(pad) = &result.directives[0].value {
        assert_eq!(pad.account, "Assets:Bank:Checking");
        assert_eq!(pad.source_account, "Equity:Opening-Balances");
    } else {
        panic!("expected pad");
    }
}

#[test]
fn test_parse_price_directive() {
    let source = r"2024-01-15 price AAPL 185.50 USD";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "price"), 1);

    if let Directive::Price(price) = &result.directives[0].value {
        assert_eq!(price.currency, "AAPL");
        assert_eq!(price.amount.number.to_string(), "185.50");
        assert_eq!(price.amount.currency, "USD");
    } else {
        panic!("expected price");
    }
}

#[test]
fn test_parse_event_directive() {
    let source = r#"2024-01-01 event "location" "New York""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "event"), 1);

    if let Directive::Event(event) = &result.directives[0].value {
        assert_eq!(event.event_type, "location");
        assert_eq!(event.value, "New York");
    } else {
        panic!("expected event");
    }
}

#[test]
fn test_parse_note_directive() {
    let source = r#"2024-01-15 note Assets:Bank:Checking "Account review completed""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "note"), 1);

    if let Directive::Note(note) = &result.directives[0].value {
        assert_eq!(note.account, "Assets:Bank:Checking");
        assert_eq!(note.comment, "Account review completed");
    } else {
        panic!("expected note");
    }
}

#[test]
fn test_parse_document_directive() {
    let source = r#"2024-01-15 document Assets:Bank:Checking "/path/to/statement.pdf""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "document"), 1);

    if let Directive::Document(doc) = &result.directives[0].value {
        assert_eq!(doc.account, "Assets:Bank:Checking");
        assert_eq!(doc.path, "/path/to/statement.pdf");
    } else {
        panic!("expected document");
    }
}

#[test]
fn test_parse_commodity_directive() {
    let source = r#"2024-01-01 commodity USD
  name: "US Dollar""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "commodity"), 1);

    if let Directive::Commodity(comm) = &result.directives[0].value {
        assert_eq!(comm.currency, "USD");
    } else {
        panic!("expected commodity");
    }
}

#[test]
fn test_commodity_precision_metadata_round_trips() {
    // Issue #991: per-commodity `precision: N` metadata must round-trip
    // through parse → format → parse without changing value or type.
    use rust_decimal_macros::dec;
    use rustledger_core::{FormatConfig, MetaValue, format_directive};

    let source = "2024-01-01 commodity USD\n  precision: 2\n";
    let parsed = parse_ok(source);
    let Directive::Commodity(comm) = &parsed.directives[0].value else {
        panic!("expected commodity");
    };
    // First parse: stays a Number(2), not a string or anything else.
    assert_eq!(
        comm.meta.get("precision"),
        Some(&MetaValue::Number(dec!(2))),
        "parser must produce Number(2) for unquoted integer metadata"
    );

    // Format the directive and re-parse — the value must survive unchanged.
    let formatted = format_directive(&parsed.directives[0].value, &FormatConfig::default());
    let reparsed = parse_ok(&formatted);
    let Directive::Commodity(comm2) = &reparsed.directives[0].value else {
        panic!("expected commodity after re-parse");
    };
    assert_eq!(
        comm2.meta.get("precision"),
        Some(&MetaValue::Number(dec!(2))),
        "round-tripped precision must remain Number(2); got formatted: {formatted:?}"
    );
}

#[test]
fn test_parse_query_directive() {
    let source = r#"2024-01-01 query "expenses" "SELECT account, SUM(position)""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "query"), 1);

    if let Directive::Query(q) = &result.directives[0].value {
        assert_eq!(q.name, "expenses");
        assert!(q.query.contains("SELECT"));
    } else {
        panic!("expected query");
    }
}

#[test]
fn test_parse_custom_directive() {
    let source = r#"2024-01-01 custom "budget" Expenses:Food 500.00 USD"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "custom"), 1);
}

// ============================================================================
// Options, Includes, and Plugins
// ============================================================================

#[test]
fn test_parse_options() {
    let source = r#"
option "title" "My Ledger"
option "operating_currency" "USD"
option "operating_currency" "EUR"
"#;
    let result = parse_ok(source);
    assert_eq!(result.options.len(), 3);
    assert_eq!(result.options[0].0, "title");
    assert_eq!(result.options[0].1, "My Ledger");
}

#[test]
fn test_parse_includes() {
    let source = r#"
include "accounts.beancount"
include "transactions/2024.beancount"
"#;
    let result = parse_ok(source);
    assert_eq!(result.includes.len(), 2);
    assert_eq!(result.includes[0].0, "accounts.beancount");
    assert_eq!(result.includes[1].0, "transactions/2024.beancount");
}

#[test]
fn test_parse_plugins() {
    let source = r#"
plugin "beancount.plugins.leafonly"
plugin "beancount.plugins.check_commodity" "config_string"
"#;
    let result = parse_ok(source);
    assert_eq!(result.plugins.len(), 2);
    assert_eq!(result.plugins[0].0, "beancount.plugins.leafonly");
    assert!(result.plugins[0].1.is_none());
    assert_eq!(result.plugins[1].0, "beancount.plugins.check_commodity");
    assert_eq!(result.plugins[1].1, Some("config_string".to_string()));
}

// ============================================================================
// Complex Transactions
// ============================================================================

#[test]
fn test_parse_transaction_with_cost() {
    let source = r#"
2024-01-15 * "Buy stock"
  Assets:Brokerage  10 AAPL {185.50 USD}
  Assets:Cash  -1855.00 USD
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        let posting = &txn.postings[0];
        assert!(posting.cost.is_some());
        let cost = posting.cost.as_ref().unwrap();
        assert_eq!(cost.number_per.unwrap().to_string(), "185.50");
        assert_eq!(cost.currency.as_deref(), Some("USD"));
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_transaction_with_price() {
    let source = r#"
2024-01-15 * "Currency exchange"
  Assets:USD  100.00 USD @ 0.85 EUR
  Assets:EUR  -85.00 EUR
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        let posting = &txn.postings[0];
        assert!(posting.price.is_some());
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_transaction_with_total_cost() {
    let source = r#"
2024-01-15 * "Buy stock with fees"
  Assets:Brokerage  10 AAPL {{1860.00 USD}}
  Assets:Cash  -1860.00 USD
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        let posting = &txn.postings[0];
        assert!(posting.cost.is_some());
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_transaction_with_metadata() {
    let source = r#"
2024-01-15 * "Purchase"
  receipt: "scan-001.pdf"
  category: "office"
  Expenses:Office  100.00 USD
    item: "Printer paper"
  Assets:Cash
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        assert!(txn.meta.contains_key("receipt"));
        assert!(txn.meta.contains_key("category"));
        assert!(txn.postings[0].meta.contains_key("item"));
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_boolean_metadata() {
    let source = r#"
2024-01-15 * "Test"
  recurring: TRUE
  active: FALSE
  enabled: True
  disabled: False
  Expenses:Test  100.00 USD
  Assets:Cash
"#;
    let result = parse_ok(source);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        use rustledger_core::MetaValue;
        assert_eq!(txn.meta.get("recurring"), Some(&MetaValue::Bool(true)));
        assert_eq!(txn.meta.get("active"), Some(&MetaValue::Bool(false)));
        assert_eq!(txn.meta.get("enabled"), Some(&MetaValue::Bool(true)));
        assert_eq!(txn.meta.get("disabled"), Some(&MetaValue::Bool(false)));
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_extended_transaction_flags() {
    // Test all extended flags parse correctly
    for (flag, expected) in [
        ("P", 'P'), // Pad-generated
        ("S", 'S'), // Summarization
        ("T", 'T'), // Transfer
        ("C", 'C'), // Conversion
        ("U", 'U'), // Unrealized
        ("R", 'R'), // Return
        ("M", 'M'), // Merge
        ("#", '#'), // Bookmarked
        ("?", '?'), // Needs investigation
    ] {
        let source = format!(
            r#"
2024-01-15 {flag} "Test transaction"
  Expenses:Test  100 USD
  Assets:Cash
"#
        );
        let result = parse_ok(&source);
        if let Directive::Transaction(txn) = &result.directives[0].value {
            assert_eq!(
                txn.flag, expected,
                "Flag {flag} should parse as '{expected}'"
            );
        } else {
            panic!("expected transaction for flag {flag}");
        }
    }
}

// ============================================================================
// Error Recovery
// ============================================================================

#[test]
fn test_error_recovery_continues_parsing() {
    let source = r"
2024-01-01 open Assets:Bank

; This line has an error
2024-01-15 invalid directive here

2024-01-31 close Assets:Bank
";
    let result = parse(source);

    // Should have errors
    assert!(!result.errors.is_empty(), "expected parse errors");

    // But should still have parsed valid directives
    assert!(
        count_directive_type(&result, "open") >= 1,
        "should have parsed open directive"
    );
}

#[test]
fn test_error_on_invalid_date() {
    let source = r"2024-13-45 open Assets:Bank";
    let result = parse(source);
    assert!(!result.errors.is_empty(), "expected error for invalid date");
}

#[test]
fn test_parse_single_digit_month() {
    // Beancount accepts YYYY-M-DD (single-digit month)
    let source = "2024-1-15 open Assets:Checking\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "unexpected errors for single-digit month: {:?}",
        result.errors
    );
    assert_eq!(count_directive_type(&result, "open"), 1);
    if let Directive::Open(open) = &result.directives[0].value {
        assert_eq!(open.date, rustledger_core::naive_date(2024, 1, 15).unwrap());
    } else {
        panic!("expected open directive");
    }
}

#[test]
fn test_parse_single_digit_day() {
    // Beancount accepts YYYY-MM-D (single-digit day)
    let source = "2024-01-5 open Assets:Cash USD\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "unexpected errors for single-digit day: {:?}",
        result.errors
    );
    assert_eq!(count_directive_type(&result, "open"), 1);
}

#[test]
fn test_parse_single_digit_month_and_day() {
    // Beancount accepts YYYY-M-D (single-digit month and day)
    let source = "2024-1-1 open Assets:Cash USD\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "unexpected errors for single-digit month/day: {:?}",
        result.errors
    );
    assert_eq!(count_directive_type(&result, "open"), 1);
    if let Directive::Open(open) = &result.directives[0].value {
        assert_eq!(open.date, rustledger_core::naive_date(2024, 1, 1).unwrap());
    } else {
        panic!("expected open directive");
    }
}

#[test]
fn test_error_invalid_leap_year_date() {
    // Feb 29 in a non-leap year should produce a descriptive error
    let source = "2023-02-29 open Assets:Cash USD\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected error for invalid leap-year date"
    );
    let err = &result.errors[0];
    assert!(
        matches!(err.kind, ParseErrorKind::InvalidDateValue(_)),
        "expected InvalidDateValue error kind, got: {:?}",
        err.kind
    );
    let msg = err.message();
    assert!(
        msg.contains("day") && msg.contains("out of range"),
        "expected error mentioning 'day' and 'out of range', got: '{msg}'"
    );
    assert!(
        msg.contains("2023-02"),
        "expected error mentioning '2023-02', got: '{msg}'"
    );
}

#[test]
fn test_error_invalid_date_month_out_of_range() {
    // Month 13 should produce a descriptive error
    let source = "2024-13-01 open Assets:Cash USD\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected error for month out of range"
    );
    let err = &result.errors[0];
    assert!(
        matches!(err.kind, ParseErrorKind::InvalidDateValue(_)),
        "expected InvalidDateValue error kind, got: {:?}",
        err.kind
    );
    let msg = err.message();
    assert!(
        msg.contains("month") && msg.contains("out of range"),
        "expected error mentioning 'month' and 'out of range', got: '{msg}'"
    );
}

#[test]
fn test_error_on_invalid_account() {
    let source = r"2024-01-01 open lowercase:invalid";
    let result = parse(source);
    // Account names must start with a capital letter
    assert!(
        !result.errors.is_empty(),
        "expected error for invalid account"
    );
}

// ============================================================================
// Edge Cases
// ============================================================================

#[test]
fn test_parse_empty_input() {
    let result = parse("");
    assert!(result.errors.is_empty());
    assert!(result.directives.is_empty());
}

#[test]
fn test_parse_only_comments() {
    let source = r"
; This is a comment
; Another comment
";
    let result = parse_ok(source);
    assert!(result.directives.is_empty());
    // Verify comments are captured
    assert_eq!(result.comments.len(), 2);
    assert!(result.comments[0].value.contains("This is a comment"));
    assert!(result.comments[1].value.contains("Another comment"));
}

#[test]
fn test_parse_comments_with_directives() {
    let source = r#"
; Header comment
option "operating_currency" "USD"

; Section comment
2024-01-01 open Assets:Bank USD
  description: "Main account"

; Footer comment
"#;
    let result = parse_ok(source);

    // Should have 1 directive (open)
    assert_eq!(result.directives.len(), 1);

    // Should have 1 option
    assert_eq!(result.options.len(), 1);

    // Should have 3 comments
    assert_eq!(result.comments.len(), 3);
    assert!(result.comments[0].value.contains("Header comment"));
    assert!(result.comments[1].value.contains("Section comment"));
    assert!(result.comments[2].value.contains("Footer comment"));
}

#[test]
fn test_parse_unicode_in_narration() {
    let source = r#"2024-01-15 * "Café ☕" "Latte mit Milch"
  Expenses:Food  5.00 EUR
  Assets:Cash"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);

    if let Directive::Transaction(txn) = &result.directives[0].value {
        assert_eq!(txn.payee.as_deref(), Some("Café ☕"));
        assert_eq!(txn.narration.as_str(), "Latte mit Milch");
    } else {
        panic!("expected transaction");
    }
}

#[test]
fn test_parse_negative_amounts() {
    let source = r#"
2024-01-15 * "Refund"
  Assets:Bank  -50.00 USD
  Expenses:Food
"#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "transaction"), 1);
}

#[test]
fn test_parse_large_numbers() {
    let source = r"2024-01-15 price BTC 15000.00 USD";
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "price"), 1);
}

#[test]
fn test_parse_booking_method() {
    let source = r#"2024-01-01 open Assets:Stock "FIFO""#;
    let result = parse_ok(source);
    assert_eq!(count_directive_type(&result, "open"), 1);

    if let Directive::Open(open) = &result.directives[0].value {
        assert_eq!(open.booking, Some("FIFO".to_string()));
    } else {
        panic!("expected open");
    }
}

// ============================================================================
// Real-World Scenarios
// ============================================================================

#[test]
fn test_parse_complete_ledger() {
    let source = r#"
; Main ledger file
option "title" "Personal Finance"
option "operating_currency" "USD"

plugin "beancount.plugins.auto_accounts"

2024-01-01 open Assets:Bank:Checking USD
2024-01-01 open Assets:Bank:Savings USD
2024-01-01 open Expenses:Food
2024-01-01 open Expenses:Transport
2024-01-01 open Income:Salary

2024-01-01 pad Assets:Bank:Checking Equity:Opening-Balances

2024-01-15 * "Employer" "Monthly salary"
  Income:Salary  -5000.00 USD
  Assets:Bank:Checking  5000.00 USD

2024-01-16 * "Grocery Store" "Weekly groceries" #food
  Expenses:Food  150.00 USD
  Assets:Bank:Checking

2024-01-17 * "Gas Station" "Fill up"
  Expenses:Transport  45.00 USD
  Assets:Bank:Checking

2024-01-31 balance Assets:Bank:Checking 4805.00 USD

2024-01-31 note Assets:Bank:Checking "Reconciled with bank statement"
"#;
    let result = parse_ok(source);

    assert_eq!(result.options.len(), 2);
    assert_eq!(result.plugins.len(), 1);
    assert_eq!(count_directive_type(&result, "open"), 5);
    assert_eq!(count_directive_type(&result, "pad"), 1);
    assert_eq!(count_directive_type(&result, "transaction"), 3);
    assert_eq!(count_directive_type(&result, "balance"), 1);
    assert_eq!(count_directive_type(&result, "note"), 1);
}

#[test]
fn test_parse_investment_ledger() {
    let source = r#"
2024-01-01 open Assets:Brokerage AAPL, GOOG, USD
2024-01-01 open Income:Dividends
2024-01-01 open Income:Capital-Gains

2024-01-01 commodity AAPL
  name: "Apple Inc."

2024-01-15 * "Buy Apple stock"
  Assets:Brokerage  10 AAPL {185.00 USD, 2024-01-15}
  Assets:Brokerage  -1850.00 USD

2024-02-15 * "Receive dividend"
  Assets:Brokerage  5.00 USD
  Income:Dividends  -5.00 USD

2024-03-15 price AAPL 190.00 USD

2024-04-15 * "Sell Apple stock"
  Assets:Brokerage  -5 AAPL {185.00 USD, 2024-01-15}
  Assets:Brokerage  950.00 USD
  Income:Capital-Gains  -25.00 USD
"#;
    let result = parse_ok(source);

    assert_eq!(count_directive_type(&result, "open"), 3);
    assert_eq!(count_directive_type(&result, "commodity"), 1);
    assert_eq!(count_directive_type(&result, "transaction"), 3);
    assert_eq!(count_directive_type(&result, "price"), 1);
}

// ============================================================================
// parse_directives API
// ============================================================================

#[test]
fn test_parse_directives_simple() {
    let source = r#"
option "title" "Test"
2024-01-01 open Assets:Bank
"#;
    let (directives, errors) = parse_directives(source);
    assert!(errors.is_empty());
    assert_eq!(directives.len(), 1);
}

// ============================================================================
// Conformance: invalid inputs that must be rejected (pta-standards suite)
// ============================================================================

/// Case: invalid-leading-decimal
/// Amounts must have an integer part before the decimal point (.50 is invalid).
/// Valid amounts like 0.50 must still be accepted.
#[test]
fn test_reject_leading_decimal() {
    let source = "2024-01-15 * \"Test\"\n  Expenses:Food  .50 USD\n  Assets:Checking\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for leading decimal amount '.50 USD'"
    );
}

/// Positive counterpart: amounts with an integer part must still be accepted.
#[test]
fn test_accept_decimal_with_integer_part() {
    let source = "2024-01-15 * \"Test\"\n  Expenses:Food  0.50 USD\n  Assets:Checking\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "valid amount '0.50 USD' should parse without errors, errors: {:?}",
        result.errors
    );
}

/// Case: invalid-booking-method-lowercase / booking-method-case-sensitive
/// Booking methods must be uppercase (FIFO, STRICT, `STRICT_WITH_SIZE`, LIFO, HIFO, NONE, AVERAGE).
/// Lowercase variants like "fifo" must be rejected.
#[test]
fn test_reject_lowercase_booking_method() {
    let source = "2024-01-01 open Assets:Stock AAPL \"fifo\"\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for lowercase booking method 'fifo'"
    );
}

/// Counterpart: uppercase booking method must still be accepted.
#[test]
fn test_accept_uppercase_booking_method() {
    let source = "2024-01-01 open Assets:Stock AAPL \"FIFO\"\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "uppercase booking method 'FIFO' should be valid, errors: {:?}",
        result.errors
    );
}

/// `STRICT_WITH_SIZE` booking method must be accepted on open directives.
#[test]
fn test_accept_strict_with_size_booking_method() {
    let source = "2024-01-01 open Assets:Stock AAPL \"STRICT_WITH_SIZE\"\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "booking method 'STRICT_WITH_SIZE' should be valid, errors: {:?}",
        result.errors
    );

    if let Directive::Open(open) = &result.directives[0].value {
        assert_eq!(open.booking, Some("STRICT_WITH_SIZE".to_string()));
    } else {
        panic!("expected open directive");
    }
}

/// Invalid booking method error message should include `STRICT_WITH_SIZE` in the valid list.
#[test]
fn test_invalid_booking_method_error_includes_strict_with_size() {
    let source = "2024-01-01 open Assets:Stock AAPL \"invalid_method\"\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for invalid booking method"
    );
    let error_msg = result.errors[0].message();
    assert!(
        error_msg.contains("STRICT_WITH_SIZE"),
        "error message should list STRICT_WITH_SIZE as a valid method, got: {error_msg}"
    );
}

/// Case: invalid-metadata-uppercase-key
/// Metadata keys must start with a lowercase ASCII letter.
/// Keys starting with uppercase (e.g. "Category:") must be rejected.
#[test]
fn test_reject_uppercase_metadata_key() {
    let source =
        "2024-01-15 * \"Test\"\n  Category: \"test\"\n  Expenses:Food  50 USD\n  Assets:Checking\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for metadata key starting with uppercase 'Category:'"
    );
}

/// Case: invalid-balance-no-amount
/// Balance directives require both an account and an amount+currency.
#[test]
fn test_reject_balance_without_amount() {
    let source = "2024-01-15 balance Assets:Checking\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for balance directive without amount"
    );
}

/// Case: invalid-pad-no-source
/// Pad directives require both a target account and a source account.
#[test]
fn test_reject_pad_without_source_account() {
    let source = "2024-01-15 pad Assets:Checking\n";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for pad directive without source account"
    );
}

/// Case: unicode-account-name
/// Unicode letters (CJK, Cyrillic, etc.) are valid in account names.
/// This extends beyond the beancount v3 spec's ASCII restriction, which
/// was an artifact of the C flex lexer's poor Unicode support.
#[test]
fn test_accept_unicode_account_name() {
    let source = "2024-01-01 open Assets:銀行口座\n";
    let result = parse(source);
    assert!(
        result.errors.is_empty(),
        "Unicode account names should parse successfully, got: {:?}",
        result
            .errors
            .iter()
            .map(rustledger_parser::ParseError::message)
            .collect::<Vec<_>>()
    );
}

/// Case: invalid-cost-unclosed (issue #736)
/// A cost specification must be closed with `}` on the same logical line
/// as the opening `{`. Hitting a newline before the closing brace is a
/// parse error — the parser must not silently consume tokens on following
/// posting lines looking for a close brace.
#[test]
fn test_reject_unclosed_cost_brace() {
    let source = "\
2024-01-01 open Assets:Stock
2024-01-01 open Assets:Cash USD

2024-01-15 *
  Assets:Stock 10 AAPL {150 USD
  Assets:Cash -1500 USD
";
    let result = parse(source);
    assert!(
        result
            .errors
            .iter()
            .any(|e| e.message().contains("unclosed cost")),
        "expected 'unclosed cost' parse error, got: {:?}",
        result
            .errors
            .iter()
            .map(ParseError::message)
            .collect::<Vec<_>>()
    );
}

/// Regression: an incomplete final directive at EOF (no trailing newline
/// and no account name) must produce a parse error, not be silently
/// dropped by the top-level error-recovery loop. Guards against a Copilot
/// review finding from PR #740 where an overly-eager early-break on an
/// empty stream could mask real EOF syntax errors.
#[test]
fn test_reject_incomplete_final_directive_at_eof() {
    let source = "2024-01-01 open";
    let result = parse(source);
    assert!(
        !result.errors.is_empty(),
        "expected parse error for incomplete open directive at EOF, got: {:?}",
        result
            .errors
            .iter()
            .map(ParseError::message)
            .collect::<Vec<_>>()
    );
}

/// Regression: an unclosed cost brace followed by EOF (no trailing newline)
/// should also produce a parse error, not silently drop the cost.
#[test]
fn test_reject_unclosed_cost_brace_at_eof() {
    let source = "\
2024-01-01 open Assets:Stock
2024-01-01 open Assets:Cash USD

2024-01-15 *
  Assets:Stock 10 AAPL {150 USD";
    let result = parse(source);
    assert!(
        result
            .errors
            .iter()
            .any(|e| e.message().contains("unclosed cost")),
        "expected 'unclosed cost' parse error at EOF, got: {:?}",
        result
            .errors
            .iter()
            .map(ParseError::message)
            .collect::<Vec<_>>()
    );
}