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