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