Skip to main content

rustledger_validate/
lib.rs

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