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
48use chrono::{Local, NaiveDate};
49use rayon::prelude::*;
50use rust_decimal::Decimal;
51use rustledger_core::{
52    Amount, Balance, BookingMethod, Close, Directive, Document, InternedStr, Inventory, Note, Open,
53    Pad, Position, Posting, Transaction,
54};
55use rustledger_parser::{Span, Spanned};
56use std::collections::{HashMap, HashSet};
57use std::path::Path;
58use thiserror::Error;
59
60/// Validation error codes.
61///
62/// Error codes follow the spec in `spec/validation.md`.
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum ErrorCode {
65    // === Account Errors (E1xxx) ===
66    /// E1001: Account used before it was opened.
67    AccountNotOpen,
68    /// E1002: Account already open (duplicate open directive).
69    AccountAlreadyOpen,
70    /// E1003: Account used after it was closed.
71    AccountClosed,
72    /// E1004: Account close with non-zero balance.
73    AccountCloseNotEmpty,
74    /// E1005: Invalid account name.
75    InvalidAccountName,
76
77    // === Balance Errors (E2xxx) ===
78    /// E2001: Balance assertion failed.
79    BalanceAssertionFailed,
80    /// E2002: Balance exceeds explicit tolerance.
81    BalanceToleranceExceeded,
82    /// E2003: Pad without subsequent balance assertion.
83    PadWithoutBalance,
84    /// E2004: Multiple pads for same balance assertion.
85    MultiplePadForBalance,
86
87    // === Transaction Errors (E3xxx) ===
88    /// E3001: Transaction does not balance.
89    TransactionUnbalanced,
90    /// E3002: Multiple postings missing amounts for same currency.
91    MultipleInterpolation,
92    /// E3003: Transaction has no postings.
93    NoPostings,
94    /// E3004: Transaction has single posting (warning).
95    SinglePosting,
96
97    // === Booking Errors (E4xxx) ===
98    /// E4001: No matching lot for reduction.
99    NoMatchingLot,
100    /// E4002: Insufficient units in lot for reduction.
101    InsufficientUnits,
102    /// E4003: Ambiguous lot match in STRICT mode.
103    AmbiguousLotMatch,
104    /// E4004: Reduction would create negative inventory.
105    NegativeInventory,
106
107    // === Currency Errors (E5xxx) ===
108    /// E5001: Currency not declared (when strict mode enabled).
109    UndeclaredCurrency,
110    /// E5002: Currency not allowed in account.
111    CurrencyNotAllowed,
112
113    // === Metadata Errors (E6xxx) ===
114    /// E6001: Duplicate metadata key.
115    DuplicateMetadataKey,
116    /// E6002: Invalid metadata value type.
117    InvalidMetadataValue,
118
119    // === Option Errors (E7xxx) ===
120    /// E7001: Unknown option name.
121    UnknownOption,
122    /// E7002: Invalid option value.
123    InvalidOptionValue,
124    /// E7003: Duplicate non-repeatable option.
125    DuplicateOption,
126
127    // === Document Errors (E8xxx) ===
128    /// E8001: Document file not found.
129    DocumentNotFound,
130
131    // === Date Errors (E10xxx) ===
132    /// E10001: Date out of order (info only).
133    DateOutOfOrder,
134    /// E10002: Entry dated in the future (warning).
135    FutureDate,
136}
137
138impl ErrorCode {
139    /// Get the error code string (e.g., "E1001").
140    #[must_use]
141    pub const fn code(&self) -> &'static str {
142        match self {
143            // Account errors
144            Self::AccountNotOpen => "E1001",
145            Self::AccountAlreadyOpen => "E1002",
146            Self::AccountClosed => "E1003",
147            Self::AccountCloseNotEmpty => "E1004",
148            Self::InvalidAccountName => "E1005",
149            // Balance errors
150            Self::BalanceAssertionFailed => "E2001",
151            Self::BalanceToleranceExceeded => "E2002",
152            Self::PadWithoutBalance => "E2003",
153            Self::MultiplePadForBalance => "E2004",
154            // Transaction errors
155            Self::TransactionUnbalanced => "E3001",
156            Self::MultipleInterpolation => "E3002",
157            Self::NoPostings => "E3003",
158            Self::SinglePosting => "E3004",
159            // Booking errors
160            Self::NoMatchingLot => "E4001",
161            Self::InsufficientUnits => "E4002",
162            Self::AmbiguousLotMatch => "E4003",
163            Self::NegativeInventory => "E4004",
164            // Currency errors
165            Self::UndeclaredCurrency => "E5001",
166            Self::CurrencyNotAllowed => "E5002",
167            // Metadata errors
168            Self::DuplicateMetadataKey => "E6001",
169            Self::InvalidMetadataValue => "E6002",
170            // Option errors
171            Self::UnknownOption => "E7001",
172            Self::InvalidOptionValue => "E7002",
173            Self::DuplicateOption => "E7003",
174            // Document errors
175            Self::DocumentNotFound => "E8001",
176            // Date errors
177            Self::DateOutOfOrder => "E10001",
178            Self::FutureDate => "E10002",
179        }
180    }
181
182    /// Check if this is a warning (not an error).
183    #[must_use]
184    pub const fn is_warning(&self) -> bool {
185        matches!(
186            self,
187            Self::FutureDate
188                | Self::SinglePosting
189                | Self::AccountCloseNotEmpty
190                | Self::DateOutOfOrder
191        )
192    }
193
194    /// Check if this is just informational.
195    #[must_use]
196    pub const fn is_info(&self) -> bool {
197        matches!(self, Self::DateOutOfOrder)
198    }
199
200    /// Get the severity level.
201    #[must_use]
202    pub const fn severity(&self) -> Severity {
203        if self.is_info() {
204            Severity::Info
205        } else if self.is_warning() {
206            Severity::Warning
207        } else {
208            Severity::Error
209        }
210    }
211}
212
213/// Severity level for validation messages.
214#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
215pub enum Severity {
216    /// Ledger is invalid.
217    Error,
218    /// Suspicious but valid.
219    Warning,
220    /// Informational only.
221    Info,
222}
223
224impl std::fmt::Display for ErrorCode {
225    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
226        write!(f, "{}", self.code())
227    }
228}
229
230/// A validation error.
231#[derive(Debug, Clone, Error)]
232#[error("[{code}] {message}")]
233pub struct ValidationError {
234    /// Error code.
235    pub code: ErrorCode,
236    /// Error message.
237    pub message: String,
238    /// Date of the directive that caused the error.
239    pub date: NaiveDate,
240    /// Additional context.
241    pub context: Option<String>,
242    /// Source span (byte offsets within the file).
243    pub span: Option<Span>,
244    /// Source file ID (index into `SourceMap`).
245    /// Uses `u16` to minimize struct size (max 65,535 files).
246    pub file_id: Option<u16>,
247}
248
249impl ValidationError {
250    /// Create a new validation error without source location.
251    #[must_use]
252    pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
253        Self {
254            code,
255            message: message.into(),
256            date,
257            context: None,
258            span: None,
259            file_id: None,
260        }
261    }
262
263    /// Create a new validation error with source location from a spanned directive.
264    #[must_use]
265    pub fn with_location<T>(
266        code: ErrorCode,
267        message: impl Into<String>,
268        date: NaiveDate,
269        spanned: &Spanned<T>,
270    ) -> Self {
271        Self {
272            code,
273            message: message.into(),
274            date,
275            context: None,
276            span: Some(spanned.span),
277            file_id: Some(spanned.file_id),
278        }
279    }
280
281    /// Add context to this error.
282    #[must_use]
283    pub fn with_context(mut self, context: impl Into<String>) -> Self {
284        self.context = Some(context.into());
285        self
286    }
287
288    /// Set the source location for this error (builder pattern).
289    ///
290    /// Use this to add location info to an existing error. For creating
291    /// new errors with location, prefer [`Self::with_location`] instead.
292    #[must_use]
293    pub const fn at_location<T>(mut self, spanned: &Spanned<T>) -> Self {
294        self.span = Some(spanned.span);
295        self.file_id = Some(spanned.file_id);
296        self
297    }
298}
299
300/// Account state for tracking lifecycle.
301#[derive(Debug, Clone)]
302struct AccountState {
303    /// Date opened.
304    opened: NaiveDate,
305    /// Date closed (if closed).
306    closed: Option<NaiveDate>,
307    /// Allowed currencies (empty = any).
308    currencies: HashSet<InternedStr>,
309    /// Booking method (stored for future use in booking validation).
310    #[allow(dead_code)]
311    booking: BookingMethod,
312}
313
314/// Validation options.
315#[derive(Debug, Clone)]
316pub struct ValidationOptions {
317    /// Whether to require commodity declarations.
318    pub require_commodities: bool,
319    /// Whether to check if document files exist.
320    pub check_documents: bool,
321    /// Whether to warn about future-dated entries.
322    pub warn_future_dates: bool,
323    /// Base directory for resolving relative document paths.
324    pub document_base: Option<std::path::PathBuf>,
325    /// Valid account type prefixes (from options like `name_assets`, `name_liabilities`, etc.).
326    /// Defaults to `["Assets", "Liabilities", "Equity", "Income", "Expenses"]`.
327    pub account_types: Vec<String>,
328    /// Whether to infer tolerance from cost (matches Python beancount's `infer_tolerance_from_cost`).
329    /// When true, tolerance for cost-based postings is calculated as: `units_quantum * cost_per_unit`.
330    pub infer_tolerance_from_cost: bool,
331    /// Tolerance multiplier (matches Python beancount's `inferred_tolerance_multiplier`).
332    /// Default is 0.5.
333    pub tolerance_multiplier: Decimal,
334}
335
336impl Default for ValidationOptions {
337    fn default() -> Self {
338        Self {
339            require_commodities: false,
340            check_documents: true, // Python beancount validates document files by default
341            warn_future_dates: false,
342            document_base: None,
343            account_types: vec![
344                "Assets".to_string(),
345                "Liabilities".to_string(),
346                "Equity".to_string(),
347                "Income".to_string(),
348                "Expenses".to_string(),
349            ],
350            // Match Python beancount defaults
351            infer_tolerance_from_cost: true,
352            tolerance_multiplier: Decimal::new(5, 1), // 0.5
353        }
354    }
355}
356
357/// Pending pad directive info.
358#[derive(Debug, Clone)]
359struct PendingPad {
360    /// Source account for padding.
361    source_account: InternedStr,
362    /// Date of the pad directive.
363    date: NaiveDate,
364    /// Whether this pad has been used (has at least one balance assertion).
365    used: bool,
366}
367
368/// Ledger state for validation.
369#[derive(Debug, Default)]
370pub struct LedgerState {
371    /// Account states.
372    accounts: HashMap<InternedStr, AccountState>,
373    /// Account inventories.
374    inventories: HashMap<InternedStr, Inventory>,
375    /// Declared commodities.
376    commodities: HashSet<InternedStr>,
377    /// Pending pad directives (account -> list of pads).
378    pending_pads: HashMap<InternedStr, Vec<PendingPad>>,
379    /// Validation options.
380    options: ValidationOptions,
381    /// Track previous directive date for out-of-order detection.
382    last_date: Option<NaiveDate>,
383}
384
385impl LedgerState {
386    /// Create a new ledger state.
387    #[must_use]
388    pub fn new() -> Self {
389        Self::default()
390    }
391
392    /// Create a new ledger state with options.
393    #[must_use]
394    pub fn with_options(options: ValidationOptions) -> Self {
395        Self {
396            options,
397            ..Default::default()
398        }
399    }
400
401    /// Set whether to require commodity declarations.
402    pub const fn set_require_commodities(&mut self, require: bool) {
403        self.options.require_commodities = require;
404    }
405
406    /// Set whether to check document files.
407    pub const fn set_check_documents(&mut self, check: bool) {
408        self.options.check_documents = check;
409    }
410
411    /// Set whether to warn about future dates.
412    pub const fn set_warn_future_dates(&mut self, warn: bool) {
413        self.options.warn_future_dates = warn;
414    }
415
416    /// Set the document base directory.
417    pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
418        self.options.document_base = Some(base.into());
419    }
420
421    /// Get the inventory for an account.
422    #[must_use]
423    pub fn inventory(&self, account: &str) -> Option<&Inventory> {
424        self.inventories.get(account)
425    }
426
427    /// Get all account names.
428    pub fn accounts(&self) -> impl Iterator<Item = &str> {
429        self.accounts.keys().map(InternedStr::as_str)
430    }
431}
432
433/// Validate a stream of directives.
434///
435/// Returns a list of validation errors found.
436pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
437    validate_with_options(directives, ValidationOptions::default())
438}
439
440/// Validate a stream of directives with custom options.
441///
442/// Returns a list of validation errors and warnings found.
443pub fn validate_with_options(
444    directives: &[Directive],
445    options: ValidationOptions,
446) -> Vec<ValidationError> {
447    let mut state = LedgerState::with_options(options);
448    let mut errors = Vec::new();
449
450    let today = Local::now().date_naive();
451
452    // Sort directives by date, then by type priority (parallel)
453    // (e.g., balance assertions before transactions on the same day)
454    let mut sorted: Vec<&Directive> = directives.iter().collect();
455    sorted.par_sort_by(|a, b| {
456        a.date()
457            .cmp(&b.date())
458            .then_with(|| a.priority().cmp(&b.priority()))
459    });
460
461    for directive in sorted {
462        let date = directive.date();
463
464        // Check for date ordering (info only - we sort anyway)
465        if let Some(last) = state.last_date {
466            if date < last {
467                errors.push(ValidationError::new(
468                    ErrorCode::DateOutOfOrder,
469                    format!("Directive date {date} is before previous directive {last}"),
470                    date,
471                ));
472            }
473        }
474        state.last_date = Some(date);
475
476        // Check for future dates if enabled
477        if state.options.warn_future_dates && date > today {
478            errors.push(ValidationError::new(
479                ErrorCode::FutureDate,
480                format!("Entry dated in the future: {date}"),
481                date,
482            ));
483        }
484
485        match directive {
486            Directive::Open(open) => {
487                validate_open(&mut state, open, &mut errors);
488            }
489            Directive::Close(close) => {
490                validate_close(&mut state, close, &mut errors);
491            }
492            Directive::Transaction(txn) => {
493                validate_transaction(&mut state, txn, &mut errors);
494            }
495            Directive::Balance(bal) => {
496                validate_balance(&mut state, bal, &mut errors);
497            }
498            Directive::Commodity(comm) => {
499                state.commodities.insert(comm.currency.clone());
500            }
501            Directive::Pad(pad) => {
502                validate_pad(&mut state, pad, &mut errors);
503            }
504            Directive::Document(doc) => {
505                validate_document(&state, doc, &mut errors);
506            }
507            Directive::Note(note) => {
508                validate_note(&state, note, &mut errors);
509            }
510            _ => {}
511        }
512    }
513
514    // Check for unused pads (E2003)
515    for (target_account, pads) in &state.pending_pads {
516        for pad in pads {
517            if !pad.used {
518                errors.push(
519                    ValidationError::new(
520                        ErrorCode::PadWithoutBalance,
521                        "Unused Pad entry".to_string(),
522                        pad.date,
523                    )
524                    .with_context(format!(
525                        "   {} pad {} {}",
526                        pad.date, target_account, pad.source_account
527                    )),
528                );
529            }
530        }
531    }
532
533    errors
534}
535
536/// Validate a stream of spanned directives with custom options.
537///
538/// This variant accepts `Spanned<Directive>` to preserve source location information,
539/// which is propagated to any validation errors. This enables IDE-friendly error
540/// messages with `file:line` information.
541///
542/// Returns a list of validation errors and warnings found, each with source location
543/// when available.
544pub fn validate_spanned_with_options(
545    directives: &[Spanned<Directive>],
546    options: ValidationOptions,
547) -> Vec<ValidationError> {
548    let mut state = LedgerState::with_options(options);
549    let mut errors = Vec::new();
550
551    let today = Local::now().date_naive();
552
553    // Sort directives by date, then by type priority (parallel)
554    let mut sorted: Vec<&Spanned<Directive>> = directives.iter().collect();
555    sorted.par_sort_by(|a, b| {
556        a.value
557            .date()
558            .cmp(&b.value.date())
559            .then_with(|| a.value.priority().cmp(&b.value.priority()))
560    });
561
562    for spanned in sorted {
563        let directive = &spanned.value;
564        let date = directive.date();
565
566        // Check for date ordering (info only - we sort anyway)
567        if let Some(last) = state.last_date {
568            if date < last {
569                errors.push(ValidationError::with_location(
570                    ErrorCode::DateOutOfOrder,
571                    format!("Directive date {date} is before previous directive {last}"),
572                    date,
573                    spanned,
574                ));
575            }
576        }
577        state.last_date = Some(date);
578
579        // Check for future dates if enabled
580        if state.options.warn_future_dates && date > today {
581            errors.push(ValidationError::with_location(
582                ErrorCode::FutureDate,
583                format!("Entry dated in the future: {date}"),
584                date,
585                spanned,
586            ));
587        }
588
589        // Track error count before helper function so we can patch new errors with location
590        let error_count_before = errors.len();
591
592        match directive {
593            Directive::Open(open) => {
594                validate_open(&mut state, open, &mut errors);
595            }
596            Directive::Close(close) => {
597                validate_close(&mut state, close, &mut errors);
598            }
599            Directive::Transaction(txn) => {
600                validate_transaction(&mut state, txn, &mut errors);
601            }
602            Directive::Balance(bal) => {
603                validate_balance(&mut state, bal, &mut errors);
604            }
605            Directive::Commodity(comm) => {
606                state.commodities.insert(comm.currency.clone());
607            }
608            Directive::Pad(pad) => {
609                validate_pad(&mut state, pad, &mut errors);
610            }
611            Directive::Document(doc) => {
612                validate_document(&state, doc, &mut errors);
613            }
614            Directive::Note(note) => {
615                validate_note(&state, note, &mut errors);
616            }
617            _ => {}
618        }
619
620        // Patch any new errors with location info from the current directive
621        for error in errors.iter_mut().skip(error_count_before) {
622            if error.span.is_none() {
623                error.span = Some(spanned.span);
624                error.file_id = Some(spanned.file_id);
625            }
626        }
627    }
628
629    // Check for unused pads (E2003)
630    // Note: These errors won't have location info since we don't store spans in PendingPad
631    for (target_account, pads) in &state.pending_pads {
632        for pad in pads {
633            if !pad.used {
634                errors.push(
635                    ValidationError::new(
636                        ErrorCode::PadWithoutBalance,
637                        "Unused Pad entry".to_string(),
638                        pad.date,
639                    )
640                    .with_context(format!(
641                        "   {} pad {} {}",
642                        pad.date, target_account, pad.source_account
643                    )),
644                );
645            }
646        }
647    }
648
649    errors
650}
651
652/// Validate an account name according to beancount rules.
653/// Returns None if valid, or Some(reason) if invalid.
654///
655/// The `account_types` parameter specifies valid account type prefixes (from options
656/// like `name_assets`, `name_liabilities`, etc.). Defaults are: Assets, Liabilities,
657/// Equity, Income, Expenses.
658fn validate_account_name(account: &str, account_types: &[String]) -> Option<String> {
659    if account.is_empty() {
660        return Some("account name is empty".to_string());
661    }
662
663    let parts: Vec<&str> = account.split(':').collect();
664    if parts.is_empty() {
665        return Some("account name has no components".to_string());
666    }
667
668    // Check root account type
669    let root = parts[0];
670    if !account_types.iter().any(|t| t == root) {
671        return Some(format!(
672            "account must start with one of: {}",
673            account_types.join(", ")
674        ));
675    }
676
677    // Check each component
678    for (i, part) in parts.iter().enumerate() {
679        if part.is_empty() {
680            return Some(format!("component {} is empty", i + 1));
681        }
682
683        // First character must be uppercase ASCII letter, ASCII digit, or any non-ASCII character.
684        // This matches beancount's lexer.l: `([A-Z]|UTF-8-ONLY)` for account type start,
685        // and `([A-Z0-9]|UTF-8-ONLY)` for sub-account start.
686        // Non-ASCII includes CJK, emojis, symbols, and any other Unicode character.
687        // Safety: we just checked part.is_empty() above, so this is guaranteed to succeed
688        let Some(first_char) = part.chars().next() else {
689            // This branch is unreachable due to the is_empty check above,
690            // but we handle it defensively to avoid unwrap
691            return Some(format!("component {} is empty", i + 1));
692        };
693        // Accept: uppercase ASCII letters, ASCII digits, or any non-ASCII character
694        let is_valid_start = first_char.is_ascii_uppercase()
695            || first_char.is_ascii_digit()
696            || !first_char.is_ascii();
697        if !is_valid_start {
698            return Some(format!(
699                "component '{part}' must start with uppercase letter or digit"
700            ));
701        }
702
703        // Remaining characters: ASCII letters, ASCII digits, hyphens, or any non-ASCII character.
704        // This matches beancount's lexer.l: `([A-Za-z0-9-]|UTF-8-ONLY)*`
705        for c in part.chars().skip(1) {
706            if !c.is_ascii_alphanumeric() && c != '-' && c.is_ascii() {
707                return Some(format!(
708                    "component '{part}' contains invalid character '{c}'"
709                ));
710            }
711        }
712    }
713
714    None // Valid
715}
716
717fn validate_open(state: &mut LedgerState, open: &Open, errors: &mut Vec<ValidationError>) {
718    // Validate account name format
719    if let Some(reason) = validate_account_name(&open.account, &state.options.account_types) {
720        errors.push(
721            ValidationError::new(
722                ErrorCode::InvalidAccountName,
723                format!("Invalid account name \"{}\": {}", open.account, reason),
724                open.date,
725            )
726            .with_context(open.account.to_string()),
727        );
728        // Continue anyway to allow further validation
729    }
730
731    // Check if already open
732    if let Some(existing) = state.accounts.get(&open.account) {
733        errors.push(ValidationError::new(
734            ErrorCode::AccountAlreadyOpen,
735            format!(
736                "Account {} is already open (opened on {})",
737                open.account, existing.opened
738            ),
739            open.date,
740        ));
741        return;
742    }
743
744    let booking = open
745        .booking
746        .as_ref()
747        .and_then(|b| b.parse::<BookingMethod>().ok())
748        .unwrap_or_default();
749
750    state.accounts.insert(
751        open.account.clone(),
752        AccountState {
753            opened: open.date,
754            closed: None,
755            currencies: open.currencies.iter().cloned().collect(),
756            booking,
757        },
758    );
759
760    state
761        .inventories
762        .insert(open.account.clone(), Inventory::new());
763}
764
765fn validate_close(state: &mut LedgerState, close: &Close, errors: &mut Vec<ValidationError>) {
766    match state.accounts.get_mut(&close.account) {
767        Some(account_state) => {
768            if account_state.closed.is_some() {
769                errors.push(ValidationError::new(
770                    ErrorCode::AccountClosed,
771                    format!("Account {} already closed", close.account),
772                    close.date,
773                ));
774            } else {
775                // Check if account has non-zero balance (warning)
776                if let Some(inv) = state.inventories.get(&close.account) {
777                    if !inv.is_empty() {
778                        let positions: Vec<String> = inv
779                            .positions()
780                            .iter()
781                            .map(|p| format!("{} {}", p.units.number, p.units.currency))
782                            .collect();
783                        errors.push(
784                            ValidationError::new(
785                                ErrorCode::AccountCloseNotEmpty,
786                                format!(
787                                    "Cannot close account {} with non-zero balance",
788                                    close.account
789                                ),
790                                close.date,
791                            )
792                            .with_context(format!("balance: {}", positions.join(", "))),
793                        );
794                    }
795                }
796                account_state.closed = Some(close.date);
797            }
798        }
799        None => {
800            errors.push(ValidationError::new(
801                ErrorCode::AccountNotOpen,
802                format!("Account {} was never opened", close.account),
803                close.date,
804            ));
805        }
806    }
807}
808
809fn validate_transaction(
810    state: &mut LedgerState,
811    txn: &Transaction,
812    errors: &mut Vec<ValidationError>,
813) {
814    // Check transaction structure
815    if !validate_transaction_structure(txn, errors) {
816        return; // No point checking further if no postings
817    }
818
819    // Check each posting's account lifecycle and currency constraints
820    validate_posting_accounts(state, txn, errors);
821
822    // Check transaction balance
823    validate_transaction_balance(txn, &state.options, errors);
824
825    // Update inventories with booking validation
826    update_inventories(state, txn, errors);
827}
828
829/// Validate transaction structure.
830/// Returns false if validation should stop (no postings to validate).
831///
832/// Note: Python beancount allows transactions with zero postings (metadata-only transactions).
833/// Single-posting transactions are allowed structurally but will fail balance checking.
834fn validate_transaction_structure(txn: &Transaction, errors: &mut Vec<ValidationError>) -> bool {
835    if txn.postings.is_empty() {
836        // Python beancount allows transactions with no postings (metadata-only).
837        // No error, but skip further validation since there's nothing to validate.
838        return false;
839    }
840
841    // Warn about single posting (structurally valid but will fail balance check)
842    if txn.postings.len() == 1 {
843        errors.push(ValidationError::new(
844            ErrorCode::SinglePosting,
845            "Transaction has only one posting".to_string(),
846            txn.date,
847        ));
848    }
849
850    true
851}
852
853/// Validate account lifecycle and currency constraints for each posting.
854fn validate_posting_accounts(
855    state: &LedgerState,
856    txn: &Transaction,
857    errors: &mut Vec<ValidationError>,
858) {
859    for posting in &txn.postings {
860        match state.accounts.get(&posting.account) {
861            Some(account_state) => {
862                validate_account_lifecycle(txn, posting, account_state, errors);
863                validate_posting_currency(state, txn, posting, account_state, errors);
864            }
865            None => {
866                errors.push(ValidationError::new(
867                    ErrorCode::AccountNotOpen,
868                    format!("Account {} was never opened", posting.account),
869                    txn.date,
870                ));
871            }
872        }
873    }
874}
875
876/// Validate that an account is open at transaction time and not closed.
877fn validate_account_lifecycle(
878    txn: &Transaction,
879    posting: &Posting,
880    account_state: &AccountState,
881    errors: &mut Vec<ValidationError>,
882) {
883    if txn.date < account_state.opened {
884        errors.push(ValidationError::new(
885            ErrorCode::AccountNotOpen,
886            format!(
887                "Account {} used on {} but not opened until {}",
888                posting.account, txn.date, account_state.opened
889            ),
890            txn.date,
891        ));
892    }
893
894    if let Some(closed) = account_state.closed {
895        if txn.date >= closed {
896            errors.push(ValidationError::new(
897                ErrorCode::AccountClosed,
898                format!(
899                    "Account {} used on {} but was closed on {}",
900                    posting.account, txn.date, closed
901                ),
902                txn.date,
903            ));
904        }
905    }
906}
907
908/// Validate currency constraints and commodity declarations for a posting.
909fn validate_posting_currency(
910    state: &LedgerState,
911    txn: &Transaction,
912    posting: &Posting,
913    account_state: &AccountState,
914    errors: &mut Vec<ValidationError>,
915) {
916    let Some(units) = posting.amount() else {
917        return;
918    };
919
920    // Check currency constraints
921    if !account_state.currencies.is_empty() && !account_state.currencies.contains(&units.currency) {
922        errors.push(ValidationError::new(
923            ErrorCode::CurrencyNotAllowed,
924            format!(
925                "Currency {} not allowed in account {}",
926                units.currency, posting.account
927            ),
928            txn.date,
929        ));
930    }
931
932    // Check commodity declaration
933    if state.options.require_commodities && !state.commodities.contains(&units.currency) {
934        errors.push(ValidationError::new(
935            ErrorCode::UndeclaredCurrency,
936            format!("Currency {} not declared", units.currency),
937            txn.date,
938        ));
939    }
940}
941
942/// Validate that the transaction balances within tolerance.
943///
944/// Tolerance is calculated per-currency based on:
945/// 1. The quantum (precision) of amounts in postings
946/// 2. Cost-based tolerance when `infer_tolerance_from_cost` is enabled:
947///    `tolerance = units_quantum * cost_per_unit * tolerance_multiplier`
948fn validate_transaction_balance(
949    txn: &Transaction,
950    options: &ValidationOptions,
951    errors: &mut Vec<ValidationError>,
952) {
953    // Skip balance checking if there are any empty cost specs (e.g., `{}`).
954    // Empty cost specs will have their cost filled in by lot matching during booking,
955    // and if there's no matching lot, that error will be reported separately.
956    // This matches Python beancount behavior where booking runs before balance checking.
957    let has_empty_cost_spec = txn.postings.iter().any(|p| {
958        if let Some(cost) = &p.cost {
959            // Empty cost spec: no per-unit cost, no total cost
960            cost.number_per.is_none() && cost.number_total.is_none()
961        } else {
962            false
963        }
964    });
965    if has_empty_cost_spec {
966        return; // Lot matching will validate this transaction
967    }
968
969    let residuals = rustledger_booking::calculate_residual(txn);
970
971    // Calculate per-currency tolerance based on postings
972    let tolerances = calculate_tolerances(txn, options);
973
974    for (currency, residual) in residuals {
975        // Get the tolerance for this currency, defaulting to 0.005
976        let tolerance = tolerances
977            .get(currency.as_str())
978            .copied()
979            .unwrap_or_else(|| Decimal::new(5, 3));
980
981        if residual.abs() > tolerance {
982            errors.push(ValidationError::new(
983                ErrorCode::TransactionUnbalanced,
984                format!("Transaction does not balance: residual {residual} {currency}"),
985                txn.date,
986            ));
987        }
988    }
989}
990
991/// Calculate the quantum (smallest unit) of a decimal number based on its precision.
992/// For example: 10.436 has quantum 0.001, 100.00 has quantum 0.01
993fn decimal_quantum(value: Decimal) -> Decimal {
994    let scale = value.scale();
995    if scale == 0 {
996        Decimal::ONE
997    } else {
998        Decimal::new(1, scale)
999    }
1000}
1001
1002/// Calculate per-currency tolerances for a transaction.
1003///
1004/// When `infer_tolerance_from_cost` is enabled, for each posting with a cost:
1005///   `tolerance = units_quantum * cost_per_unit * tolerance_multiplier`
1006///
1007/// The tolerance for each cost currency is the maximum of all such values
1008/// computed from postings with costs in that currency.
1009fn calculate_tolerances(
1010    txn: &Transaction,
1011    options: &ValidationOptions,
1012) -> HashMap<String, Decimal> {
1013    let mut tolerances: HashMap<String, Decimal> = HashMap::new();
1014
1015    // Default tolerance based on quantum of amounts in postings
1016    for posting in &txn.postings {
1017        if let Some(units) = posting.amount() {
1018            let quantum = decimal_quantum(units.number);
1019            // Use half the quantum as base tolerance (like Python beancount)
1020            let base_tolerance = quantum * options.tolerance_multiplier;
1021
1022            tolerances
1023                .entry(units.currency.to_string())
1024                .and_modify(|t| *t = (*t).max(base_tolerance))
1025                .or_insert(base_tolerance);
1026        }
1027    }
1028
1029    // Calculate cost-inferred tolerance if enabled
1030    if options.infer_tolerance_from_cost {
1031        for posting in &txn.postings {
1032            if let (Some(units), Some(cost_spec)) = (posting.amount(), &posting.cost) {
1033                // Get the cost per unit
1034                if let Some(cost_per_unit) = cost_spec.number_per {
1035                    // Get the cost currency
1036                    if let Some(cost_currency) = &cost_spec.currency {
1037                        // Calculate: units_quantum * cost_per_unit * multiplier
1038                        let units_quantum = decimal_quantum(units.number);
1039                        let cost_tolerance =
1040                            units_quantum * cost_per_unit * options.tolerance_multiplier;
1041
1042                        // Update tolerance for the cost currency (take max)
1043                        tolerances
1044                            .entry(cost_currency.to_string())
1045                            .and_modify(|t| *t = (*t).max(cost_tolerance))
1046                            .or_insert(cost_tolerance);
1047                    }
1048                }
1049            }
1050        }
1051    }
1052
1053    tolerances
1054}
1055
1056/// Update inventories with booking validation for each posting.
1057fn update_inventories(
1058    state: &mut LedgerState,
1059    txn: &Transaction,
1060    errors: &mut Vec<ValidationError>,
1061) {
1062    for posting in &txn.postings {
1063        let Some(units) = posting.amount() else {
1064            continue;
1065        };
1066        let Some(inv) = state.inventories.get_mut(&posting.account) else {
1067            continue;
1068        };
1069
1070        let booking_method = state
1071            .accounts
1072            .get(&posting.account)
1073            .map(|a| a.booking)
1074            .unwrap_or_default();
1075
1076        let is_reduction = units.number.is_sign_negative() && posting.cost.is_some();
1077
1078        if is_reduction {
1079            process_inventory_reduction(inv, posting, units, booking_method, txn, errors);
1080        } else {
1081            process_inventory_addition(inv, posting, units, txn);
1082        }
1083    }
1084}
1085
1086/// Process an inventory reduction (selling/removing units).
1087fn process_inventory_reduction(
1088    inv: &mut Inventory,
1089    posting: &Posting,
1090    units: &Amount,
1091    booking_method: BookingMethod,
1092    txn: &Transaction,
1093    errors: &mut Vec<ValidationError>,
1094) {
1095    match inv.reduce(units, posting.cost.as_ref(), booking_method) {
1096        Ok(_) => {}
1097        Err(rustledger_core::BookingError::InsufficientUnits {
1098            requested,
1099            available,
1100            ..
1101        }) => {
1102            errors.push(
1103                ValidationError::new(
1104                    ErrorCode::InsufficientUnits,
1105                    format!(
1106                        "Insufficient units in {}: requested {}, available {}",
1107                        posting.account, requested, available
1108                    ),
1109                    txn.date,
1110                )
1111                .with_context(format!("currency: {}", units.currency)),
1112            );
1113        }
1114        Err(rustledger_core::BookingError::NoMatchingLot { currency, .. }) => {
1115            // In STRICT mode, when no lot matches AND the inventory has no POSITIVE
1116            // positions for this commodity, Python beancount allows "sell to open"
1117            // by creating a new lot with negative units. This is common in options trading.
1118            // However, if there ARE positive lots that just don't match the cost spec,
1119            // that's an error (you're trying to sell from a lot that doesn't exist).
1120            // We only check for positive lots because negative lots are short positions
1121            // from previous sell-to-open operations.
1122            let has_positive_lots = inv
1123                .positions()
1124                .iter()
1125                .any(|p| p.units.currency == units.currency && p.units.number > Decimal::ZERO);
1126
1127            if booking_method == BookingMethod::Strict && !has_positive_lots {
1128                if let Some(cost_spec) = &posting.cost {
1129                    // Need cost per unit (or total) and currency to create a new lot
1130                    let cost_number = cost_spec
1131                        .number_per
1132                        .or_else(|| cost_spec.number_total.map(|t| t / units.number.abs()));
1133
1134                    // Infer currency from cost spec, price annotation, or fall back
1135                    let cost_currency = cost_spec.currency.clone().or_else(|| {
1136                        // Try to get currency from price annotation
1137                        posting.price.as_ref().and_then(|p| match p {
1138                            rustledger_core::PriceAnnotation::Unit(a)
1139                            | rustledger_core::PriceAnnotation::Total(a) => {
1140                                Some(a.currency.clone())
1141                            }
1142                            rustledger_core::PriceAnnotation::UnitIncomplete(inc)
1143                            | rustledger_core::PriceAnnotation::TotalIncomplete(inc) => {
1144                                inc.as_amount().map(|a| a.currency.clone())
1145                            }
1146                            _ => None,
1147                        })
1148                    });
1149
1150                    if let (Some(number), Some(curr)) = (cost_number, cost_currency) {
1151                        // Create a new position with negative units (sell to open)
1152                        let cost = rustledger_core::Cost::new(number, curr)
1153                            .with_date(cost_spec.date.unwrap_or(txn.date));
1154                        let cost = if let Some(label) = &cost_spec.label {
1155                            cost.with_label(label.clone())
1156                        } else {
1157                            cost
1158                        };
1159                        let position = rustledger_core::Position::with_cost(units.clone(), cost);
1160                        inv.add(position);
1161                        return; // Successfully created sell-to-open position
1162                    }
1163                }
1164            }
1165            // Couldn't create sell-to-open (or has existing lots that don't match), report error
1166            errors.push(
1167                ValidationError::new(
1168                    ErrorCode::NoMatchingLot,
1169                    format!("No matching lot for {} in {}", currency, posting.account),
1170                    txn.date,
1171                )
1172                .with_context(format!("cost spec: {:?}", posting.cost)),
1173            );
1174        }
1175        Err(rustledger_core::BookingError::AmbiguousMatch {
1176            currency,
1177            num_matches,
1178        }) => {
1179            errors.push(
1180                ValidationError::new(
1181                    ErrorCode::AmbiguousLotMatch,
1182                    format!(
1183                        "Ambiguous lot match for {}: {} lots match in {}",
1184                        currency, num_matches, posting.account
1185                    ),
1186                    txn.date,
1187                )
1188                .with_context("Specify cost, date, or label to disambiguate".to_string()),
1189            );
1190        }
1191        Err(rustledger_core::BookingError::CurrencyMismatch { .. }) => {
1192            // This shouldn't happen in normal validation
1193        }
1194    }
1195}
1196
1197/// Process an inventory addition (buying/adding units).
1198fn process_inventory_addition(
1199    inv: &mut Inventory,
1200    posting: &Posting,
1201    units: &Amount,
1202    txn: &Transaction,
1203) {
1204    let position = if let Some(cost_spec) = &posting.cost {
1205        if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
1206            rustledger_core::Position::with_cost(units.clone(), cost)
1207        } else {
1208            rustledger_core::Position::simple(units.clone())
1209        }
1210    } else {
1211        rustledger_core::Position::simple(units.clone())
1212    };
1213
1214    inv.add(position);
1215}
1216
1217fn validate_pad(state: &mut LedgerState, pad: &Pad, errors: &mut Vec<ValidationError>) {
1218    // Check that the target account exists
1219    if !state.accounts.contains_key(&pad.account) {
1220        errors.push(ValidationError::new(
1221            ErrorCode::AccountNotOpen,
1222            format!("Pad target account {} was never opened", pad.account),
1223            pad.date,
1224        ));
1225        return;
1226    }
1227
1228    // Check that the source account exists
1229    if !state.accounts.contains_key(&pad.source_account) {
1230        errors.push(ValidationError::new(
1231            ErrorCode::AccountNotOpen,
1232            format!("Pad source account {} was never opened", pad.source_account),
1233            pad.date,
1234        ));
1235        return;
1236    }
1237
1238    // Add to pending pads list for this account
1239    let pending_pad = PendingPad {
1240        source_account: pad.source_account.clone(),
1241        date: pad.date,
1242        used: false,
1243    };
1244    state
1245        .pending_pads
1246        .entry(pad.account.clone())
1247        .or_default()
1248        .push(pending_pad);
1249}
1250
1251fn validate_balance(state: &mut LedgerState, bal: &Balance, errors: &mut Vec<ValidationError>) {
1252    // Check account exists
1253    if !state.accounts.contains_key(&bal.account) {
1254        errors.push(ValidationError::new(
1255            ErrorCode::AccountNotOpen,
1256            format!("Account {} was never opened", bal.account),
1257            bal.date,
1258        ));
1259        return;
1260    }
1261
1262    // Check if there are pending pads for this account
1263    // Use get_mut instead of remove - a pad can apply to multiple currencies
1264    if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
1265        // Check for multiple pads (E2004) - only warn if none have been used yet
1266        if pending_pads.len() > 1 && !pending_pads.iter().any(|p| p.used) {
1267            errors.push(
1268                ValidationError::new(
1269                    ErrorCode::MultiplePadForBalance,
1270                    format!(
1271                        "Multiple pad directives for {} {} before balance assertion",
1272                        bal.account, bal.amount.currency
1273                    ),
1274                    bal.date,
1275                )
1276                .with_context(format!(
1277                    "pad dates: {}",
1278                    pending_pads
1279                        .iter()
1280                        .map(|p| p.date.to_string())
1281                        .collect::<Vec<_>>()
1282                        .join(", ")
1283                )),
1284            );
1285        }
1286
1287        // Use the most recent pad
1288        if let Some(pending_pad) = pending_pads.last_mut() {
1289            // Apply padding: calculate difference and add to both accounts
1290            // Balance assertions include sub-accounts, so sum them all up
1291            let mut actual = Decimal::ZERO;
1292            let account_prefix = format!("{}:", bal.account);
1293            for (account, inv) in &state.inventories {
1294                if account == &bal.account || account.starts_with(&account_prefix) {
1295                    actual += inv.units(&bal.amount.currency);
1296                }
1297            }
1298            {
1299                let expected = bal.amount.number;
1300                let difference = expected - actual;
1301
1302                if difference != Decimal::ZERO {
1303                    // Add padding amount to target account
1304                    if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
1305                        target_inv.add(Position::simple(Amount::new(
1306                            difference,
1307                            &bal.amount.currency,
1308                        )));
1309                    }
1310
1311                    // Subtract padding amount from source account
1312                    if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
1313                    {
1314                        source_inv.add(Position::simple(Amount::new(
1315                            -difference,
1316                            &bal.amount.currency,
1317                        )));
1318                    }
1319
1320                    // Mark pad as used only if padding was actually needed
1321                    pending_pad.used = true;
1322                }
1323            }
1324        }
1325        // After padding, the balance should match (no error needed)
1326        return;
1327    }
1328
1329    // Get inventory and check balance (no padding case)
1330    // In beancount, balance assertions include sub-accounts
1331    // e.g., balance Assets:Checking includes Assets:Checking:Sub1, Assets:Checking:Sub2, etc.
1332    let mut actual = Decimal::ZERO;
1333    let account_prefix = format!("{}:", bal.account);
1334    for (account, inv) in &state.inventories {
1335        // Include exact match or sub-accounts (account:*)
1336        if account == &bal.account || account.starts_with(&account_prefix) {
1337            actual += inv.units(&bal.amount.currency);
1338        }
1339    }
1340
1341    if actual != Decimal::ZERO || state.inventories.contains_key(&bal.account) {
1342        let expected = bal.amount.number;
1343        let difference = (actual - expected).abs();
1344
1345        // Determine tolerance and whether it was explicitly specified
1346        let (tolerance, is_explicit) = if let Some(t) = bal.tolerance {
1347            (t, true)
1348        } else {
1349            (bal.amount.inferred_tolerance(), false)
1350        };
1351
1352        if difference > tolerance {
1353            // Use E2002 for explicit tolerance, E2001 for inferred
1354            let error_code = if is_explicit {
1355                ErrorCode::BalanceToleranceExceeded
1356            } else {
1357                ErrorCode::BalanceAssertionFailed
1358            };
1359
1360            let message = if is_explicit {
1361                format!(
1362                    "Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
1363                    bal.account,
1364                    expected,
1365                    bal.amount.currency,
1366                    tolerance,
1367                    actual,
1368                    bal.amount.currency,
1369                    difference
1370                )
1371            } else {
1372                format!(
1373                    "Balance assertion failed for {}: expected {} {}, got {} {}",
1374                    bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1375                )
1376            };
1377
1378            errors.push(
1379                ValidationError::new(error_code, message, bal.date)
1380                    .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1381            );
1382        }
1383    }
1384}
1385
1386/// Validate a Note directive.
1387///
1388/// Checks that the referenced account has been opened.
1389fn validate_note(state: &LedgerState, note: &Note, errors: &mut Vec<ValidationError>) {
1390    // Check account exists
1391    if !state.accounts.contains_key(&note.account) {
1392        errors.push(ValidationError::new(
1393            ErrorCode::AccountNotOpen,
1394            format!("Invalid reference to unknown account '{}'", note.account),
1395            note.date,
1396        ));
1397    }
1398}
1399
1400fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1401    // Check account exists
1402    if !state.accounts.contains_key(&doc.account) {
1403        errors.push(ValidationError::new(
1404            ErrorCode::AccountNotOpen,
1405            format!("Invalid reference to unknown account '{}'", doc.account),
1406            doc.date,
1407        ));
1408    }
1409
1410    // Check if document file exists (if enabled)
1411    if state.options.check_documents {
1412        let doc_path = Path::new(&doc.path);
1413
1414        let full_path = if doc_path.is_absolute() {
1415            doc_path.to_path_buf()
1416        } else if let Some(base) = &state.options.document_base {
1417            base.join(doc_path)
1418        } else {
1419            doc_path.to_path_buf()
1420        };
1421
1422        if !full_path.exists() {
1423            errors.push(
1424                ValidationError::new(
1425                    ErrorCode::DocumentNotFound,
1426                    format!("Document file not found: {}", doc.path),
1427                    doc.date,
1428                )
1429                .with_context(format!("resolved path: {}", full_path.display())),
1430            );
1431        }
1432    }
1433}
1434
1435#[cfg(test)]
1436mod tests {
1437    use super::*;
1438    use rust_decimal_macros::dec;
1439    use rustledger_core::{Amount, NaiveDate, Posting};
1440
1441    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1442        NaiveDate::from_ymd_opt(year, month, day).unwrap()
1443    }
1444
1445    #[test]
1446    fn test_validate_account_lifecycle() {
1447        let directives = vec![
1448            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1449            Directive::Transaction(
1450                Transaction::new(date(2024, 1, 15), "Test")
1451                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1452                    .with_posting(Posting::new(
1453                        "Income:Salary",
1454                        Amount::new(dec!(-100), "USD"),
1455                    )),
1456            ),
1457        ];
1458
1459        let errors = validate(&directives);
1460
1461        // Should have error: Income:Salary not opened
1462        assert!(errors
1463            .iter()
1464            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1465    }
1466
1467    #[test]
1468    fn test_validate_account_used_before_open() {
1469        let directives = vec![
1470            Directive::Transaction(
1471                Transaction::new(date(2024, 1, 1), "Test")
1472                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1473                    .with_posting(Posting::new(
1474                        "Income:Salary",
1475                        Amount::new(dec!(-100), "USD"),
1476                    )),
1477            ),
1478            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1479        ];
1480
1481        let errors = validate(&directives);
1482
1483        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1484    }
1485
1486    #[test]
1487    fn test_validate_account_used_after_close() {
1488        let directives = vec![
1489            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1490            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1491            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1492            Directive::Transaction(
1493                Transaction::new(date(2024, 7, 1), "Test")
1494                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1495                    .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1496            ),
1497        ];
1498
1499        let errors = validate(&directives);
1500
1501        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1502    }
1503
1504    #[test]
1505    fn test_validate_balance_assertion() {
1506        let directives = vec![
1507            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1508            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1509            Directive::Transaction(
1510                Transaction::new(date(2024, 1, 15), "Deposit")
1511                    .with_posting(Posting::new(
1512                        "Assets:Bank",
1513                        Amount::new(dec!(1000.00), "USD"),
1514                    ))
1515                    .with_posting(Posting::new(
1516                        "Income:Salary",
1517                        Amount::new(dec!(-1000.00), "USD"),
1518                    )),
1519            ),
1520            Directive::Balance(Balance::new(
1521                date(2024, 1, 16),
1522                "Assets:Bank",
1523                Amount::new(dec!(1000.00), "USD"),
1524            )),
1525        ];
1526
1527        let errors = validate(&directives);
1528        assert!(errors.is_empty(), "{errors:?}");
1529    }
1530
1531    #[test]
1532    fn test_validate_balance_assertion_failed() {
1533        let directives = vec![
1534            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1535            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1536            Directive::Transaction(
1537                Transaction::new(date(2024, 1, 15), "Deposit")
1538                    .with_posting(Posting::new(
1539                        "Assets:Bank",
1540                        Amount::new(dec!(1000.00), "USD"),
1541                    ))
1542                    .with_posting(Posting::new(
1543                        "Income:Salary",
1544                        Amount::new(dec!(-1000.00), "USD"),
1545                    )),
1546            ),
1547            Directive::Balance(Balance::new(
1548                date(2024, 1, 16),
1549                "Assets:Bank",
1550                Amount::new(dec!(500.00), "USD"), // Wrong!
1551            )),
1552        ];
1553
1554        let errors = validate(&directives);
1555        assert!(
1556            errors
1557                .iter()
1558                .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1559        );
1560    }
1561
1562    #[test]
1563    fn test_validate_unbalanced_transaction() {
1564        let directives = vec![
1565            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1566            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1567            Directive::Transaction(
1568                Transaction::new(date(2024, 1, 15), "Unbalanced")
1569                    .with_posting(Posting::new(
1570                        "Assets:Bank",
1571                        Amount::new(dec!(-50.00), "USD"),
1572                    ))
1573                    .with_posting(Posting::new(
1574                        "Expenses:Food",
1575                        Amount::new(dec!(40.00), "USD"),
1576                    )), // Missing $10
1577            ),
1578        ];
1579
1580        let errors = validate(&directives);
1581        assert!(
1582            errors
1583                .iter()
1584                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1585        );
1586    }
1587
1588    #[test]
1589    fn test_validate_currency_not_allowed() {
1590        let directives = vec![
1591            Directive::Open(
1592                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1593            ),
1594            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1595            Directive::Transaction(
1596                Transaction::new(date(2024, 1, 15), "Test")
1597                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
1598                    .with_posting(Posting::new(
1599                        "Income:Salary",
1600                        Amount::new(dec!(-100.00), "EUR"),
1601                    )),
1602            ),
1603        ];
1604
1605        let errors = validate(&directives);
1606        assert!(
1607            errors
1608                .iter()
1609                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1610        );
1611    }
1612
1613    #[test]
1614    fn test_validate_future_date_warning() {
1615        // Create a date in the future
1616        let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1617
1618        let directives = vec![Directive::Open(Open {
1619            date: future_date,
1620            account: "Assets:Bank".into(),
1621            currencies: vec![],
1622            booking: None,
1623            meta: Default::default(),
1624        })];
1625
1626        // Without warn_future_dates option, no warnings
1627        let errors = validate(&directives);
1628        assert!(
1629            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1630            "Should not warn about future dates by default"
1631        );
1632
1633        // With warn_future_dates option, should warn
1634        let options = ValidationOptions {
1635            warn_future_dates: true,
1636            ..Default::default()
1637        };
1638        let errors = validate_with_options(&directives, options);
1639        assert!(
1640            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1641            "Should warn about future dates when enabled"
1642        );
1643    }
1644
1645    #[test]
1646    fn test_validate_document_not_found() {
1647        let directives = vec![
1648            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1649            Directive::Document(Document {
1650                date: date(2024, 1, 15),
1651                account: "Assets:Bank".into(),
1652                path: "/nonexistent/path/to/document.pdf".to_string(),
1653                tags: vec![],
1654                links: vec![],
1655                meta: Default::default(),
1656            }),
1657        ];
1658
1659        // With default options (check_documents: true), should error
1660        let errors = validate(&directives);
1661        assert!(
1662            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1663            "Should check documents by default"
1664        );
1665
1666        // With check_documents disabled, should not error
1667        let options = ValidationOptions {
1668            check_documents: false,
1669            ..Default::default()
1670        };
1671        let errors = validate_with_options(&directives, options);
1672        assert!(
1673            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1674            "Should not report missing document when disabled"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_validate_document_account_not_open() {
1680        let directives = vec![Directive::Document(Document {
1681            date: date(2024, 1, 15),
1682            account: "Assets:Unknown".into(),
1683            path: "receipt.pdf".to_string(),
1684            tags: vec![],
1685            links: vec![],
1686            meta: Default::default(),
1687        })];
1688
1689        let errors = validate(&directives);
1690        assert!(
1691            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1692            "Should error for document on unopened account"
1693        );
1694    }
1695
1696    #[test]
1697    fn test_error_code_is_warning() {
1698        assert!(!ErrorCode::AccountNotOpen.is_warning());
1699        assert!(!ErrorCode::DocumentNotFound.is_warning());
1700        assert!(ErrorCode::FutureDate.is_warning());
1701    }
1702
1703    #[test]
1704    fn test_validate_pad_basic() {
1705        let directives = vec![
1706            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1707            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1708            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1709            Directive::Balance(Balance::new(
1710                date(2024, 1, 2),
1711                "Assets:Bank",
1712                Amount::new(dec!(1000.00), "USD"),
1713            )),
1714        ];
1715
1716        let errors = validate(&directives);
1717        // Should have no errors - pad should satisfy the balance
1718        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1719    }
1720
1721    #[test]
1722    fn test_validate_pad_with_existing_balance() {
1723        let directives = vec![
1724            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1725            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1726            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1727            // Add some initial transactions
1728            Directive::Transaction(
1729                Transaction::new(date(2024, 1, 5), "Initial deposit")
1730                    .with_posting(Posting::new(
1731                        "Assets:Bank",
1732                        Amount::new(dec!(500.00), "USD"),
1733                    ))
1734                    .with_posting(Posting::new(
1735                        "Income:Salary",
1736                        Amount::new(dec!(-500.00), "USD"),
1737                    )),
1738            ),
1739            // Pad to reach the target balance
1740            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1741            Directive::Balance(Balance::new(
1742                date(2024, 1, 15),
1743                "Assets:Bank",
1744                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
1745            )),
1746        ];
1747
1748        let errors = validate(&directives);
1749        // Should have no errors - pad should add the missing 500
1750        assert!(
1751            errors.is_empty(),
1752            "Pad should add missing amount: {errors:?}"
1753        );
1754    }
1755
1756    #[test]
1757    fn test_validate_pad_account_not_open() {
1758        let directives = vec![
1759            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1760            // Assets:Bank not opened
1761            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1762        ];
1763
1764        let errors = validate(&directives);
1765        assert!(
1766            errors
1767                .iter()
1768                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1769            "Should error for pad on unopened account"
1770        );
1771    }
1772
1773    #[test]
1774    fn test_validate_pad_source_not_open() {
1775        let directives = vec![
1776            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1777            // Equity:Opening not opened
1778            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1779        ];
1780
1781        let errors = validate(&directives);
1782        assert!(
1783            errors.iter().any(
1784                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1785            ),
1786            "Should error for pad with unopened source account"
1787        );
1788    }
1789
1790    #[test]
1791    fn test_validate_pad_negative_adjustment() {
1792        // Test that pad can reduce a balance too
1793        let directives = vec![
1794            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1795            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1796            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1797            // Add more than needed
1798            Directive::Transaction(
1799                Transaction::new(date(2024, 1, 5), "Big deposit")
1800                    .with_posting(Posting::new(
1801                        "Assets:Bank",
1802                        Amount::new(dec!(2000.00), "USD"),
1803                    ))
1804                    .with_posting(Posting::new(
1805                        "Income:Salary",
1806                        Amount::new(dec!(-2000.00), "USD"),
1807                    )),
1808            ),
1809            // Pad to reach a lower target
1810            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1811            Directive::Balance(Balance::new(
1812                date(2024, 1, 15),
1813                "Assets:Bank",
1814                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
1815            )),
1816        ];
1817
1818        let errors = validate(&directives);
1819        assert!(
1820            errors.is_empty(),
1821            "Pad should handle negative adjustment: {errors:?}"
1822        );
1823    }
1824
1825    #[test]
1826    fn test_validate_insufficient_units() {
1827        use rustledger_core::CostSpec;
1828
1829        let cost_spec = CostSpec::empty()
1830            .with_number_per(dec!(150))
1831            .with_currency("USD");
1832
1833        let directives = vec![
1834            Directive::Open(
1835                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1836            ),
1837            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1838            // Buy 10 shares
1839            Directive::Transaction(
1840                Transaction::new(date(2024, 1, 15), "Buy")
1841                    .with_posting(
1842                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1843                            .with_cost(cost_spec.clone()),
1844                    )
1845                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1846            ),
1847            // Try to sell 15 shares (more than we have)
1848            Directive::Transaction(
1849                Transaction::new(date(2024, 6, 1), "Sell too many")
1850                    .with_posting(
1851                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1852                            .with_cost(cost_spec),
1853                    )
1854                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1855            ),
1856        ];
1857
1858        let errors = validate(&directives);
1859        assert!(
1860            errors
1861                .iter()
1862                .any(|e| e.code == ErrorCode::InsufficientUnits),
1863            "Should error for insufficient units: {errors:?}"
1864        );
1865    }
1866
1867    #[test]
1868    fn test_validate_no_matching_lot() {
1869        use rustledger_core::CostSpec;
1870
1871        let directives = vec![
1872            Directive::Open(
1873                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1874            ),
1875            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1876            // Buy at $150
1877            Directive::Transaction(
1878                Transaction::new(date(2024, 1, 15), "Buy")
1879                    .with_posting(
1880                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1881                            CostSpec::empty()
1882                                .with_number_per(dec!(150))
1883                                .with_currency("USD"),
1884                        ),
1885                    )
1886                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1887            ),
1888            // Try to sell at $160 (no lot at this price)
1889            Directive::Transaction(
1890                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1891                    .with_posting(
1892                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1893                            CostSpec::empty()
1894                                .with_number_per(dec!(160))
1895                                .with_currency("USD"),
1896                        ),
1897                    )
1898                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1899            ),
1900        ];
1901
1902        let errors = validate(&directives);
1903        assert!(
1904            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1905            "Should error for no matching lot: {errors:?}"
1906        );
1907    }
1908
1909    #[test]
1910    fn test_validate_multiple_lot_match_uses_fifo() {
1911        // In Python beancount, when multiple lots match the same cost spec,
1912        // STRICT mode falls back to FIFO order rather than erroring.
1913        use rustledger_core::CostSpec;
1914
1915        let cost_spec = CostSpec::empty()
1916            .with_number_per(dec!(150))
1917            .with_currency("USD");
1918
1919        let directives = vec![
1920            Directive::Open(
1921                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1922            ),
1923            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1924            // Buy at $150 on Jan 15
1925            Directive::Transaction(
1926                Transaction::new(date(2024, 1, 15), "Buy lot 1")
1927                    .with_posting(
1928                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1929                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1930                    )
1931                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1932            ),
1933            // Buy again at $150 on Feb 15 (creates second lot at same price)
1934            Directive::Transaction(
1935                Transaction::new(date(2024, 2, 15), "Buy lot 2")
1936                    .with_posting(
1937                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1938                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1939                    )
1940                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1941            ),
1942            // Sell with cost spec that matches both lots - STRICT falls back to FIFO
1943            Directive::Transaction(
1944                Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
1945                    .with_posting(
1946                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1947                            .with_cost(cost_spec),
1948                    )
1949                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1950            ),
1951        ];
1952
1953        let errors = validate(&directives);
1954        // Filter out only booking errors - balance may or may not match
1955        let booking_errors: Vec<_> = errors
1956            .iter()
1957            .filter(|e| {
1958                matches!(
1959                    e.code,
1960                    ErrorCode::InsufficientUnits
1961                        | ErrorCode::NoMatchingLot
1962                        | ErrorCode::AmbiguousLotMatch
1963                )
1964            })
1965            .collect();
1966        assert!(
1967            booking_errors.is_empty(),
1968            "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
1969        );
1970    }
1971
1972    #[test]
1973    fn test_validate_successful_booking() {
1974        use rustledger_core::CostSpec;
1975
1976        let cost_spec = CostSpec::empty()
1977            .with_number_per(dec!(150))
1978            .with_currency("USD");
1979
1980        let directives = vec![
1981            Directive::Open(
1982                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1983            ),
1984            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1985            // Buy 10 shares
1986            Directive::Transaction(
1987                Transaction::new(date(2024, 1, 15), "Buy")
1988                    .with_posting(
1989                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1990                            .with_cost(cost_spec.clone()),
1991                    )
1992                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1993            ),
1994            // Sell 5 shares (should succeed with FIFO)
1995            Directive::Transaction(
1996                Transaction::new(date(2024, 6, 1), "Sell")
1997                    .with_posting(
1998                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1999                            .with_cost(cost_spec),
2000                    )
2001                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
2002            ),
2003        ];
2004
2005        let errors = validate(&directives);
2006        // Filter out any balance errors (we're testing booking only)
2007        let booking_errors: Vec<_> = errors
2008            .iter()
2009            .filter(|e| {
2010                matches!(
2011                    e.code,
2012                    ErrorCode::InsufficientUnits
2013                        | ErrorCode::NoMatchingLot
2014                        | ErrorCode::AmbiguousLotMatch
2015                )
2016            })
2017            .collect();
2018        assert!(
2019            booking_errors.is_empty(),
2020            "Should have no booking errors: {booking_errors:?}"
2021        );
2022    }
2023
2024    #[test]
2025    fn test_validate_account_already_open() {
2026        let directives = vec![
2027            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2028            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
2029        ];
2030
2031        let errors = validate(&directives);
2032        assert!(
2033            errors
2034                .iter()
2035                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2036            "Should error for duplicate open: {errors:?}"
2037        );
2038    }
2039
2040    #[test]
2041    fn test_validate_account_close_not_empty() {
2042        let directives = vec![
2043            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2044            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2045            Directive::Transaction(
2046                Transaction::new(date(2024, 1, 15), "Deposit")
2047                    .with_posting(Posting::new(
2048                        "Assets:Bank",
2049                        Amount::new(dec!(100.00), "USD"),
2050                    ))
2051                    .with_posting(Posting::new(
2052                        "Income:Salary",
2053                        Amount::new(dec!(-100.00), "USD"),
2054                    )),
2055            ),
2056            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
2057        ];
2058
2059        let errors = validate(&directives);
2060        assert!(
2061            errors
2062                .iter()
2063                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2064            "Should warn for closing account with balance: {errors:?}"
2065        );
2066    }
2067
2068    #[test]
2069    fn test_validate_no_postings_allowed() {
2070        // Python beancount allows transactions with no postings (metadata-only).
2071        // We match this behavior.
2072        let directives = vec![
2073            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2074            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2075        ];
2076
2077        let errors = validate(&directives);
2078        assert!(
2079            !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2080            "Should NOT error for transaction with no postings: {errors:?}"
2081        );
2082    }
2083
2084    #[test]
2085    fn test_validate_single_posting() {
2086        let directives = vec![
2087            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2088            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
2089                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2090            )),
2091        ];
2092
2093        let errors = validate(&directives);
2094        assert!(
2095            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2096            "Should warn for transaction with single posting: {errors:?}"
2097        );
2098        // Check it's a warning not error
2099        assert!(ErrorCode::SinglePosting.is_warning());
2100    }
2101
2102    #[test]
2103    fn test_validate_pad_without_balance() {
2104        let directives = vec![
2105            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2106            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2107            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2108            // No balance assertion follows!
2109        ];
2110
2111        let errors = validate(&directives);
2112        assert!(
2113            errors
2114                .iter()
2115                .any(|e| e.code == ErrorCode::PadWithoutBalance),
2116            "Should error for pad without subsequent balance: {errors:?}"
2117        );
2118    }
2119
2120    #[test]
2121    fn test_validate_multiple_pads_for_balance() {
2122        let directives = vec![
2123            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2124            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2125            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2126            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
2127            Directive::Balance(Balance::new(
2128                date(2024, 1, 3),
2129                "Assets:Bank",
2130                Amount::new(dec!(1000.00), "USD"),
2131            )),
2132        ];
2133
2134        let errors = validate(&directives);
2135        assert!(
2136            errors
2137                .iter()
2138                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2139            "Should error for multiple pads before balance: {errors:?}"
2140        );
2141    }
2142
2143    #[test]
2144    fn test_error_severity() {
2145        // Errors
2146        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2147        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2148        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2149
2150        // Warnings
2151        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2152        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2153        assert_eq!(
2154            ErrorCode::AccountCloseNotEmpty.severity(),
2155            Severity::Warning
2156        );
2157
2158        // Info
2159        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2160    }
2161
2162    #[test]
2163    fn test_validate_invalid_account_name() {
2164        // Test invalid root type
2165        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2166
2167        let errors = validate(&directives);
2168        assert!(
2169            errors
2170                .iter()
2171                .any(|e| e.code == ErrorCode::InvalidAccountName),
2172            "Should error for invalid account root: {errors:?}"
2173        );
2174    }
2175
2176    #[test]
2177    fn test_validate_account_lowercase_component() {
2178        // Test lowercase component (must start with uppercase or digit)
2179        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2180
2181        let errors = validate(&directives);
2182        assert!(
2183            errors
2184                .iter()
2185                .any(|e| e.code == ErrorCode::InvalidAccountName),
2186            "Should error for lowercase component: {errors:?}"
2187        );
2188    }
2189
2190    #[test]
2191    fn test_validate_valid_account_names() {
2192        // Valid account names should not error
2193        let valid_names = [
2194            "Assets:Bank",
2195            "Assets:Bank:Checking",
2196            "Liabilities:CreditCard",
2197            "Equity:Opening-Balances",
2198            "Income:Salary2024",
2199            "Expenses:Food:Restaurant",
2200            "Assets:401k",          // Component starting with digit
2201            "Assets:CORP✨",        // Emoji in component (beancount UTF-8-ONLY support)
2202            "Assets:沪深300",       // CJK characters
2203            "Assets:Café",          // Non-ASCII letter (é)
2204            "Assets:日本銀行",      // Full non-ASCII component
2205            "Assets:Test💰Account", // Emoji in middle
2206            "Assets:€uro",          // Currency symbol at start of component
2207        ];
2208
2209        for name in valid_names {
2210            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2211
2212            let errors = validate(&directives);
2213            let name_errors: Vec<_> = errors
2214                .iter()
2215                .filter(|e| e.code == ErrorCode::InvalidAccountName)
2216                .collect();
2217            assert!(
2218                name_errors.is_empty(),
2219                "Should accept valid account name '{name}': {name_errors:?}"
2220            );
2221        }
2222    }
2223}