Skip to main content

rustledger_validate/
lib.rs

1//! Beancount validation rules.
2//!
3//! This crate implements validation checks for beancount ledgers:
4//!
5//! - Account lifecycle (opened before use, not used after close)
6//! - Balance assertions
7//! - Transaction balancing
8//! - Currency constraints
9//! - Booking validation (lot matching, sufficient units)
10//!
11//! # Error Codes
12//!
13//! All error codes follow the spec in `spec/validation.md`:
14//!
15//! | Code | Description |
16//! |------|-------------|
17//! | E1001 | Account not opened |
18//! | E1002 | Account already open |
19//! | E1003 | Account already closed |
20//! | E1004 | Account close with non-zero balance |
21//! | E1005 | Invalid account name |
22//! | E2001 | Balance assertion failed |
23//! | E2002 | Balance exceeds explicit tolerance |
24//! | E2003 | Pad without subsequent balance |
25//! | E2004 | Multiple pads for same balance |
26//! | E3001 | Transaction does not balance |
27//! | E3002 | Multiple missing amounts in transaction |
28//! | E3003 | Transaction has no postings |
29//! | E3004 | Transaction has single posting (warning) |
30//! | E4001 | No matching lot for reduction |
31//! | E4002 | Insufficient units in lot |
32//! | E4003 | Ambiguous lot match |
33//! | E4004 | Reduction would create negative inventory |
34//! | E5001 | Currency not declared |
35//! | E5002 | Currency not allowed in account |
36//! | E6001 | Duplicate metadata key |
37//! | E6002 | Invalid metadata value |
38//! | E7001 | Unknown option |
39//! | E7002 | Invalid option value |
40//! | E7003 | Duplicate option |
41//! | E8001 | Document file not found |
42//! | E10001 | Date out of order (info) |
43//! | E10002 | Entry dated in the future (warning) |
44
45#![forbid(unsafe_code)]
46#![warn(missing_docs)]
47
48mod error;
49mod validators;
50
51pub use error::{ErrorCode, Severity, ValidationError};
52
53use validators::{
54    validate_balance, validate_close, validate_document, validate_note, validate_open,
55    validate_pad, validate_transaction,
56};
57
58use chrono::{Local, NaiveDate};
59use rayon::prelude::*;
60use rust_decimal::Decimal;
61use rustledger_core::{BookingMethod, Directive, InternedStr, Inventory};
62use rustledger_parser::Spanned;
63use std::collections::{HashMap, HashSet};
64
65/// Account state for tracking lifecycle.
66#[derive(Debug, Clone)]
67struct AccountState {
68    /// Date opened.
69    opened: NaiveDate,
70    /// Date closed (if closed).
71    closed: Option<NaiveDate>,
72    /// Allowed currencies (empty = any).
73    currencies: HashSet<InternedStr>,
74    /// Booking method (stored for future use in booking validation).
75    #[allow(dead_code)]
76    booking: BookingMethod,
77}
78
79/// Validation options.
80#[derive(Debug, Clone)]
81pub struct ValidationOptions {
82    /// Whether to require commodity declarations.
83    pub require_commodities: bool,
84    /// Whether to check if document files exist.
85    pub check_documents: bool,
86    /// Whether to warn about future-dated entries.
87    pub warn_future_dates: bool,
88    /// Base directory for resolving relative document paths.
89    pub document_base: Option<std::path::PathBuf>,
90    /// Valid account type prefixes (from options like `name_assets`, `name_liabilities`, etc.).
91    /// Defaults to `["Assets", "Liabilities", "Equity", "Income", "Expenses"]`.
92    pub account_types: Vec<String>,
93    /// Whether to infer tolerance from cost (matches Python beancount's `infer_tolerance_from_cost`).
94    /// When true, tolerance for cost-based postings is calculated as: `units_quantum * cost_per_unit`.
95    pub infer_tolerance_from_cost: bool,
96    /// Tolerance multiplier (matches Python beancount's `inferred_tolerance_multiplier`).
97    /// Default is 0.5.
98    pub tolerance_multiplier: Decimal,
99}
100
101impl Default for ValidationOptions {
102    fn default() -> Self {
103        Self {
104            require_commodities: false,
105            check_documents: true, // Python beancount validates document files by default
106            warn_future_dates: false,
107            document_base: None,
108            account_types: vec![
109                "Assets".to_string(),
110                "Liabilities".to_string(),
111                "Equity".to_string(),
112                "Income".to_string(),
113                "Expenses".to_string(),
114            ],
115            // Match Python beancount defaults
116            infer_tolerance_from_cost: true,
117            tolerance_multiplier: Decimal::new(5, 1), // 0.5
118        }
119    }
120}
121
122/// Pending pad directive info.
123#[derive(Debug, Clone)]
124struct PendingPad {
125    /// Source account for padding.
126    source_account: InternedStr,
127    /// Date of the pad directive.
128    date: NaiveDate,
129    /// Whether this pad has been used (has at least one balance assertion).
130    used: bool,
131}
132
133/// Ledger state for validation.
134#[derive(Debug, Default)]
135pub struct LedgerState {
136    /// Account states.
137    accounts: HashMap<InternedStr, AccountState>,
138    /// Account inventories.
139    inventories: HashMap<InternedStr, Inventory>,
140    /// Declared commodities.
141    commodities: HashSet<InternedStr>,
142    /// Pending pad directives (account -> list of pads).
143    pending_pads: HashMap<InternedStr, Vec<PendingPad>>,
144    /// Validation options.
145    options: ValidationOptions,
146    /// Track previous directive date for out-of-order detection.
147    last_date: Option<NaiveDate>,
148}
149
150impl LedgerState {
151    /// Create a new ledger state.
152    #[must_use]
153    pub fn new() -> Self {
154        Self::default()
155    }
156
157    /// Create a new ledger state with options.
158    #[must_use]
159    pub fn with_options(options: ValidationOptions) -> Self {
160        Self {
161            options,
162            ..Default::default()
163        }
164    }
165
166    /// Set whether to require commodity declarations.
167    pub const fn set_require_commodities(&mut self, require: bool) {
168        self.options.require_commodities = require;
169    }
170
171    /// Set whether to check document files.
172    pub const fn set_check_documents(&mut self, check: bool) {
173        self.options.check_documents = check;
174    }
175
176    /// Set whether to warn about future dates.
177    pub const fn set_warn_future_dates(&mut self, warn: bool) {
178        self.options.warn_future_dates = warn;
179    }
180
181    /// Set the document base directory.
182    pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
183        self.options.document_base = Some(base.into());
184    }
185
186    /// Get the inventory for an account.
187    #[must_use]
188    pub fn inventory(&self, account: &str) -> Option<&Inventory> {
189        self.inventories.get(account)
190    }
191
192    /// Get all account names.
193    pub fn accounts(&self) -> impl Iterator<Item = &str> {
194        self.accounts.keys().map(InternedStr::as_str)
195    }
196}
197
198/// Validate a stream of directives.
199///
200/// Returns a list of validation errors found.
201pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
202    validate_with_options(directives, ValidationOptions::default())
203}
204
205/// Validate a stream of directives with custom options.
206///
207/// Returns a list of validation errors and warnings found.
208pub fn validate_with_options(
209    directives: &[Directive],
210    options: ValidationOptions,
211) -> Vec<ValidationError> {
212    let mut state = LedgerState::with_options(options);
213    let mut errors = Vec::new();
214
215    let today = Local::now().date_naive();
216
217    // Sort directives by date, then by type priority (parallel)
218    // (e.g., balance assertions before transactions on the same day)
219    let mut sorted: Vec<&Directive> = directives.iter().collect();
220    sorted.par_sort_by(|a, b| {
221        a.date()
222            .cmp(&b.date())
223            .then_with(|| a.priority().cmp(&b.priority()))
224    });
225
226    for directive in sorted {
227        let date = directive.date();
228
229        // Check for date ordering (info only - we sort anyway)
230        if let Some(last) = state.last_date {
231            if date < last {
232                errors.push(ValidationError::new(
233                    ErrorCode::DateOutOfOrder,
234                    format!("Directive date {date} is before previous directive {last}"),
235                    date,
236                ));
237            }
238        }
239        state.last_date = Some(date);
240
241        // Check for future dates if enabled
242        if state.options.warn_future_dates && date > today {
243            errors.push(ValidationError::new(
244                ErrorCode::FutureDate,
245                format!("Entry dated in the future: {date}"),
246                date,
247            ));
248        }
249
250        match directive {
251            Directive::Open(open) => {
252                validate_open(&mut state, open, &mut errors);
253            }
254            Directive::Close(close) => {
255                validate_close(&mut state, close, &mut errors);
256            }
257            Directive::Transaction(txn) => {
258                validate_transaction(&mut state, txn, &mut errors);
259            }
260            Directive::Balance(bal) => {
261                validate_balance(&mut state, bal, &mut errors);
262            }
263            Directive::Commodity(comm) => {
264                state.commodities.insert(comm.currency.clone());
265            }
266            Directive::Pad(pad) => {
267                validate_pad(&mut state, pad, &mut errors);
268            }
269            Directive::Document(doc) => {
270                validate_document(&state, doc, &mut errors);
271            }
272            Directive::Note(note) => {
273                validate_note(&state, note, &mut errors);
274            }
275            _ => {}
276        }
277    }
278
279    // Check for unused pads (E2003)
280    for (target_account, pads) in &state.pending_pads {
281        for pad in pads {
282            if !pad.used {
283                errors.push(
284                    ValidationError::new(
285                        ErrorCode::PadWithoutBalance,
286                        "Unused Pad entry".to_string(),
287                        pad.date,
288                    )
289                    .with_context(format!(
290                        "   {} pad {} {}",
291                        pad.date, target_account, pad.source_account
292                    )),
293                );
294            }
295        }
296    }
297
298    errors
299}
300
301/// Validate a stream of spanned directives with custom options.
302///
303/// This variant accepts `Spanned<Directive>` to preserve source location information,
304/// which is propagated to any validation errors. This enables IDE-friendly error
305/// messages with `file:line` information.
306///
307/// Returns a list of validation errors and warnings found, each with source location
308/// when available.
309pub fn validate_spanned_with_options(
310    directives: &[Spanned<Directive>],
311    options: ValidationOptions,
312) -> Vec<ValidationError> {
313    let mut state = LedgerState::with_options(options);
314    let mut errors = Vec::new();
315
316    let today = Local::now().date_naive();
317
318    // Sort directives by date, then by type priority (parallel)
319    let mut sorted: Vec<&Spanned<Directive>> = directives.iter().collect();
320    sorted.par_sort_by(|a, b| {
321        a.value
322            .date()
323            .cmp(&b.value.date())
324            .then_with(|| a.value.priority().cmp(&b.value.priority()))
325    });
326
327    for spanned in sorted {
328        let directive = &spanned.value;
329        let date = directive.date();
330
331        // Check for date ordering (info only - we sort anyway)
332        if let Some(last) = state.last_date {
333            if date < last {
334                errors.push(ValidationError::with_location(
335                    ErrorCode::DateOutOfOrder,
336                    format!("Directive date {date} is before previous directive {last}"),
337                    date,
338                    spanned,
339                ));
340            }
341        }
342        state.last_date = Some(date);
343
344        // Check for future dates if enabled
345        if state.options.warn_future_dates && date > today {
346            errors.push(ValidationError::with_location(
347                ErrorCode::FutureDate,
348                format!("Entry dated in the future: {date}"),
349                date,
350                spanned,
351            ));
352        }
353
354        // Track error count before helper function so we can patch new errors with location
355        let error_count_before = errors.len();
356
357        match directive {
358            Directive::Open(open) => {
359                validate_open(&mut state, open, &mut errors);
360            }
361            Directive::Close(close) => {
362                validate_close(&mut state, close, &mut errors);
363            }
364            Directive::Transaction(txn) => {
365                validate_transaction(&mut state, txn, &mut errors);
366            }
367            Directive::Balance(bal) => {
368                validate_balance(&mut state, bal, &mut errors);
369            }
370            Directive::Commodity(comm) => {
371                state.commodities.insert(comm.currency.clone());
372            }
373            Directive::Pad(pad) => {
374                validate_pad(&mut state, pad, &mut errors);
375            }
376            Directive::Document(doc) => {
377                validate_document(&state, doc, &mut errors);
378            }
379            Directive::Note(note) => {
380                validate_note(&state, note, &mut errors);
381            }
382            _ => {}
383        }
384
385        // Patch any new errors with location info from the current directive
386        for error in errors.iter_mut().skip(error_count_before) {
387            if error.span.is_none() {
388                error.span = Some(spanned.span);
389                error.file_id = Some(spanned.file_id);
390            }
391        }
392    }
393
394    // Check for unused pads (E2003)
395    // Note: These errors won't have location info since we don't store spans in PendingPad
396    for (target_account, pads) in &state.pending_pads {
397        for pad in pads {
398            if !pad.used {
399                errors.push(
400                    ValidationError::new(
401                        ErrorCode::PadWithoutBalance,
402                        "Unused Pad entry".to_string(),
403                        pad.date,
404                    )
405                    .with_context(format!(
406                        "   {} pad {} {}",
407                        pad.date, target_account, pad.source_account
408                    )),
409                );
410            }
411        }
412    }
413
414    errors
415}
416
417#[cfg(test)]
418mod tests {
419    use super::*;
420    use rust_decimal_macros::dec;
421    use rustledger_core::{
422        Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
423    };
424
425    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
426        NaiveDate::from_ymd_opt(year, month, day).unwrap()
427    }
428
429    #[test]
430    fn test_validate_account_lifecycle() {
431        let directives = vec![
432            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
433            Directive::Transaction(
434                Transaction::new(date(2024, 1, 15), "Test")
435                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
436                    .with_posting(Posting::new(
437                        "Income:Salary",
438                        Amount::new(dec!(-100), "USD"),
439                    )),
440            ),
441        ];
442
443        let errors = validate(&directives);
444
445        // Should have error: Income:Salary not opened
446        assert!(errors
447            .iter()
448            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
449    }
450
451    #[test]
452    fn test_validate_account_used_before_open() {
453        let directives = vec![
454            Directive::Transaction(
455                Transaction::new(date(2024, 1, 1), "Test")
456                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
457                    .with_posting(Posting::new(
458                        "Income:Salary",
459                        Amount::new(dec!(-100), "USD"),
460                    )),
461            ),
462            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
463        ];
464
465        let errors = validate(&directives);
466
467        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
468    }
469
470    #[test]
471    fn test_validate_account_used_after_close() {
472        let directives = vec![
473            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
474            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
475            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
476            Directive::Transaction(
477                Transaction::new(date(2024, 7, 1), "Test")
478                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
479                    .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
480            ),
481        ];
482
483        let errors = validate(&directives);
484
485        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
486    }
487
488    #[test]
489    fn test_validate_balance_assertion() {
490        let directives = vec![
491            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
492            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
493            Directive::Transaction(
494                Transaction::new(date(2024, 1, 15), "Deposit")
495                    .with_posting(Posting::new(
496                        "Assets:Bank",
497                        Amount::new(dec!(1000.00), "USD"),
498                    ))
499                    .with_posting(Posting::new(
500                        "Income:Salary",
501                        Amount::new(dec!(-1000.00), "USD"),
502                    )),
503            ),
504            Directive::Balance(Balance::new(
505                date(2024, 1, 16),
506                "Assets:Bank",
507                Amount::new(dec!(1000.00), "USD"),
508            )),
509        ];
510
511        let errors = validate(&directives);
512        assert!(errors.is_empty(), "{errors:?}");
513    }
514
515    #[test]
516    fn test_validate_balance_assertion_failed() {
517        let directives = vec![
518            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
519            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
520            Directive::Transaction(
521                Transaction::new(date(2024, 1, 15), "Deposit")
522                    .with_posting(Posting::new(
523                        "Assets:Bank",
524                        Amount::new(dec!(1000.00), "USD"),
525                    ))
526                    .with_posting(Posting::new(
527                        "Income:Salary",
528                        Amount::new(dec!(-1000.00), "USD"),
529                    )),
530            ),
531            Directive::Balance(Balance::new(
532                date(2024, 1, 16),
533                "Assets:Bank",
534                Amount::new(dec!(500.00), "USD"), // Wrong!
535            )),
536        ];
537
538        let errors = validate(&directives);
539        assert!(
540            errors
541                .iter()
542                .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
543        );
544    }
545
546    /// Test that balance assertions use 2x tolerance multiplier (beancount compatibility).
547    ///
548    /// Python beancount uses 2x the inferred tolerance for Balance/Pad directives
549    /// because user-created balances may be further off than transaction amounts.
550    /// For 2 decimal places: base tolerance 0.005 * 2 = 0.01.
551    #[test]
552    fn test_validate_balance_assertion_2x_tolerance_multiplier() {
553        // Actual balance is 100.00, assertion is 100.01
554        // Difference is 0.01, which equals the 2x tolerance (0.005 * 2)
555        // This should PASS with the 2x multiplier
556        let directives = vec![
557            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
558            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
559            Directive::Transaction(
560                Transaction::new(date(2024, 1, 15), "Deposit")
561                    .with_posting(Posting::new(
562                        "Assets:Bank",
563                        Amount::new(dec!(100.00), "USD"),
564                    ))
565                    .with_posting(Posting::new(
566                        "Expenses:Misc",
567                        Amount::new(dec!(-100.00), "USD"),
568                    )),
569            ),
570            Directive::Balance(Balance::new(
571                date(2024, 1, 16),
572                "Assets:Bank",
573                Amount::new(dec!(100.01), "USD"), // 0.01 difference, within 2x tolerance
574            )),
575        ];
576
577        let errors = validate(&directives);
578        assert!(
579            errors.is_empty(),
580            "Balance within 2x tolerance should pass: {errors:?}"
581        );
582    }
583
584    /// Test that balance assertions still fail when exceeding 2x tolerance.
585    #[test]
586    fn test_validate_balance_assertion_exceeds_2x_tolerance() {
587        // Actual balance is 100.00, assertion is 100.011
588        // Difference is 0.011, which exceeds the 2x tolerance for 3 decimals (0.0005 * 2 = 0.001)
589        // This should FAIL
590        let directives = vec![
591            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
592            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
593            Directive::Transaction(
594                Transaction::new(date(2024, 1, 15), "Deposit")
595                    .with_posting(Posting::new(
596                        "Assets:Bank",
597                        Amount::new(dec!(100.00), "USD"),
598                    ))
599                    .with_posting(Posting::new(
600                        "Expenses:Misc",
601                        Amount::new(dec!(-100.00), "USD"),
602                    )),
603            ),
604            Directive::Balance(Balance::new(
605                date(2024, 1, 16),
606                "Assets:Bank",
607                Amount::new(dec!(100.011), "USD"), // 0.011 difference, exceeds 2x tolerance
608            )),
609        ];
610
611        let errors = validate(&directives);
612        assert!(
613            errors
614                .iter()
615                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
616            "Balance exceeding 2x tolerance should fail"
617        );
618    }
619
620    #[test]
621    fn test_validate_unbalanced_transaction() {
622        let directives = vec![
623            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
624            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
625            Directive::Transaction(
626                Transaction::new(date(2024, 1, 15), "Unbalanced")
627                    .with_posting(Posting::new(
628                        "Assets:Bank",
629                        Amount::new(dec!(-50.00), "USD"),
630                    ))
631                    .with_posting(Posting::new(
632                        "Expenses:Food",
633                        Amount::new(dec!(40.00), "USD"),
634                    )), // Missing $10
635            ),
636        ];
637
638        let errors = validate(&directives);
639        assert!(
640            errors
641                .iter()
642                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
643        );
644    }
645
646    #[test]
647    fn test_validate_currency_not_allowed() {
648        let directives = vec![
649            Directive::Open(
650                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
651            ),
652            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
653            Directive::Transaction(
654                Transaction::new(date(2024, 1, 15), "Test")
655                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
656                    .with_posting(Posting::new(
657                        "Income:Salary",
658                        Amount::new(dec!(-100.00), "EUR"),
659                    )),
660            ),
661        ];
662
663        let errors = validate(&directives);
664        assert!(
665            errors
666                .iter()
667                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
668        );
669    }
670
671    #[test]
672    fn test_validate_future_date_warning() {
673        // Create a date in the future
674        let future_date = Local::now().date_naive() + chrono::Duration::days(30);
675
676        let directives = vec![Directive::Open(Open {
677            date: future_date,
678            account: "Assets:Bank".into(),
679            currencies: vec![],
680            booking: None,
681            meta: Default::default(),
682        })];
683
684        // Without warn_future_dates option, no warnings
685        let errors = validate(&directives);
686        assert!(
687            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
688            "Should not warn about future dates by default"
689        );
690
691        // With warn_future_dates option, should warn
692        let options = ValidationOptions {
693            warn_future_dates: true,
694            ..Default::default()
695        };
696        let errors = validate_with_options(&directives, options);
697        assert!(
698            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
699            "Should warn about future dates when enabled"
700        );
701    }
702
703    #[test]
704    fn test_validate_document_not_found() {
705        let directives = vec![
706            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
707            Directive::Document(Document {
708                date: date(2024, 1, 15),
709                account: "Assets:Bank".into(),
710                path: "/nonexistent/path/to/document.pdf".to_string(),
711                tags: vec![],
712                links: vec![],
713                meta: Default::default(),
714            }),
715        ];
716
717        // With default options (check_documents: true), should error
718        let errors = validate(&directives);
719        assert!(
720            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
721            "Should check documents by default"
722        );
723
724        // With check_documents disabled, should not error
725        let options = ValidationOptions {
726            check_documents: false,
727            ..Default::default()
728        };
729        let errors = validate_with_options(&directives, options);
730        assert!(
731            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
732            "Should not report missing document when disabled"
733        );
734    }
735
736    #[test]
737    fn test_validate_document_account_not_open() {
738        let directives = vec![Directive::Document(Document {
739            date: date(2024, 1, 15),
740            account: "Assets:Unknown".into(),
741            path: "receipt.pdf".to_string(),
742            tags: vec![],
743            links: vec![],
744            meta: Default::default(),
745        })];
746
747        let errors = validate(&directives);
748        assert!(
749            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
750            "Should error for document on unopened account"
751        );
752    }
753
754    #[test]
755    fn test_error_code_is_warning() {
756        assert!(!ErrorCode::AccountNotOpen.is_warning());
757        assert!(!ErrorCode::DocumentNotFound.is_warning());
758        assert!(ErrorCode::FutureDate.is_warning());
759    }
760
761    #[test]
762    fn test_validate_pad_basic() {
763        let directives = vec![
764            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
765            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
766            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
767            Directive::Balance(Balance::new(
768                date(2024, 1, 2),
769                "Assets:Bank",
770                Amount::new(dec!(1000.00), "USD"),
771            )),
772        ];
773
774        let errors = validate(&directives);
775        // Should have no errors - pad should satisfy the balance
776        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
777    }
778
779    #[test]
780    fn test_validate_pad_with_existing_balance() {
781        let directives = vec![
782            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
783            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
784            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
785            // Add some initial transactions
786            Directive::Transaction(
787                Transaction::new(date(2024, 1, 5), "Initial deposit")
788                    .with_posting(Posting::new(
789                        "Assets:Bank",
790                        Amount::new(dec!(500.00), "USD"),
791                    ))
792                    .with_posting(Posting::new(
793                        "Income:Salary",
794                        Amount::new(dec!(-500.00), "USD"),
795                    )),
796            ),
797            // Pad to reach the target balance
798            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
799            Directive::Balance(Balance::new(
800                date(2024, 1, 15),
801                "Assets:Bank",
802                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
803            )),
804        ];
805
806        let errors = validate(&directives);
807        // Should have no errors - pad should add the missing 500
808        assert!(
809            errors.is_empty(),
810            "Pad should add missing amount: {errors:?}"
811        );
812    }
813
814    #[test]
815    fn test_validate_pad_account_not_open() {
816        let directives = vec![
817            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
818            // Assets:Bank not opened
819            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
820        ];
821
822        let errors = validate(&directives);
823        assert!(
824            errors
825                .iter()
826                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
827            "Should error for pad on unopened account"
828        );
829    }
830
831    #[test]
832    fn test_validate_pad_source_not_open() {
833        let directives = vec![
834            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
835            // Equity:Opening not opened
836            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
837        ];
838
839        let errors = validate(&directives);
840        assert!(
841            errors.iter().any(
842                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
843            ),
844            "Should error for pad with unopened source account"
845        );
846    }
847
848    #[test]
849    fn test_validate_pad_negative_adjustment() {
850        // Test that pad can reduce a balance too
851        let directives = vec![
852            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
853            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
854            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
855            // Add more than needed
856            Directive::Transaction(
857                Transaction::new(date(2024, 1, 5), "Big deposit")
858                    .with_posting(Posting::new(
859                        "Assets:Bank",
860                        Amount::new(dec!(2000.00), "USD"),
861                    ))
862                    .with_posting(Posting::new(
863                        "Income:Salary",
864                        Amount::new(dec!(-2000.00), "USD"),
865                    )),
866            ),
867            // Pad to reach a lower target
868            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
869            Directive::Balance(Balance::new(
870                date(2024, 1, 15),
871                "Assets:Bank",
872                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
873            )),
874        ];
875
876        let errors = validate(&directives);
877        assert!(
878            errors.is_empty(),
879            "Pad should handle negative adjustment: {errors:?}"
880        );
881    }
882
883    #[test]
884    fn test_validate_insufficient_units() {
885        use rustledger_core::CostSpec;
886
887        let cost_spec = CostSpec::empty()
888            .with_number_per(dec!(150))
889            .with_currency("USD");
890
891        let directives = vec![
892            Directive::Open(
893                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
894            ),
895            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
896            // Buy 10 shares
897            Directive::Transaction(
898                Transaction::new(date(2024, 1, 15), "Buy")
899                    .with_posting(
900                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
901                            .with_cost(cost_spec.clone()),
902                    )
903                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
904            ),
905            // Try to sell 15 shares (more than we have)
906            Directive::Transaction(
907                Transaction::new(date(2024, 6, 1), "Sell too many")
908                    .with_posting(
909                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
910                            .with_cost(cost_spec),
911                    )
912                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
913            ),
914        ];
915
916        let errors = validate(&directives);
917        assert!(
918            errors
919                .iter()
920                .any(|e| e.code == ErrorCode::InsufficientUnits),
921            "Should error for insufficient units: {errors:?}"
922        );
923    }
924
925    #[test]
926    fn test_validate_no_matching_lot() {
927        use rustledger_core::CostSpec;
928
929        let directives = vec![
930            Directive::Open(
931                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
932            ),
933            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
934            // Buy at $150
935            Directive::Transaction(
936                Transaction::new(date(2024, 1, 15), "Buy")
937                    .with_posting(
938                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
939                            CostSpec::empty()
940                                .with_number_per(dec!(150))
941                                .with_currency("USD"),
942                        ),
943                    )
944                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
945            ),
946            // Try to sell at $160 (no lot at this price)
947            Directive::Transaction(
948                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
949                    .with_posting(
950                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
951                            CostSpec::empty()
952                                .with_number_per(dec!(160))
953                                .with_currency("USD"),
954                        ),
955                    )
956                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
957            ),
958        ];
959
960        let errors = validate(&directives);
961        assert!(
962            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
963            "Should error for no matching lot: {errors:?}"
964        );
965    }
966
967    #[test]
968    fn test_validate_multiple_lot_match_uses_fifo() {
969        // In Python beancount, when multiple lots match the same cost spec,
970        // STRICT mode falls back to FIFO order rather than erroring.
971        use rustledger_core::CostSpec;
972
973        let cost_spec = CostSpec::empty()
974            .with_number_per(dec!(150))
975            .with_currency("USD");
976
977        let directives = vec![
978            Directive::Open(
979                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
980            ),
981            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
982            // Buy at $150 on Jan 15
983            Directive::Transaction(
984                Transaction::new(date(2024, 1, 15), "Buy lot 1")
985                    .with_posting(
986                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
987                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
988                    )
989                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
990            ),
991            // Buy again at $150 on Feb 15 (creates second lot at same price)
992            Directive::Transaction(
993                Transaction::new(date(2024, 2, 15), "Buy lot 2")
994                    .with_posting(
995                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
996                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
997                    )
998                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
999            ),
1000            // Sell with cost spec that matches both lots - STRICT falls back to FIFO
1001            Directive::Transaction(
1002                Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1003                    .with_posting(
1004                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1005                            .with_cost(cost_spec),
1006                    )
1007                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1008            ),
1009        ];
1010
1011        let errors = validate(&directives);
1012        // Filter out only booking errors - balance may or may not match
1013        let booking_errors: Vec<_> = errors
1014            .iter()
1015            .filter(|e| {
1016                matches!(
1017                    e.code,
1018                    ErrorCode::InsufficientUnits
1019                        | ErrorCode::NoMatchingLot
1020                        | ErrorCode::AmbiguousLotMatch
1021                )
1022            })
1023            .collect();
1024        assert!(
1025            booking_errors.is_empty(),
1026            "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1027        );
1028    }
1029
1030    #[test]
1031    fn test_validate_successful_booking() {
1032        use rustledger_core::CostSpec;
1033
1034        let cost_spec = CostSpec::empty()
1035            .with_number_per(dec!(150))
1036            .with_currency("USD");
1037
1038        let directives = vec![
1039            Directive::Open(
1040                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1041            ),
1042            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1043            // Buy 10 shares
1044            Directive::Transaction(
1045                Transaction::new(date(2024, 1, 15), "Buy")
1046                    .with_posting(
1047                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1048                            .with_cost(cost_spec.clone()),
1049                    )
1050                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1051            ),
1052            // Sell 5 shares (should succeed with FIFO)
1053            Directive::Transaction(
1054                Transaction::new(date(2024, 6, 1), "Sell")
1055                    .with_posting(
1056                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1057                            .with_cost(cost_spec),
1058                    )
1059                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1060            ),
1061        ];
1062
1063        let errors = validate(&directives);
1064        // Filter out any balance errors (we're testing booking only)
1065        let booking_errors: Vec<_> = errors
1066            .iter()
1067            .filter(|e| {
1068                matches!(
1069                    e.code,
1070                    ErrorCode::InsufficientUnits
1071                        | ErrorCode::NoMatchingLot
1072                        | ErrorCode::AmbiguousLotMatch
1073                )
1074            })
1075            .collect();
1076        assert!(
1077            booking_errors.is_empty(),
1078            "Should have no booking errors: {booking_errors:?}"
1079        );
1080    }
1081
1082    #[test]
1083    fn test_validate_account_already_open() {
1084        let directives = vec![
1085            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1086            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
1087        ];
1088
1089        let errors = validate(&directives);
1090        assert!(
1091            errors
1092                .iter()
1093                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1094            "Should error for duplicate open: {errors:?}"
1095        );
1096    }
1097
1098    #[test]
1099    fn test_validate_account_close_not_empty() {
1100        let directives = vec![
1101            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1102            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1103            Directive::Transaction(
1104                Transaction::new(date(2024, 1, 15), "Deposit")
1105                    .with_posting(Posting::new(
1106                        "Assets:Bank",
1107                        Amount::new(dec!(100.00), "USD"),
1108                    ))
1109                    .with_posting(Posting::new(
1110                        "Income:Salary",
1111                        Amount::new(dec!(-100.00), "USD"),
1112                    )),
1113            ),
1114            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
1115        ];
1116
1117        let errors = validate(&directives);
1118        assert!(
1119            errors
1120                .iter()
1121                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1122            "Should warn for closing account with balance: {errors:?}"
1123        );
1124    }
1125
1126    #[test]
1127    fn test_validate_no_postings_allowed() {
1128        // Python beancount allows transactions with no postings (metadata-only).
1129        // We match this behavior.
1130        let directives = vec![
1131            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1132            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1133        ];
1134
1135        let errors = validate(&directives);
1136        assert!(
1137            !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1138            "Should NOT error for transaction with no postings: {errors:?}"
1139        );
1140    }
1141
1142    #[test]
1143    fn test_validate_single_posting() {
1144        let directives = vec![
1145            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1146            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1147                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1148            )),
1149        ];
1150
1151        let errors = validate(&directives);
1152        assert!(
1153            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1154            "Should warn for transaction with single posting: {errors:?}"
1155        );
1156        // Check it's a warning not error
1157        assert!(ErrorCode::SinglePosting.is_warning());
1158    }
1159
1160    #[test]
1161    fn test_validate_pad_without_balance() {
1162        let directives = vec![
1163            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1164            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1165            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1166            // No balance assertion follows!
1167        ];
1168
1169        let errors = validate(&directives);
1170        assert!(
1171            errors
1172                .iter()
1173                .any(|e| e.code == ErrorCode::PadWithoutBalance),
1174            "Should error for pad without subsequent balance: {errors:?}"
1175        );
1176    }
1177
1178    #[test]
1179    fn test_validate_multiple_pads_for_balance() {
1180        let directives = vec![
1181            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1182            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1183            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1184            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
1185            Directive::Balance(Balance::new(
1186                date(2024, 1, 3),
1187                "Assets:Bank",
1188                Amount::new(dec!(1000.00), "USD"),
1189            )),
1190        ];
1191
1192        let errors = validate(&directives);
1193        assert!(
1194            errors
1195                .iter()
1196                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1197            "Should error for multiple pads before balance: {errors:?}"
1198        );
1199    }
1200
1201    #[test]
1202    fn test_error_severity() {
1203        // Errors
1204        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1205        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1206        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1207
1208        // Warnings
1209        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1210        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1211        assert_eq!(
1212            ErrorCode::AccountCloseNotEmpty.severity(),
1213            Severity::Warning
1214        );
1215
1216        // Info
1217        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1218    }
1219
1220    #[test]
1221    fn test_validate_invalid_account_name() {
1222        // Test invalid root type
1223        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1224
1225        let errors = validate(&directives);
1226        assert!(
1227            errors
1228                .iter()
1229                .any(|e| e.code == ErrorCode::InvalidAccountName),
1230            "Should error for invalid account root: {errors:?}"
1231        );
1232    }
1233
1234    #[test]
1235    fn test_validate_account_lowercase_component() {
1236        // Test lowercase component (must start with uppercase or digit)
1237        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1238
1239        let errors = validate(&directives);
1240        assert!(
1241            errors
1242                .iter()
1243                .any(|e| e.code == ErrorCode::InvalidAccountName),
1244            "Should error for lowercase component: {errors:?}"
1245        );
1246    }
1247
1248    #[test]
1249    fn test_validate_valid_account_names() {
1250        // Valid account names should not error
1251        let valid_names = [
1252            "Assets:Bank",
1253            "Assets:Bank:Checking",
1254            "Liabilities:CreditCard",
1255            "Equity:Opening-Balances",
1256            "Income:Salary2024",
1257            "Expenses:Food:Restaurant",
1258            "Assets:401k",          // Component starting with digit
1259            "Assets:CORP✨",        // Emoji in component (beancount UTF-8-ONLY support)
1260            "Assets:沪深300",       // CJK characters
1261            "Assets:Café",          // Non-ASCII letter (é)
1262            "Assets:日本銀行",      // Full non-ASCII component
1263            "Assets:Test💰Account", // Emoji in middle
1264            "Assets:€uro",          // Currency symbol at start of component
1265        ];
1266
1267        for name in valid_names {
1268            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1269
1270            let errors = validate(&directives);
1271            let name_errors: Vec<_> = errors
1272                .iter()
1273                .filter(|e| e.code == ErrorCode::InvalidAccountName)
1274                .collect();
1275            assert!(
1276                name_errors.is_empty(),
1277                "Should accept valid account name '{name}': {name_errors:?}"
1278            );
1279        }
1280    }
1281}