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//! | E4005 | Negative cost amount |
34//! | E5001 | Currency not declared |
35//! | E5002 | Currency not allowed in account |
36//! | E7001 | Unknown option |
37//! | E7002 | Invalid option value |
38//! | E7003 | Duplicate option |
39//! | E8001 | Document file not found |
40//! | E10001 | Date out of order (info) |
41//! | E10002 | Entry dated in the future (warning) |
42
43#![forbid(unsafe_code)]
44#![warn(missing_docs)]
45
46mod error;
47mod validators;
48
49pub use error::{ErrorCode, Severity, ValidationError};
50
51use validators::{
52    validate_balance, validate_close, validate_document, validate_note, validate_open,
53    validate_pad, validate_transaction,
54};
55
56use rayon::prelude::*;
57use rustledger_core::NaiveDate;
58
59/// Threshold for using parallel sort. For small collections, sequential sort
60/// is faster due to reduced threading overhead.
61const PARALLEL_SORT_THRESHOLD: usize = 5000;
62use rust_decimal::Decimal;
63use rustc_hash::{FxHashMap, FxHashSet};
64use rustledger_core::{BookingMethod, Directive, InternedStr, Inventory};
65use rustledger_parser::Spanned;
66
67/// Account state for tracking lifecycle.
68#[derive(Debug, Clone)]
69struct AccountState {
70    /// Date opened.
71    opened: NaiveDate,
72    /// Date closed (if closed).
73    closed: Option<NaiveDate>,
74    /// Allowed currencies (empty = any).
75    currencies: FxHashSet<InternedStr>,
76    /// Booking method for this account (from `open` directive).
77    /// Used by `update_inventories()` for lot matching during validation.
78    booking: BookingMethod,
79}
80
81/// Validation options.
82#[derive(Debug, Clone)]
83pub struct ValidationOptions {
84    /// Whether to require commodity declarations.
85    pub require_commodities: bool,
86    /// Whether to check if document files exist.
87    pub check_documents: bool,
88    /// Whether to warn about future-dated entries.
89    pub warn_future_dates: bool,
90    /// Base directory for resolving relative document paths.
91    pub document_base: Option<std::path::PathBuf>,
92    /// Valid account type prefixes (from options like `name_assets`, `name_liabilities`, etc.).
93    /// Defaults to `["Assets", "Liabilities", "Equity", "Income", "Expenses"]`.
94    pub account_types: Vec<String>,
95    /// Whether to infer tolerance from cost (matches Python beancount's `infer_tolerance_from_cost`).
96    /// When true, tolerance for cost-based postings is calculated as: `units_quantum * cost_per_unit`.
97    pub infer_tolerance_from_cost: bool,
98    /// Tolerance multiplier (matches Python beancount's `inferred_tolerance_multiplier`).
99    /// Default is 0.5.
100    pub tolerance_multiplier: Decimal,
101    /// Per-currency default tolerances (matches Python beancount's `inferred_tolerance_default`).
102    /// e.g., `{"GBP": 0.004}` means GBP transactions tolerate up to 0.004 residual.
103    pub inferred_tolerance_default: FxHashMap<String, Decimal>,
104}
105
106impl Default for ValidationOptions {
107    fn default() -> Self {
108        Self {
109            require_commodities: false,
110            check_documents: true, // Python beancount validates document files by default
111            warn_future_dates: false,
112            document_base: None,
113            account_types: vec![
114                "Assets".to_string(),
115                "Liabilities".to_string(),
116                "Equity".to_string(),
117                "Income".to_string(),
118                "Expenses".to_string(),
119            ],
120            // Match Python beancount defaults
121            infer_tolerance_from_cost: false,
122            tolerance_multiplier: Decimal::new(5, 1), // 0.5
123            inferred_tolerance_default: FxHashMap::default(),
124        }
125    }
126}
127
128/// Pending pad directive info.
129#[derive(Debug, Clone)]
130struct PendingPad {
131    /// Source account for padding.
132    source_account: InternedStr,
133    /// Date of the pad directive.
134    date: NaiveDate,
135    /// Whether this pad has been used (has at least one balance assertion).
136    used: bool,
137}
138
139/// Ledger state for validation.
140#[derive(Debug, Default)]
141pub struct LedgerState {
142    /// Account states.
143    accounts: FxHashMap<InternedStr, AccountState>,
144    /// Account inventories.
145    inventories: FxHashMap<InternedStr, Inventory>,
146    /// Declared commodities.
147    commodities: FxHashSet<InternedStr>,
148    /// Pending pad directives (account -> list of pads).
149    pending_pads: FxHashMap<InternedStr, Vec<PendingPad>>,
150    /// Validation options.
151    options: ValidationOptions,
152    /// Track previous directive date for out-of-order detection.
153    last_date: Option<NaiveDate>,
154    /// Accumulated tolerances per currency from transaction amounts.
155    /// Balance assertions use these with 2x multiplier (Python beancount behavior).
156    tolerances: FxHashMap<InternedStr, Decimal>,
157}
158
159impl LedgerState {
160    /// Create a new ledger state.
161    #[must_use]
162    pub fn new() -> Self {
163        Self::default()
164    }
165
166    /// Create a new ledger state with options.
167    #[must_use]
168    pub fn with_options(options: ValidationOptions) -> Self {
169        Self {
170            options,
171            ..Default::default()
172        }
173    }
174
175    /// Set whether to require commodity declarations.
176    pub const fn set_require_commodities(&mut self, require: bool) {
177        self.options.require_commodities = require;
178    }
179
180    /// Set whether to check document files.
181    pub const fn set_check_documents(&mut self, check: bool) {
182        self.options.check_documents = check;
183    }
184
185    /// Set whether to warn about future dates.
186    pub const fn set_warn_future_dates(&mut self, warn: bool) {
187        self.options.warn_future_dates = warn;
188    }
189
190    /// Set the document base directory.
191    pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
192        self.options.document_base = Some(base.into());
193    }
194
195    /// Get the inventory for an account.
196    #[must_use]
197    pub fn inventory(&self, account: &str) -> Option<&Inventory> {
198        self.inventories.get(account)
199    }
200
201    /// Get all account names.
202    pub fn accounts(&self) -> impl Iterator<Item = &str> {
203        self.accounts.keys().map(InternedStr::as_str)
204    }
205
206    /// Import option warnings from the loader and convert them to validation errors.
207    ///
208    /// The loader collects option warnings (E7001 unknown option, E7002 invalid value,
209    /// E7003 duplicate option) during option processing. Call this method to include
210    /// those warnings as validation errors.
211    ///
212    /// Each tuple is `(code, message)` where code is "E7001", "E7002", or "E7003".
213    pub fn import_option_warnings(
214        &self,
215        warnings: &[(&str, &str)],
216        errors: &mut Vec<ValidationError>,
217    ) {
218        for &(code, message) in warnings {
219            let error_code = match code {
220                "E7001" => ErrorCode::UnknownOption,
221                "E7002" => ErrorCode::InvalidOptionValue,
222                "E7003" => ErrorCode::DuplicateOption,
223                _ => continue,
224            };
225            errors.push(ValidationError::new(
226                error_code,
227                message.to_string(),
228                // Options don't have dates — use epoch as sentinel
229                NaiveDate::default(),
230            ));
231        }
232    }
233}
234
235/// Validate a stream of directives.
236///
237/// Returns a list of validation errors found.
238pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
239    validate_with_options(directives, ValidationOptions::default())
240}
241
242/// Validate a stream of directives with custom options.
243///
244/// Returns a list of validation errors and warnings found.
245pub fn validate_with_options(
246    directives: &[Directive],
247    options: ValidationOptions,
248) -> Vec<ValidationError> {
249    let mut state = LedgerState::with_options(options);
250    let mut errors = Vec::new();
251
252    let today = jiff::Zoned::now().date();
253
254    // Sort directives by date, then by type priority
255    // (e.g., balance assertions before transactions on the same day)
256    // Use parallel sort only for large collections (threading overhead otherwise)
257    let mut sorted: Vec<&Directive> = Vec::with_capacity(directives.len());
258    sorted.extend(directives.iter());
259    let sort_fn = |a: &&Directive, b: &&Directive| {
260        a.date()
261            .cmp(&b.date())
262            .then_with(|| a.priority().cmp(&b.priority()))
263            .then_with(|| a.has_cost_reduction().cmp(&b.has_cost_reduction()))
264    };
265    if sorted.len() >= PARALLEL_SORT_THRESHOLD {
266        sorted.par_sort_by(sort_fn);
267    } else {
268        sorted.sort_by(sort_fn);
269    }
270
271    for directive in sorted {
272        let date = directive.date();
273
274        // Check for date ordering (info only - we sort anyway)
275        if let Some(last) = state.last_date
276            && date < last
277        {
278            errors.push(ValidationError::new(
279                ErrorCode::DateOutOfOrder,
280                format!("Directive date {date} is before previous directive {last}"),
281                date,
282            ));
283        }
284        state.last_date = Some(date);
285
286        // Check for future dates if enabled
287        if state.options.warn_future_dates && date > today {
288            errors.push(ValidationError::new(
289                ErrorCode::FutureDate,
290                format!("Entry dated in the future: {date}"),
291                date,
292            ));
293        }
294
295        match directive {
296            Directive::Open(open) => {
297                validate_open(&mut state, open, &mut errors);
298            }
299            Directive::Close(close) => {
300                validate_close(&mut state, close, &mut errors);
301            }
302            Directive::Transaction(txn) => {
303                validate_transaction(&mut state, txn, &mut errors);
304            }
305            Directive::Balance(bal) => {
306                validate_balance(&mut state, bal, &mut errors);
307            }
308            Directive::Commodity(comm) => {
309                state.commodities.insert(comm.currency.clone());
310            }
311            Directive::Pad(pad) => {
312                validate_pad(&mut state, pad, &mut errors);
313            }
314            Directive::Document(doc) => {
315                validate_document(&state, doc, &mut errors);
316            }
317            Directive::Note(note) => {
318                validate_note(&state, note, &mut errors);
319            }
320            _ => {}
321        }
322    }
323
324    // Check for unused pads (E2003)
325    for (target_account, pads) in &state.pending_pads {
326        for pad in pads {
327            if !pad.used {
328                errors.push(
329                    ValidationError::new(
330                        ErrorCode::PadWithoutBalance,
331                        "Unused Pad entry".to_string(),
332                        pad.date,
333                    )
334                    .with_context(format!(
335                        "   {} pad {} {}",
336                        pad.date, target_account, pad.source_account
337                    )),
338                );
339            }
340        }
341    }
342
343    errors
344}
345
346/// Validate a stream of spanned directives with custom options.
347///
348/// This variant accepts `Spanned<Directive>` to preserve source location information,
349/// which is propagated to any validation errors. This enables IDE-friendly error
350/// messages with `file:line` information.
351///
352/// Returns a list of validation errors and warnings found, each with source location
353/// when available.
354pub fn validate_spanned_with_options(
355    directives: &[Spanned<Directive>],
356    options: ValidationOptions,
357) -> Vec<ValidationError> {
358    let mut state = LedgerState::with_options(options);
359    let mut errors = Vec::new();
360
361    let today = jiff::Zoned::now().date();
362
363    // Sort directives by date, then by type priority
364    // Use parallel sort only for large collections (threading overhead otherwise)
365    let mut sorted: Vec<&Spanned<Directive>> = Vec::with_capacity(directives.len());
366    sorted.extend(directives.iter());
367    let sort_fn = |a: &&Spanned<Directive>, b: &&Spanned<Directive>| {
368        a.value
369            .date()
370            .cmp(&b.value.date())
371            .then_with(|| a.value.priority().cmp(&b.value.priority()))
372            .then_with(|| {
373                a.value
374                    .has_cost_reduction()
375                    .cmp(&b.value.has_cost_reduction())
376            })
377    };
378    if sorted.len() >= PARALLEL_SORT_THRESHOLD {
379        sorted.par_sort_by(sort_fn);
380    } else {
381        sorted.sort_by(sort_fn);
382    }
383
384    for spanned in sorted {
385        let directive = &spanned.value;
386        let date = directive.date();
387
388        // Check for date ordering (info only - we sort anyway)
389        if let Some(last) = state.last_date
390            && date < last
391        {
392            errors.push(ValidationError::with_location(
393                ErrorCode::DateOutOfOrder,
394                format!("Directive date {date} is before previous directive {last}"),
395                date,
396                spanned,
397            ));
398        }
399        state.last_date = Some(date);
400
401        // Check for future dates if enabled
402        if state.options.warn_future_dates && date > today {
403            errors.push(ValidationError::with_location(
404                ErrorCode::FutureDate,
405                format!("Entry dated in the future: {date}"),
406                date,
407                spanned,
408            ));
409        }
410
411        // Track error count before helper function so we can patch new errors with location
412        let error_count_before = errors.len();
413
414        match directive {
415            Directive::Open(open) => {
416                validate_open(&mut state, open, &mut errors);
417            }
418            Directive::Close(close) => {
419                validate_close(&mut state, close, &mut errors);
420            }
421            Directive::Transaction(txn) => {
422                validate_transaction(&mut state, txn, &mut errors);
423            }
424            Directive::Balance(bal) => {
425                validate_balance(&mut state, bal, &mut errors);
426            }
427            Directive::Commodity(comm) => {
428                state.commodities.insert(comm.currency.clone());
429            }
430            Directive::Pad(pad) => {
431                validate_pad(&mut state, pad, &mut errors);
432            }
433            Directive::Document(doc) => {
434                validate_document(&state, doc, &mut errors);
435            }
436            Directive::Note(note) => {
437                validate_note(&state, note, &mut errors);
438            }
439            _ => {}
440        }
441
442        // Patch any new errors with location info from the current directive
443        for error in errors.iter_mut().skip(error_count_before) {
444            if error.span.is_none() {
445                error.span = Some(spanned.span);
446                error.file_id = Some(spanned.file_id);
447            }
448        }
449    }
450
451    // Check for unused pads (E2003)
452    // Note: These errors won't have location info since we don't store spans in PendingPad
453    for (target_account, pads) in &state.pending_pads {
454        for pad in pads {
455            if !pad.used {
456                errors.push(
457                    ValidationError::new(
458                        ErrorCode::PadWithoutBalance,
459                        "Unused Pad entry".to_string(),
460                        pad.date,
461                    )
462                    .with_context(format!(
463                        "   {} pad {} {}",
464                        pad.date, target_account, pad.source_account
465                    )),
466                );
467            }
468        }
469    }
470
471    errors
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477    use rust_decimal_macros::dec;
478    use rustledger_core::{
479        Amount, Balance, Close, Document, NaiveDate, Open, Pad, Posting, Transaction,
480    };
481
482    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
483        rustledger_core::naive_date(year, month, day).unwrap()
484    }
485
486    #[test]
487    fn test_validate_account_lifecycle() {
488        let directives = vec![
489            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
490            Directive::Transaction(
491                Transaction::new(date(2024, 1, 15), "Test")
492                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
493                    .with_posting(Posting::new(
494                        "Income:Salary",
495                        Amount::new(dec!(-100), "USD"),
496                    )),
497            ),
498        ];
499
500        let errors = validate(&directives);
501
502        // Should have error: Income:Salary not opened
503        assert!(errors
504            .iter()
505            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
506    }
507
508    #[test]
509    fn test_validate_account_used_before_open() {
510        let directives = vec![
511            Directive::Transaction(
512                Transaction::new(date(2024, 1, 1), "Test")
513                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
514                    .with_posting(Posting::new(
515                        "Income:Salary",
516                        Amount::new(dec!(-100), "USD"),
517                    )),
518            ),
519            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
520        ];
521
522        let errors = validate(&directives);
523
524        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
525    }
526
527    #[test]
528    fn test_validate_account_used_after_close() {
529        let directives = vec![
530            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
531            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
532            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
533            Directive::Transaction(
534                Transaction::new(date(2024, 7, 1), "Test")
535                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
536                    .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
537            ),
538        ];
539
540        let errors = validate(&directives);
541
542        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
543    }
544
545    #[test]
546    fn test_validate_balance_assertion() {
547        let directives = vec![
548            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
549            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
550            Directive::Transaction(
551                Transaction::new(date(2024, 1, 15), "Deposit")
552                    .with_posting(Posting::new(
553                        "Assets:Bank",
554                        Amount::new(dec!(1000.00), "USD"),
555                    ))
556                    .with_posting(Posting::new(
557                        "Income:Salary",
558                        Amount::new(dec!(-1000.00), "USD"),
559                    )),
560            ),
561            Directive::Balance(Balance::new(
562                date(2024, 1, 16),
563                "Assets:Bank",
564                Amount::new(dec!(1000.00), "USD"),
565            )),
566        ];
567
568        let errors = validate(&directives);
569        assert!(errors.is_empty(), "{errors:?}");
570    }
571
572    #[test]
573    fn test_validate_balance_assertion_failed() {
574        let directives = vec![
575            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
576            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
577            Directive::Transaction(
578                Transaction::new(date(2024, 1, 15), "Deposit")
579                    .with_posting(Posting::new(
580                        "Assets:Bank",
581                        Amount::new(dec!(1000.00), "USD"),
582                    ))
583                    .with_posting(Posting::new(
584                        "Income:Salary",
585                        Amount::new(dec!(-1000.00), "USD"),
586                    )),
587            ),
588            Directive::Balance(Balance::new(
589                date(2024, 1, 16),
590                "Assets:Bank",
591                Amount::new(dec!(500.00), "USD"), // Wrong!
592            )),
593        ];
594
595        let errors = validate(&directives);
596        assert!(
597            errors
598                .iter()
599                .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
600        );
601    }
602
603    /// Test that balance assertions use inferred tolerance (matching Python beancount).
604    ///
605    /// Tolerance is derived from the balance assertion amount's precision, then multiplied by 2.
606    /// See: <https://github.com/beancount/beancount/blob/master/beancount/ops/balance.py>
607    /// Balance assertion with 2 decimal places: tolerance = 0.5 * 2 * 10^(-2) = 0.01.
608    #[test]
609    fn test_validate_balance_assertion_within_tolerance() {
610        // Actual balance is 70.538, assertion is 70.53 (2 decimal places)
611        // Tolerance is derived from balance assertion: 0.5 * 2 * 10^(-2) = 0.01
612        // Difference is 0.008, which is less than tolerance (0.01)
613        // This should PASS (matching Python beancount behavior from issue #251)
614        let directives = vec![
615            Directive::Open(
616                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
617            ),
618            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
619            Directive::Transaction(
620                Transaction::new(date(2024, 1, 15), "Deposit")
621                    .with_posting(Posting::new(
622                        "Assets:Bank",
623                        Amount::new(dec!(70.538), "ABC"), // 3 decimal places in transaction
624                    ))
625                    .with_posting(Posting::new(
626                        "Expenses:Misc",
627                        Amount::new(dec!(-70.538), "ABC"),
628                    )),
629            ),
630            Directive::Balance(Balance::new(
631                date(2024, 1, 16),
632                "Assets:Bank",
633                Amount::new(dec!(70.53), "ABC"), // 2 decimal places → tolerance = 0.01, diff = 0.008 < 0.01
634            )),
635        ];
636
637        let errors = validate(&directives);
638        assert!(
639            errors.is_empty(),
640            "Balance within tolerance should pass: {errors:?}"
641        );
642    }
643
644    /// Test that balance assertions fail when exceeding tolerance.
645    #[test]
646    fn test_validate_balance_assertion_exceeds_tolerance() {
647        // Actual balance is 70.538, assertion is 70.53 with explicit precision
648        // Balance assertion has 2 decimal places: tolerance = 0.5 * 2 * 10^(-2) = 0.01
649        // Difference is 0.012, which exceeds tolerance
650        // This should FAIL
651        let directives = vec![
652            Directive::Open(
653                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
654            ),
655            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
656            Directive::Transaction(
657                Transaction::new(date(2024, 1, 15), "Deposit")
658                    .with_posting(Posting::new(
659                        "Assets:Bank",
660                        Amount::new(dec!(70.542), "ABC"),
661                    ))
662                    .with_posting(Posting::new(
663                        "Expenses:Misc",
664                        Amount::new(dec!(-70.542), "ABC"),
665                    )),
666            ),
667            Directive::Balance(Balance::new(
668                date(2024, 1, 16),
669                "Assets:Bank",
670                Amount::new(dec!(70.53), "ABC"), // 2 decimal places → tolerance = 0.01, diff = 0.012 > 0.01
671            )),
672        ];
673
674        let errors = validate(&directives);
675        assert!(
676            errors
677                .iter()
678                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
679            "Balance exceeding tolerance should fail"
680        );
681    }
682
683    #[test]
684    fn test_validate_unbalanced_transaction() {
685        let directives = vec![
686            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
687            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
688            Directive::Transaction(
689                Transaction::new(date(2024, 1, 15), "Unbalanced")
690                    .with_posting(Posting::new(
691                        "Assets:Bank",
692                        Amount::new(dec!(-50.00), "USD"),
693                    ))
694                    .with_posting(Posting::new(
695                        "Expenses:Food",
696                        Amount::new(dec!(40.00), "USD"),
697                    )), // Missing $10
698            ),
699        ];
700
701        let errors = validate(&directives);
702        assert!(
703            errors
704                .iter()
705                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
706        );
707    }
708
709    #[test]
710    fn test_validate_currency_not_allowed() {
711        let directives = vec![
712            Directive::Open(
713                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
714            ),
715            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
716            Directive::Transaction(
717                Transaction::new(date(2024, 1, 15), "Test")
718                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
719                    .with_posting(Posting::new(
720                        "Income:Salary",
721                        Amount::new(dec!(-100.00), "EUR"),
722                    )),
723            ),
724        ];
725
726        let errors = validate(&directives);
727        assert!(
728            errors
729                .iter()
730                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
731        );
732    }
733
734    #[test]
735    fn test_validate_future_date_warning() {
736        // Create a date in the future
737        let future_date = jiff::Zoned::now()
738            .date()
739            .checked_add(jiff::ToSpan::days(30))
740            .unwrap();
741
742        let directives = vec![Directive::Open(Open {
743            date: future_date,
744            account: "Assets:Bank".into(),
745            currencies: vec![],
746            booking: None,
747            meta: Default::default(),
748        })];
749
750        // Without warn_future_dates option, no warnings
751        let errors = validate(&directives);
752        assert!(
753            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
754            "Should not warn about future dates by default"
755        );
756
757        // With warn_future_dates option, should warn
758        let options = ValidationOptions {
759            warn_future_dates: true,
760            ..Default::default()
761        };
762        let errors = validate_with_options(&directives, options);
763        assert!(
764            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
765            "Should warn about future dates when enabled"
766        );
767    }
768
769    #[test]
770    fn test_validate_document_not_found() {
771        let directives = vec![
772            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
773            Directive::Document(Document {
774                date: date(2024, 1, 15),
775                account: "Assets:Bank".into(),
776                path: "/nonexistent/path/to/document.pdf".to_string(),
777                tags: vec![],
778                links: vec![],
779                meta: Default::default(),
780            }),
781        ];
782
783        // With default options (check_documents: true), should error
784        let errors = validate(&directives);
785        assert!(
786            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
787            "Should check documents by default"
788        );
789
790        // With check_documents disabled, should not error
791        let options = ValidationOptions {
792            check_documents: false,
793            ..Default::default()
794        };
795        let errors = validate_with_options(&directives, options);
796        assert!(
797            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
798            "Should not report missing document when disabled"
799        );
800    }
801
802    #[test]
803    fn test_validate_document_account_not_open() {
804        let directives = vec![Directive::Document(Document {
805            date: date(2024, 1, 15),
806            account: "Assets:Unknown".into(),
807            path: "receipt.pdf".to_string(),
808            tags: vec![],
809            links: vec![],
810            meta: Default::default(),
811        })];
812
813        let errors = validate(&directives);
814        assert!(
815            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
816            "Should error for document on unopened account"
817        );
818    }
819
820    #[test]
821    fn test_error_code_is_warning() {
822        assert!(!ErrorCode::AccountNotOpen.is_warning());
823        assert!(!ErrorCode::DocumentNotFound.is_warning());
824        assert!(ErrorCode::FutureDate.is_warning());
825    }
826
827    #[test]
828    fn test_validate_pad_basic() {
829        let directives = vec![
830            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
831            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
832            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
833            Directive::Balance(Balance::new(
834                date(2024, 1, 2),
835                "Assets:Bank",
836                Amount::new(dec!(1000.00), "USD"),
837            )),
838        ];
839
840        let errors = validate(&directives);
841        // Should have no errors - pad should satisfy the balance
842        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
843    }
844
845    #[test]
846    fn test_validate_pad_with_existing_balance() {
847        let directives = vec![
848            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
849            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
850            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
851            // Add some initial transactions
852            Directive::Transaction(
853                Transaction::new(date(2024, 1, 5), "Initial deposit")
854                    .with_posting(Posting::new(
855                        "Assets:Bank",
856                        Amount::new(dec!(500.00), "USD"),
857                    ))
858                    .with_posting(Posting::new(
859                        "Income:Salary",
860                        Amount::new(dec!(-500.00), "USD"),
861                    )),
862            ),
863            // Pad to reach the target balance
864            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
865            Directive::Balance(Balance::new(
866                date(2024, 1, 15),
867                "Assets:Bank",
868                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
869            )),
870        ];
871
872        let errors = validate(&directives);
873        // Should have no errors - pad should add the missing 500
874        assert!(
875            errors.is_empty(),
876            "Pad should add missing amount: {errors:?}"
877        );
878    }
879
880    #[test]
881    fn test_validate_pad_account_not_open() {
882        let directives = vec![
883            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
884            // Assets:Bank not opened
885            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
886        ];
887
888        let errors = validate(&directives);
889        assert!(
890            errors
891                .iter()
892                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
893            "Should error for pad on unopened account"
894        );
895    }
896
897    #[test]
898    fn test_validate_pad_source_not_open() {
899        let directives = vec![
900            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
901            // Equity:Opening not opened
902            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
903        ];
904
905        let errors = validate(&directives);
906        assert!(
907            errors.iter().any(
908                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
909            ),
910            "Should error for pad with unopened source account"
911        );
912    }
913
914    #[test]
915    fn test_validate_pad_negative_adjustment() {
916        // Test that pad can reduce a balance too
917        let directives = vec![
918            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
919            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
920            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
921            // Add more than needed
922            Directive::Transaction(
923                Transaction::new(date(2024, 1, 5), "Big deposit")
924                    .with_posting(Posting::new(
925                        "Assets:Bank",
926                        Amount::new(dec!(2000.00), "USD"),
927                    ))
928                    .with_posting(Posting::new(
929                        "Income:Salary",
930                        Amount::new(dec!(-2000.00), "USD"),
931                    )),
932            ),
933            // Pad to reach a lower target
934            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
935            Directive::Balance(Balance::new(
936                date(2024, 1, 15),
937                "Assets:Bank",
938                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
939            )),
940        ];
941
942        let errors = validate(&directives);
943        assert!(
944            errors.is_empty(),
945            "Pad should handle negative adjustment: {errors:?}"
946        );
947    }
948
949    #[test]
950    fn test_validate_insufficient_units() {
951        use rustledger_core::CostSpec;
952
953        let cost_spec = CostSpec::empty()
954            .with_number_per(dec!(150))
955            .with_currency("USD");
956
957        let directives = vec![
958            Directive::Open(
959                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
960            ),
961            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
962            // Buy 10 shares
963            Directive::Transaction(
964                Transaction::new(date(2024, 1, 15), "Buy")
965                    .with_posting(
966                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
967                            .with_cost(cost_spec.clone()),
968                    )
969                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
970            ),
971            // Try to sell 15 shares (more than we have)
972            Directive::Transaction(
973                Transaction::new(date(2024, 6, 1), "Sell too many")
974                    .with_posting(
975                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
976                            .with_cost(cost_spec),
977                    )
978                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
979            ),
980        ];
981
982        let errors = validate(&directives);
983        assert!(
984            errors
985                .iter()
986                .any(|e| e.code == ErrorCode::InsufficientUnits),
987            "Should error for insufficient units: {errors:?}"
988        );
989    }
990
991    #[test]
992    fn test_validate_no_matching_lot() {
993        use rustledger_core::CostSpec;
994
995        let directives = vec![
996            Directive::Open(
997                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
998            ),
999            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1000            // Buy at $150
1001            Directive::Transaction(
1002                Transaction::new(date(2024, 1, 15), "Buy")
1003                    .with_posting(
1004                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1005                            CostSpec::empty()
1006                                .with_number_per(dec!(150))
1007                                .with_currency("USD"),
1008                        ),
1009                    )
1010                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1011            ),
1012            // Try to sell at $160 (no lot at this price)
1013            Directive::Transaction(
1014                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1015                    .with_posting(
1016                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1017                            CostSpec::empty()
1018                                .with_number_per(dec!(160))
1019                                .with_currency("USD"),
1020                        ),
1021                    )
1022                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1023            ),
1024        ];
1025
1026        let errors = validate(&directives);
1027        assert!(
1028            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1029            "Should error for no matching lot: {errors:?}"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_validate_multiple_lot_match_uses_fifo() {
1035        // In Python beancount, when multiple lots match the same cost spec,
1036        // STRICT mode falls back to FIFO order rather than erroring.
1037        use rustledger_core::CostSpec;
1038
1039        let cost_spec = CostSpec::empty()
1040            .with_number_per(dec!(150))
1041            .with_currency("USD");
1042
1043        let directives = vec![
1044            Directive::Open(
1045                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1046            ),
1047            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1048            // Buy at $150 on Jan 15
1049            Directive::Transaction(
1050                Transaction::new(date(2024, 1, 15), "Buy lot 1")
1051                    .with_posting(
1052                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1053                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1054                    )
1055                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1056            ),
1057            // Buy again at $150 on Feb 15 (creates second lot at same price)
1058            Directive::Transaction(
1059                Transaction::new(date(2024, 2, 15), "Buy lot 2")
1060                    .with_posting(
1061                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1062                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1063                    )
1064                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1065            ),
1066            // Sell with cost spec that matches both lots - STRICT falls back to FIFO
1067            Directive::Transaction(
1068                Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1069                    .with_posting(
1070                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1071                            .with_cost(cost_spec),
1072                    )
1073                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1074            ),
1075        ];
1076
1077        let errors = validate(&directives);
1078        // Filter out only booking errors - balance may or may not match
1079        let booking_errors: Vec<_> = errors
1080            .iter()
1081            .filter(|e| {
1082                matches!(
1083                    e.code,
1084                    ErrorCode::InsufficientUnits
1085                        | ErrorCode::NoMatchingLot
1086                        | ErrorCode::AmbiguousLotMatch
1087                )
1088            })
1089            .collect();
1090        assert!(
1091            booking_errors.is_empty(),
1092            "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1093        );
1094    }
1095
1096    #[test]
1097    fn test_validate_successful_booking() {
1098        use rustledger_core::CostSpec;
1099
1100        let cost_spec = CostSpec::empty()
1101            .with_number_per(dec!(150))
1102            .with_currency("USD");
1103
1104        let directives = vec![
1105            Directive::Open(
1106                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1107            ),
1108            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1109            // Buy 10 shares
1110            Directive::Transaction(
1111                Transaction::new(date(2024, 1, 15), "Buy")
1112                    .with_posting(
1113                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1114                            .with_cost(cost_spec.clone()),
1115                    )
1116                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1117            ),
1118            // Sell 5 shares (should succeed with FIFO)
1119            Directive::Transaction(
1120                Transaction::new(date(2024, 6, 1), "Sell")
1121                    .with_posting(
1122                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1123                            .with_cost(cost_spec),
1124                    )
1125                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1126            ),
1127        ];
1128
1129        let errors = validate(&directives);
1130        // Filter out any balance errors (we're testing booking only)
1131        let booking_errors: Vec<_> = errors
1132            .iter()
1133            .filter(|e| {
1134                matches!(
1135                    e.code,
1136                    ErrorCode::InsufficientUnits
1137                        | ErrorCode::NoMatchingLot
1138                        | ErrorCode::AmbiguousLotMatch
1139                )
1140            })
1141            .collect();
1142        assert!(
1143            booking_errors.is_empty(),
1144            "Should have no booking errors: {booking_errors:?}"
1145        );
1146    }
1147
1148    #[test]
1149    fn test_validate_account_already_open() {
1150        let directives = vec![
1151            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1152            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
1153        ];
1154
1155        let errors = validate(&directives);
1156        assert!(
1157            errors
1158                .iter()
1159                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1160            "Should error for duplicate open: {errors:?}"
1161        );
1162    }
1163
1164    #[test]
1165    fn test_validate_account_close_not_empty() {
1166        let directives = vec![
1167            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1168            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1169            Directive::Transaction(
1170                Transaction::new(date(2024, 1, 15), "Deposit")
1171                    .with_posting(Posting::new(
1172                        "Assets:Bank",
1173                        Amount::new(dec!(100.00), "USD"),
1174                    ))
1175                    .with_posting(Posting::new(
1176                        "Income:Salary",
1177                        Amount::new(dec!(-100.00), "USD"),
1178                    )),
1179            ),
1180            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
1181        ];
1182
1183        let errors = validate(&directives);
1184        assert!(
1185            errors
1186                .iter()
1187                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1188            "Should warn for closing account with balance: {errors:?}"
1189        );
1190    }
1191
1192    #[test]
1193    fn test_validate_no_postings_allowed() {
1194        // Python beancount allows transactions with no postings (metadata-only).
1195        // We match this behavior.
1196        let directives = vec![
1197            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1198            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1199        ];
1200
1201        let errors = validate(&directives);
1202        assert!(
1203            !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1204            "Should NOT error for transaction with no postings: {errors:?}"
1205        );
1206    }
1207
1208    #[test]
1209    fn test_validate_single_posting() {
1210        let directives = vec![
1211            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1212            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1213                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1214            )),
1215        ];
1216
1217        let errors = validate(&directives);
1218        assert!(
1219            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1220            "Should warn for transaction with single posting: {errors:?}"
1221        );
1222        // Check it's a warning not error
1223        assert!(ErrorCode::SinglePosting.is_warning());
1224    }
1225
1226    #[test]
1227    fn test_validate_single_posting_zero_cost_no_warning() {
1228        // A transaction with a single posting that has {0 USD} cost should not
1229        // warn about single posting — the counterpart was removed during
1230        // zero-cost interpolation.
1231        let directives = vec![
1232            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1233            Directive::Transaction(
1234                Transaction::new(date(2024, 1, 15), "Grant").with_posting(
1235                    Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1236                        rustledger_core::CostSpec::empty()
1237                            .with_number_per(dec!(0))
1238                            .with_currency("USD"),
1239                    ),
1240                ),
1241            ),
1242        ];
1243
1244        let errors = validate(&directives);
1245        assert!(
1246            !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1247            "Should NOT warn for zero-cost single posting: {errors:?}"
1248        );
1249    }
1250
1251    #[test]
1252    fn test_validate_single_posting_nonzero_cost_still_warns() {
1253        // A single posting with a NON-zero cost should still warn
1254        let directives = vec![
1255            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
1256            Directive::Transaction(
1257                Transaction::new(date(2024, 1, 15), "Buy").with_posting(
1258                    Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
1259                        rustledger_core::CostSpec::empty()
1260                            .with_number_per(dec!(150))
1261                            .with_currency("USD"),
1262                    ),
1263                ),
1264            ),
1265        ];
1266
1267        let errors = validate(&directives);
1268        assert!(
1269            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1270            "Should warn for single posting with non-zero cost: {errors:?}"
1271        );
1272    }
1273
1274    #[test]
1275    fn test_validate_pad_without_balance() {
1276        let directives = vec![
1277            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1278            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1279            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1280            // No balance assertion follows!
1281        ];
1282
1283        let errors = validate(&directives);
1284        assert!(
1285            errors
1286                .iter()
1287                .any(|e| e.code == ErrorCode::PadWithoutBalance),
1288            "Should error for pad without subsequent balance: {errors:?}"
1289        );
1290    }
1291
1292    #[test]
1293    fn test_validate_multiple_pads_for_balance() {
1294        let directives = vec![
1295            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1296            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1297            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1298            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
1299            Directive::Balance(Balance::new(
1300                date(2024, 1, 3),
1301                "Assets:Bank",
1302                Amount::new(dec!(1000.00), "USD"),
1303            )),
1304        ];
1305
1306        let errors = validate(&directives);
1307        assert!(
1308            errors
1309                .iter()
1310                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1311            "Should error for multiple pads before balance: {errors:?}"
1312        );
1313    }
1314
1315    #[test]
1316    fn test_error_severity() {
1317        // Errors
1318        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1319        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1320        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1321
1322        // Warnings
1323        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1324        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1325        assert_eq!(
1326            ErrorCode::AccountCloseNotEmpty.severity(),
1327            Severity::Warning
1328        );
1329
1330        // Info
1331        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1332    }
1333
1334    #[test]
1335    fn test_validate_invalid_account_name() {
1336        // Test invalid root type
1337        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1338
1339        let errors = validate(&directives);
1340        assert!(
1341            errors
1342                .iter()
1343                .any(|e| e.code == ErrorCode::InvalidAccountName),
1344            "Should error for invalid account root: {errors:?}"
1345        );
1346    }
1347
1348    #[test]
1349    fn test_validate_account_lowercase_component() {
1350        // Test lowercase component (must start with uppercase or digit)
1351        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1352
1353        let errors = validate(&directives);
1354        assert!(
1355            errors
1356                .iter()
1357                .any(|e| e.code == ErrorCode::InvalidAccountName),
1358            "Should error for lowercase component: {errors:?}"
1359        );
1360    }
1361
1362    #[test]
1363    fn test_validate_valid_account_names() {
1364        // Valid account names should not error
1365        let valid_names = [
1366            "Assets:Bank",
1367            "Assets:Bank:Checking",
1368            "Liabilities:CreditCard",
1369            "Equity:Opening-Balances",
1370            "Income:Salary2024",
1371            "Expenses:Food:Restaurant",
1372            "Assets:401k",     // Component starting with digit
1373            "Assets:沪深300",  // CJK characters
1374            "Assets:Café",     // Non-ASCII letter (é)
1375            "Assets:日本銀行", // Full non-ASCII component
1376            "Assets:Капитал",  // Cyrillic sub-account
1377        ];
1378
1379        for name in valid_names {
1380            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1381
1382            let errors = validate(&directives);
1383            let name_errors: Vec<_> = errors
1384                .iter()
1385                .filter(|e| e.code == ErrorCode::InvalidAccountName)
1386                .collect();
1387            assert!(
1388                name_errors.is_empty(),
1389                "Should accept valid account name '{name}': {name_errors:?}"
1390            );
1391        }
1392    }
1393
1394    // =========================================================================
1395    // Error code coverage tests (spring 2026 audit)
1396    // =========================================================================
1397
1398    #[test]
1399    fn test_e2002_balance_exceeds_explicit_tolerance() {
1400        // E2002: When a balance directive specifies an explicit tolerance and the
1401        // actual balance exceeds it, we should get BalanceToleranceExceeded.
1402        let directives = vec![
1403            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1404            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1405            Directive::Transaction(
1406                Transaction::new(date(2024, 1, 15), "Deposit")
1407                    .with_posting(Posting::new(
1408                        "Assets:Bank",
1409                        Amount::new(dec!(1000.00), "USD"),
1410                    ))
1411                    .with_posting(Posting::new(
1412                        "Income:Salary",
1413                        Amount::new(dec!(-1000.00), "USD"),
1414                    )),
1415            ),
1416            // Balance assertion with explicit tolerance of 0.01,
1417            // but actual is 1000.00 vs expected 999.00 (difference = 1.00)
1418            Directive::Balance(
1419                Balance::new(
1420                    date(2024, 1, 16),
1421                    "Assets:Bank",
1422                    Amount::new(dec!(999.00), "USD"),
1423                )
1424                .with_tolerance(dec!(0.01)),
1425            ),
1426        ];
1427
1428        let errors = validate(&directives);
1429
1430        assert!(
1431            errors
1432                .iter()
1433                .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
1434            "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
1435        );
1436    }
1437
1438    #[test]
1439    fn test_e2002_balance_within_explicit_tolerance_passes() {
1440        // When within explicit tolerance, no error should be raised
1441        let directives = vec![
1442            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1443            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1444            Directive::Transaction(
1445                Transaction::new(date(2024, 1, 15), "Deposit")
1446                    .with_posting(Posting::new(
1447                        "Assets:Bank",
1448                        Amount::new(dec!(1000.00), "USD"),
1449                    ))
1450                    .with_posting(Posting::new(
1451                        "Income:Salary",
1452                        Amount::new(dec!(-1000.00), "USD"),
1453                    )),
1454            ),
1455            // Balance assertion with tolerance of 5.00, difference is only 1.00
1456            Directive::Balance(
1457                Balance::new(
1458                    date(2024, 1, 16),
1459                    "Assets:Bank",
1460                    Amount::new(dec!(999.00), "USD"),
1461                )
1462                .with_tolerance(dec!(5.00)),
1463            ),
1464        ];
1465
1466        let errors = validate(&directives);
1467
1468        assert!(
1469            !errors
1470                .iter()
1471                .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
1472                    || e.code == ErrorCode::BalanceAssertionFailed),
1473            "Expected no balance errors, got: {errors:?}"
1474        );
1475    }
1476
1477    #[test]
1478    fn test_e5001_undeclared_currency() {
1479        // E5001: When require_commodities=true, using a currency without a
1480        // commodity directive should raise UndeclaredCurrency.
1481        use rustledger_core::Commodity;
1482
1483        let directives = vec![
1484            Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1485            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1486            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1487            Directive::Transaction(
1488                Transaction::new(date(2024, 1, 15), "Lunch")
1489                    .with_posting(Posting::new(
1490                        "Expenses:Food",
1491                        Amount::new(dec!(20.00), "EUR"), // EUR not declared
1492                    ))
1493                    .with_posting(Posting::new(
1494                        "Assets:Bank",
1495                        Amount::new(dec!(-20.00), "EUR"),
1496                    )),
1497            ),
1498        ];
1499
1500        let options = ValidationOptions {
1501            require_commodities: true,
1502            ..Default::default()
1503        };
1504        let errors = validate_with_options(&directives, options);
1505
1506        assert!(
1507            errors
1508                .iter()
1509                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1510            "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
1511        );
1512    }
1513
1514    #[test]
1515    fn test_e5001_declared_currency_passes() {
1516        // When the currency is declared, no E5001 error
1517        use rustledger_core::Commodity;
1518
1519        let directives = vec![
1520            Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
1521            Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
1522            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1523            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1524            Directive::Transaction(
1525                Transaction::new(date(2024, 1, 15), "Lunch")
1526                    .with_posting(Posting::new(
1527                        "Expenses:Food",
1528                        Amount::new(dec!(20.00), "EUR"),
1529                    ))
1530                    .with_posting(Posting::new(
1531                        "Assets:Bank",
1532                        Amount::new(dec!(-20.00), "EUR"),
1533                    )),
1534            ),
1535        ];
1536
1537        let options = ValidationOptions {
1538            require_commodities: true,
1539            ..Default::default()
1540        };
1541        let errors = validate_with_options(&directives, options);
1542
1543        assert!(
1544            !errors
1545                .iter()
1546                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1547            "Expected no E5001 errors, got: {errors:?}"
1548        );
1549    }
1550
1551    #[test]
1552    fn test_e5001_not_raised_without_require_commodities() {
1553        // Without require_commodities=true, undeclared currencies are fine
1554        let directives = vec![
1555            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1556            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1557            Directive::Transaction(
1558                Transaction::new(date(2024, 1, 15), "Lunch")
1559                    .with_posting(Posting::new(
1560                        "Expenses:Food",
1561                        Amount::new(dec!(20.00), "XYZ"), // Totally made up
1562                    ))
1563                    .with_posting(Posting::new(
1564                        "Assets:Bank",
1565                        Amount::new(dec!(-20.00), "XYZ"),
1566                    )),
1567            ),
1568        ];
1569
1570        let errors = validate(&directives);
1571
1572        assert!(
1573            !errors
1574                .iter()
1575                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
1576            "Should not raise E5001 without require_commodities, got: {errors:?}"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_e3002_multiple_missing_amounts() {
1582        // E3002: Multiple postings with missing amounts is ambiguous
1583        let directives = vec![
1584            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1585            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1586            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
1587            Directive::Transaction(
1588                Transaction::new(date(2024, 1, 15), "Lunch")
1589                    .with_posting(Posting::new(
1590                        "Assets:Bank",
1591                        Amount::new(dec!(-50.00), "USD"),
1592                    ))
1593                    // Two postings with no amount — ambiguous interpolation
1594                    .with_posting(Posting {
1595                        account: "Expenses:Food".into(),
1596                        units: None,
1597                        cost: None,
1598                        price: None,
1599                        flag: None,
1600                        meta: Default::default(),
1601                        comments: vec![],
1602                        trailing_comments: vec![],
1603                    })
1604                    .with_posting(Posting {
1605                        account: "Expenses:Drinks".into(),
1606                        units: None,
1607                        cost: None,
1608                        price: None,
1609                        flag: None,
1610                        meta: Default::default(),
1611                        comments: vec![],
1612                        trailing_comments: vec![],
1613                    }),
1614            ),
1615        ];
1616
1617        let errors = validate(&directives);
1618
1619        assert!(
1620            errors
1621                .iter()
1622                .any(|e| e.code == ErrorCode::MultipleInterpolation),
1623            "Expected E3002 MultipleInterpolation, got: {errors:?}"
1624        );
1625    }
1626
1627    #[test]
1628    fn test_e3002_single_missing_amount_ok() {
1629        // A single missing amount is fine (can be interpolated)
1630        let directives = vec![
1631            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1632            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1633            Directive::Transaction(
1634                Transaction::new(date(2024, 1, 15), "Lunch")
1635                    .with_posting(Posting::new(
1636                        "Assets:Bank",
1637                        Amount::new(dec!(-50.00), "USD"),
1638                    ))
1639                    .with_posting(Posting {
1640                        account: "Expenses:Food".into(),
1641                        units: None,
1642                        cost: None,
1643                        price: None,
1644                        flag: None,
1645                        meta: Default::default(),
1646                        comments: vec![],
1647                        trailing_comments: vec![],
1648                    }),
1649            ),
1650        ];
1651
1652        let errors = validate(&directives);
1653
1654        assert!(
1655            !errors
1656                .iter()
1657                .any(|e| e.code == ErrorCode::MultipleInterpolation),
1658            "Should not raise E3002 with single missing amount, got: {errors:?}"
1659        );
1660    }
1661
1662    #[test]
1663    fn test_e7001_unknown_option() {
1664        // E7001: import_option_warnings converts loader warnings to validation errors
1665        let state = LedgerState::new();
1666        let mut errors = Vec::new();
1667
1668        state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
1669
1670        assert_eq!(errors.len(), 1);
1671        assert_eq!(errors[0].code, ErrorCode::UnknownOption);
1672        assert!(errors[0].message.contains("bogus_option"));
1673    }
1674
1675    #[test]
1676    fn test_e7002_invalid_option_value() {
1677        let state = LedgerState::new();
1678        let mut errors = Vec::new();
1679
1680        state.import_option_warnings(
1681            &[("E7002", "Invalid leaf account name: 'not-valid'")],
1682            &mut errors,
1683        );
1684
1685        assert_eq!(errors.len(), 1);
1686        assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
1687    }
1688
1689    #[test]
1690    fn test_e7003_duplicate_option() {
1691        let state = LedgerState::new();
1692        let mut errors = Vec::new();
1693
1694        state.import_option_warnings(
1695            &[("E7003", "Option \"title\" can only be specified once")],
1696            &mut errors,
1697        );
1698
1699        assert_eq!(errors.len(), 1);
1700        assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
1701    }
1702}