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]
547    fn test_validate_unbalanced_transaction() {
548        let directives = vec![
549            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
550            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
551            Directive::Transaction(
552                Transaction::new(date(2024, 1, 15), "Unbalanced")
553                    .with_posting(Posting::new(
554                        "Assets:Bank",
555                        Amount::new(dec!(-50.00), "USD"),
556                    ))
557                    .with_posting(Posting::new(
558                        "Expenses:Food",
559                        Amount::new(dec!(40.00), "USD"),
560                    )), // Missing $10
561            ),
562        ];
563
564        let errors = validate(&directives);
565        assert!(
566            errors
567                .iter()
568                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
569        );
570    }
571
572    #[test]
573    fn test_validate_currency_not_allowed() {
574        let directives = vec![
575            Directive::Open(
576                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
577            ),
578            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
579            Directive::Transaction(
580                Transaction::new(date(2024, 1, 15), "Test")
581                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
582                    .with_posting(Posting::new(
583                        "Income:Salary",
584                        Amount::new(dec!(-100.00), "EUR"),
585                    )),
586            ),
587        ];
588
589        let errors = validate(&directives);
590        assert!(
591            errors
592                .iter()
593                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
594        );
595    }
596
597    #[test]
598    fn test_validate_future_date_warning() {
599        // Create a date in the future
600        let future_date = Local::now().date_naive() + chrono::Duration::days(30);
601
602        let directives = vec![Directive::Open(Open {
603            date: future_date,
604            account: "Assets:Bank".into(),
605            currencies: vec![],
606            booking: None,
607            meta: Default::default(),
608        })];
609
610        // Without warn_future_dates option, no warnings
611        let errors = validate(&directives);
612        assert!(
613            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
614            "Should not warn about future dates by default"
615        );
616
617        // With warn_future_dates option, should warn
618        let options = ValidationOptions {
619            warn_future_dates: true,
620            ..Default::default()
621        };
622        let errors = validate_with_options(&directives, options);
623        assert!(
624            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
625            "Should warn about future dates when enabled"
626        );
627    }
628
629    #[test]
630    fn test_validate_document_not_found() {
631        let directives = vec![
632            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
633            Directive::Document(Document {
634                date: date(2024, 1, 15),
635                account: "Assets:Bank".into(),
636                path: "/nonexistent/path/to/document.pdf".to_string(),
637                tags: vec![],
638                links: vec![],
639                meta: Default::default(),
640            }),
641        ];
642
643        // With default options (check_documents: true), should error
644        let errors = validate(&directives);
645        assert!(
646            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
647            "Should check documents by default"
648        );
649
650        // With check_documents disabled, should not error
651        let options = ValidationOptions {
652            check_documents: false,
653            ..Default::default()
654        };
655        let errors = validate_with_options(&directives, options);
656        assert!(
657            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
658            "Should not report missing document when disabled"
659        );
660    }
661
662    #[test]
663    fn test_validate_document_account_not_open() {
664        let directives = vec![Directive::Document(Document {
665            date: date(2024, 1, 15),
666            account: "Assets:Unknown".into(),
667            path: "receipt.pdf".to_string(),
668            tags: vec![],
669            links: vec![],
670            meta: Default::default(),
671        })];
672
673        let errors = validate(&directives);
674        assert!(
675            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
676            "Should error for document on unopened account"
677        );
678    }
679
680    #[test]
681    fn test_error_code_is_warning() {
682        assert!(!ErrorCode::AccountNotOpen.is_warning());
683        assert!(!ErrorCode::DocumentNotFound.is_warning());
684        assert!(ErrorCode::FutureDate.is_warning());
685    }
686
687    #[test]
688    fn test_validate_pad_basic() {
689        let directives = vec![
690            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
691            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
692            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
693            Directive::Balance(Balance::new(
694                date(2024, 1, 2),
695                "Assets:Bank",
696                Amount::new(dec!(1000.00), "USD"),
697            )),
698        ];
699
700        let errors = validate(&directives);
701        // Should have no errors - pad should satisfy the balance
702        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
703    }
704
705    #[test]
706    fn test_validate_pad_with_existing_balance() {
707        let directives = vec![
708            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
709            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
710            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
711            // Add some initial transactions
712            Directive::Transaction(
713                Transaction::new(date(2024, 1, 5), "Initial deposit")
714                    .with_posting(Posting::new(
715                        "Assets:Bank",
716                        Amount::new(dec!(500.00), "USD"),
717                    ))
718                    .with_posting(Posting::new(
719                        "Income:Salary",
720                        Amount::new(dec!(-500.00), "USD"),
721                    )),
722            ),
723            // Pad to reach the target balance
724            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
725            Directive::Balance(Balance::new(
726                date(2024, 1, 15),
727                "Assets:Bank",
728                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
729            )),
730        ];
731
732        let errors = validate(&directives);
733        // Should have no errors - pad should add the missing 500
734        assert!(
735            errors.is_empty(),
736            "Pad should add missing amount: {errors:?}"
737        );
738    }
739
740    #[test]
741    fn test_validate_pad_account_not_open() {
742        let directives = vec![
743            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
744            // Assets:Bank not opened
745            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
746        ];
747
748        let errors = validate(&directives);
749        assert!(
750            errors
751                .iter()
752                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
753            "Should error for pad on unopened account"
754        );
755    }
756
757    #[test]
758    fn test_validate_pad_source_not_open() {
759        let directives = vec![
760            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
761            // Equity:Opening not opened
762            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
763        ];
764
765        let errors = validate(&directives);
766        assert!(
767            errors.iter().any(
768                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
769            ),
770            "Should error for pad with unopened source account"
771        );
772    }
773
774    #[test]
775    fn test_validate_pad_negative_adjustment() {
776        // Test that pad can reduce a balance too
777        let directives = vec![
778            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
779            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
780            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
781            // Add more than needed
782            Directive::Transaction(
783                Transaction::new(date(2024, 1, 5), "Big deposit")
784                    .with_posting(Posting::new(
785                        "Assets:Bank",
786                        Amount::new(dec!(2000.00), "USD"),
787                    ))
788                    .with_posting(Posting::new(
789                        "Income:Salary",
790                        Amount::new(dec!(-2000.00), "USD"),
791                    )),
792            ),
793            // Pad to reach a lower target
794            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
795            Directive::Balance(Balance::new(
796                date(2024, 1, 15),
797                "Assets:Bank",
798                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
799            )),
800        ];
801
802        let errors = validate(&directives);
803        assert!(
804            errors.is_empty(),
805            "Pad should handle negative adjustment: {errors:?}"
806        );
807    }
808
809    #[test]
810    fn test_validate_insufficient_units() {
811        use rustledger_core::CostSpec;
812
813        let cost_spec = CostSpec::empty()
814            .with_number_per(dec!(150))
815            .with_currency("USD");
816
817        let directives = vec![
818            Directive::Open(
819                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
820            ),
821            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
822            // Buy 10 shares
823            Directive::Transaction(
824                Transaction::new(date(2024, 1, 15), "Buy")
825                    .with_posting(
826                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
827                            .with_cost(cost_spec.clone()),
828                    )
829                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
830            ),
831            // Try to sell 15 shares (more than we have)
832            Directive::Transaction(
833                Transaction::new(date(2024, 6, 1), "Sell too many")
834                    .with_posting(
835                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
836                            .with_cost(cost_spec),
837                    )
838                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
839            ),
840        ];
841
842        let errors = validate(&directives);
843        assert!(
844            errors
845                .iter()
846                .any(|e| e.code == ErrorCode::InsufficientUnits),
847            "Should error for insufficient units: {errors:?}"
848        );
849    }
850
851    #[test]
852    fn test_validate_no_matching_lot() {
853        use rustledger_core::CostSpec;
854
855        let directives = vec![
856            Directive::Open(
857                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
858            ),
859            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
860            // Buy at $150
861            Directive::Transaction(
862                Transaction::new(date(2024, 1, 15), "Buy")
863                    .with_posting(
864                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
865                            CostSpec::empty()
866                                .with_number_per(dec!(150))
867                                .with_currency("USD"),
868                        ),
869                    )
870                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
871            ),
872            // Try to sell at $160 (no lot at this price)
873            Directive::Transaction(
874                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
875                    .with_posting(
876                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
877                            CostSpec::empty()
878                                .with_number_per(dec!(160))
879                                .with_currency("USD"),
880                        ),
881                    )
882                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
883            ),
884        ];
885
886        let errors = validate(&directives);
887        assert!(
888            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
889            "Should error for no matching lot: {errors:?}"
890        );
891    }
892
893    #[test]
894    fn test_validate_multiple_lot_match_uses_fifo() {
895        // In Python beancount, when multiple lots match the same cost spec,
896        // STRICT mode falls back to FIFO order rather than erroring.
897        use rustledger_core::CostSpec;
898
899        let cost_spec = CostSpec::empty()
900            .with_number_per(dec!(150))
901            .with_currency("USD");
902
903        let directives = vec![
904            Directive::Open(
905                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
906            ),
907            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
908            // Buy at $150 on Jan 15
909            Directive::Transaction(
910                Transaction::new(date(2024, 1, 15), "Buy lot 1")
911                    .with_posting(
912                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
913                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
914                    )
915                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
916            ),
917            // Buy again at $150 on Feb 15 (creates second lot at same price)
918            Directive::Transaction(
919                Transaction::new(date(2024, 2, 15), "Buy lot 2")
920                    .with_posting(
921                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
922                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
923                    )
924                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
925            ),
926            // Sell with cost spec that matches both lots - STRICT falls back to FIFO
927            Directive::Transaction(
928                Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
929                    .with_posting(
930                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
931                            .with_cost(cost_spec),
932                    )
933                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
934            ),
935        ];
936
937        let errors = validate(&directives);
938        // Filter out only booking errors - balance may or may not match
939        let booking_errors: Vec<_> = errors
940            .iter()
941            .filter(|e| {
942                matches!(
943                    e.code,
944                    ErrorCode::InsufficientUnits
945                        | ErrorCode::NoMatchingLot
946                        | ErrorCode::AmbiguousLotMatch
947                )
948            })
949            .collect();
950        assert!(
951            booking_errors.is_empty(),
952            "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
953        );
954    }
955
956    #[test]
957    fn test_validate_successful_booking() {
958        use rustledger_core::CostSpec;
959
960        let cost_spec = CostSpec::empty()
961            .with_number_per(dec!(150))
962            .with_currency("USD");
963
964        let directives = vec![
965            Directive::Open(
966                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
967            ),
968            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
969            // Buy 10 shares
970            Directive::Transaction(
971                Transaction::new(date(2024, 1, 15), "Buy")
972                    .with_posting(
973                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
974                            .with_cost(cost_spec.clone()),
975                    )
976                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
977            ),
978            // Sell 5 shares (should succeed with FIFO)
979            Directive::Transaction(
980                Transaction::new(date(2024, 6, 1), "Sell")
981                    .with_posting(
982                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
983                            .with_cost(cost_spec),
984                    )
985                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
986            ),
987        ];
988
989        let errors = validate(&directives);
990        // Filter out any balance errors (we're testing booking only)
991        let booking_errors: Vec<_> = errors
992            .iter()
993            .filter(|e| {
994                matches!(
995                    e.code,
996                    ErrorCode::InsufficientUnits
997                        | ErrorCode::NoMatchingLot
998                        | ErrorCode::AmbiguousLotMatch
999                )
1000            })
1001            .collect();
1002        assert!(
1003            booking_errors.is_empty(),
1004            "Should have no booking errors: {booking_errors:?}"
1005        );
1006    }
1007
1008    #[test]
1009    fn test_validate_account_already_open() {
1010        let directives = vec![
1011            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1012            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
1013        ];
1014
1015        let errors = validate(&directives);
1016        assert!(
1017            errors
1018                .iter()
1019                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1020            "Should error for duplicate open: {errors:?}"
1021        );
1022    }
1023
1024    #[test]
1025    fn test_validate_account_close_not_empty() {
1026        let directives = vec![
1027            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1028            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1029            Directive::Transaction(
1030                Transaction::new(date(2024, 1, 15), "Deposit")
1031                    .with_posting(Posting::new(
1032                        "Assets:Bank",
1033                        Amount::new(dec!(100.00), "USD"),
1034                    ))
1035                    .with_posting(Posting::new(
1036                        "Income:Salary",
1037                        Amount::new(dec!(-100.00), "USD"),
1038                    )),
1039            ),
1040            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
1041        ];
1042
1043        let errors = validate(&directives);
1044        assert!(
1045            errors
1046                .iter()
1047                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1048            "Should warn for closing account with balance: {errors:?}"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_validate_no_postings_allowed() {
1054        // Python beancount allows transactions with no postings (metadata-only).
1055        // We match this behavior.
1056        let directives = vec![
1057            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1058            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1059        ];
1060
1061        let errors = validate(&directives);
1062        assert!(
1063            !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1064            "Should NOT error for transaction with no postings: {errors:?}"
1065        );
1066    }
1067
1068    #[test]
1069    fn test_validate_single_posting() {
1070        let directives = vec![
1071            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1072            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1073                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1074            )),
1075        ];
1076
1077        let errors = validate(&directives);
1078        assert!(
1079            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1080            "Should warn for transaction with single posting: {errors:?}"
1081        );
1082        // Check it's a warning not error
1083        assert!(ErrorCode::SinglePosting.is_warning());
1084    }
1085
1086    #[test]
1087    fn test_validate_pad_without_balance() {
1088        let directives = vec![
1089            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1090            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1091            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1092            // No balance assertion follows!
1093        ];
1094
1095        let errors = validate(&directives);
1096        assert!(
1097            errors
1098                .iter()
1099                .any(|e| e.code == ErrorCode::PadWithoutBalance),
1100            "Should error for pad without subsequent balance: {errors:?}"
1101        );
1102    }
1103
1104    #[test]
1105    fn test_validate_multiple_pads_for_balance() {
1106        let directives = vec![
1107            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1108            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1109            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1110            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
1111            Directive::Balance(Balance::new(
1112                date(2024, 1, 3),
1113                "Assets:Bank",
1114                Amount::new(dec!(1000.00), "USD"),
1115            )),
1116        ];
1117
1118        let errors = validate(&directives);
1119        assert!(
1120            errors
1121                .iter()
1122                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1123            "Should error for multiple pads before balance: {errors:?}"
1124        );
1125    }
1126
1127    #[test]
1128    fn test_error_severity() {
1129        // Errors
1130        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1131        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1132        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1133
1134        // Warnings
1135        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1136        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1137        assert_eq!(
1138            ErrorCode::AccountCloseNotEmpty.severity(),
1139            Severity::Warning
1140        );
1141
1142        // Info
1143        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1144    }
1145
1146    #[test]
1147    fn test_validate_invalid_account_name() {
1148        // Test invalid root type
1149        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1150
1151        let errors = validate(&directives);
1152        assert!(
1153            errors
1154                .iter()
1155                .any(|e| e.code == ErrorCode::InvalidAccountName),
1156            "Should error for invalid account root: {errors:?}"
1157        );
1158    }
1159
1160    #[test]
1161    fn test_validate_account_lowercase_component() {
1162        // Test lowercase component (must start with uppercase or digit)
1163        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1164
1165        let errors = validate(&directives);
1166        assert!(
1167            errors
1168                .iter()
1169                .any(|e| e.code == ErrorCode::InvalidAccountName),
1170            "Should error for lowercase component: {errors:?}"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_validate_valid_account_names() {
1176        // Valid account names should not error
1177        let valid_names = [
1178            "Assets:Bank",
1179            "Assets:Bank:Checking",
1180            "Liabilities:CreditCard",
1181            "Equity:Opening-Balances",
1182            "Income:Salary2024",
1183            "Expenses:Food:Restaurant",
1184            "Assets:401k",          // Component starting with digit
1185            "Assets:CORP✨",        // Emoji in component (beancount UTF-8-ONLY support)
1186            "Assets:沪深300",       // CJK characters
1187            "Assets:Café",          // Non-ASCII letter (é)
1188            "Assets:日本銀行",      // Full non-ASCII component
1189            "Assets:Test💰Account", // Emoji in middle
1190            "Assets:€uro",          // Currency symbol at start of component
1191        ];
1192
1193        for name in valid_names {
1194            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1195
1196            let errors = validate(&directives);
1197            let name_errors: Vec<_> = errors
1198                .iter()
1199                .filter(|e| e.code == ErrorCode::InvalidAccountName)
1200                .collect();
1201            assert!(
1202                name_errors.is_empty(),
1203                "Should accept valid account name '{name}': {name_errors:?}"
1204            );
1205        }
1206    }
1207}