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