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, Open, Pad,
53    Position, Posting, Transaction,
54};
55use std::collections::{HashMap, HashSet};
56use std::path::Path;
57use thiserror::Error;
58
59/// Validation error codes.
60///
61/// Error codes follow the spec in `spec/validation.md`.
62#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum ErrorCode {
64    // === Account Errors (E1xxx) ===
65    /// E1001: Account used before it was opened.
66    AccountNotOpen,
67    /// E1002: Account already open (duplicate open directive).
68    AccountAlreadyOpen,
69    /// E1003: Account used after it was closed.
70    AccountClosed,
71    /// E1004: Account close with non-zero balance.
72    AccountCloseNotEmpty,
73    /// E1005: Invalid account name.
74    InvalidAccountName,
75
76    // === Balance Errors (E2xxx) ===
77    /// E2001: Balance assertion failed.
78    BalanceAssertionFailed,
79    /// E2002: Balance exceeds explicit tolerance.
80    BalanceToleranceExceeded,
81    /// E2003: Pad without subsequent balance assertion.
82    PadWithoutBalance,
83    /// E2004: Multiple pads for same balance assertion.
84    MultiplePadForBalance,
85
86    // === Transaction Errors (E3xxx) ===
87    /// E3001: Transaction does not balance.
88    TransactionUnbalanced,
89    /// E3002: Multiple postings missing amounts for same currency.
90    MultipleInterpolation,
91    /// E3003: Transaction has no postings.
92    NoPostings,
93    /// E3004: Transaction has single posting (warning).
94    SinglePosting,
95
96    // === Booking Errors (E4xxx) ===
97    /// E4001: No matching lot for reduction.
98    NoMatchingLot,
99    /// E4002: Insufficient units in lot for reduction.
100    InsufficientUnits,
101    /// E4003: Ambiguous lot match in STRICT mode.
102    AmbiguousLotMatch,
103    /// E4004: Reduction would create negative inventory.
104    NegativeInventory,
105
106    // === Currency Errors (E5xxx) ===
107    /// E5001: Currency not declared (when strict mode enabled).
108    UndeclaredCurrency,
109    /// E5002: Currency not allowed in account.
110    CurrencyNotAllowed,
111
112    // === Metadata Errors (E6xxx) ===
113    /// E6001: Duplicate metadata key.
114    DuplicateMetadataKey,
115    /// E6002: Invalid metadata value type.
116    InvalidMetadataValue,
117
118    // === Option Errors (E7xxx) ===
119    /// E7001: Unknown option name.
120    UnknownOption,
121    /// E7002: Invalid option value.
122    InvalidOptionValue,
123    /// E7003: Duplicate non-repeatable option.
124    DuplicateOption,
125
126    // === Document Errors (E8xxx) ===
127    /// E8001: Document file not found.
128    DocumentNotFound,
129
130    // === Date Errors (E10xxx) ===
131    /// E10001: Date out of order (info only).
132    DateOutOfOrder,
133    /// E10002: Entry dated in the future (warning).
134    FutureDate,
135}
136
137impl ErrorCode {
138    /// Get the error code string (e.g., "E1001").
139    #[must_use]
140    pub const fn code(&self) -> &'static str {
141        match self {
142            // Account errors
143            Self::AccountNotOpen => "E1001",
144            Self::AccountAlreadyOpen => "E1002",
145            Self::AccountClosed => "E1003",
146            Self::AccountCloseNotEmpty => "E1004",
147            Self::InvalidAccountName => "E1005",
148            // Balance errors
149            Self::BalanceAssertionFailed => "E2001",
150            Self::BalanceToleranceExceeded => "E2002",
151            Self::PadWithoutBalance => "E2003",
152            Self::MultiplePadForBalance => "E2004",
153            // Transaction errors
154            Self::TransactionUnbalanced => "E3001",
155            Self::MultipleInterpolation => "E3002",
156            Self::NoPostings => "E3003",
157            Self::SinglePosting => "E3004",
158            // Booking errors
159            Self::NoMatchingLot => "E4001",
160            Self::InsufficientUnits => "E4002",
161            Self::AmbiguousLotMatch => "E4003",
162            Self::NegativeInventory => "E4004",
163            // Currency errors
164            Self::UndeclaredCurrency => "E5001",
165            Self::CurrencyNotAllowed => "E5002",
166            // Metadata errors
167            Self::DuplicateMetadataKey => "E6001",
168            Self::InvalidMetadataValue => "E6002",
169            // Option errors
170            Self::UnknownOption => "E7001",
171            Self::InvalidOptionValue => "E7002",
172            Self::DuplicateOption => "E7003",
173            // Document errors
174            Self::DocumentNotFound => "E8001",
175            // Date errors
176            Self::DateOutOfOrder => "E10001",
177            Self::FutureDate => "E10002",
178        }
179    }
180
181    /// Check if this is a warning (not an error).
182    #[must_use]
183    pub const fn is_warning(&self) -> bool {
184        matches!(
185            self,
186            Self::FutureDate
187                | Self::SinglePosting
188                | Self::AccountCloseNotEmpty
189                | Self::DateOutOfOrder
190        )
191    }
192
193    /// Check if this is just informational.
194    #[must_use]
195    pub const fn is_info(&self) -> bool {
196        matches!(self, Self::DateOutOfOrder)
197    }
198
199    /// Get the severity level.
200    #[must_use]
201    pub const fn severity(&self) -> Severity {
202        if self.is_info() {
203            Severity::Info
204        } else if self.is_warning() {
205            Severity::Warning
206        } else {
207            Severity::Error
208        }
209    }
210}
211
212/// Severity level for validation messages.
213#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
214pub enum Severity {
215    /// Ledger is invalid.
216    Error,
217    /// Suspicious but valid.
218    Warning,
219    /// Informational only.
220    Info,
221}
222
223impl std::fmt::Display for ErrorCode {
224    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225        write!(f, "{}", self.code())
226    }
227}
228
229/// A validation error.
230#[derive(Debug, Clone, Error)]
231#[error("[{code}] {message}")]
232pub struct ValidationError {
233    /// Error code.
234    pub code: ErrorCode,
235    /// Error message.
236    pub message: String,
237    /// Date of the directive that caused the error.
238    pub date: NaiveDate,
239    /// Additional context.
240    pub context: Option<String>,
241}
242
243impl ValidationError {
244    /// Create a new validation error.
245    #[must_use]
246    pub fn new(code: ErrorCode, message: impl Into<String>, date: NaiveDate) -> Self {
247        Self {
248            code,
249            message: message.into(),
250            date,
251            context: None,
252        }
253    }
254
255    /// Add context to this error.
256    #[must_use]
257    pub fn with_context(mut self, context: impl Into<String>) -> Self {
258        self.context = Some(context.into());
259        self
260    }
261}
262
263/// Account state for tracking lifecycle.
264#[derive(Debug, Clone)]
265struct AccountState {
266    /// Date opened.
267    opened: NaiveDate,
268    /// Date closed (if closed).
269    closed: Option<NaiveDate>,
270    /// Allowed currencies (empty = any).
271    currencies: HashSet<InternedStr>,
272    /// Booking method (stored for future use in booking validation).
273    #[allow(dead_code)]
274    booking: BookingMethod,
275}
276
277/// Validation options.
278#[derive(Debug, Clone, Default)]
279pub struct ValidationOptions {
280    /// Whether to require commodity declarations.
281    pub require_commodities: bool,
282    /// Whether to check if document files exist.
283    pub check_documents: bool,
284    /// Whether to warn about future-dated entries.
285    pub warn_future_dates: bool,
286    /// Base directory for resolving relative document paths.
287    pub document_base: Option<std::path::PathBuf>,
288}
289
290/// Pending pad directive info.
291#[derive(Debug, Clone)]
292struct PendingPad {
293    /// Source account for padding.
294    source_account: InternedStr,
295    /// Date of the pad directive.
296    date: NaiveDate,
297    /// Whether this pad has been used (has at least one balance assertion).
298    used: bool,
299}
300
301/// Ledger state for validation.
302#[derive(Debug, Default)]
303pub struct LedgerState {
304    /// Account states.
305    accounts: HashMap<InternedStr, AccountState>,
306    /// Account inventories.
307    inventories: HashMap<InternedStr, Inventory>,
308    /// Declared commodities.
309    commodities: HashSet<InternedStr>,
310    /// Pending pad directives (account -> list of pads).
311    pending_pads: HashMap<InternedStr, Vec<PendingPad>>,
312    /// Validation options.
313    options: ValidationOptions,
314    /// Track previous directive date for out-of-order detection.
315    last_date: Option<NaiveDate>,
316}
317
318impl LedgerState {
319    /// Create a new ledger state.
320    #[must_use]
321    pub fn new() -> Self {
322        Self::default()
323    }
324
325    /// Create a new ledger state with options.
326    #[must_use]
327    pub fn with_options(options: ValidationOptions) -> Self {
328        Self {
329            options,
330            ..Default::default()
331        }
332    }
333
334    /// Set whether to require commodity declarations.
335    pub fn set_require_commodities(&mut self, require: bool) {
336        self.options.require_commodities = require;
337    }
338
339    /// Set whether to check document files.
340    pub fn set_check_documents(&mut self, check: bool) {
341        self.options.check_documents = check;
342    }
343
344    /// Set whether to warn about future dates.
345    pub fn set_warn_future_dates(&mut self, warn: bool) {
346        self.options.warn_future_dates = warn;
347    }
348
349    /// Set the document base directory.
350    pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
351        self.options.document_base = Some(base.into());
352    }
353
354    /// Get the inventory for an account.
355    #[must_use]
356    pub fn inventory(&self, account: &str) -> Option<&Inventory> {
357        self.inventories.get(account)
358    }
359
360    /// Get all account names.
361    pub fn accounts(&self) -> impl Iterator<Item = &str> {
362        self.accounts.keys().map(InternedStr::as_str)
363    }
364}
365
366/// Validate a stream of directives.
367///
368/// Returns a list of validation errors found.
369pub fn validate(directives: &[Directive]) -> Vec<ValidationError> {
370    validate_with_options(directives, ValidationOptions::default())
371}
372
373/// Validate a stream of directives with custom options.
374///
375/// Returns a list of validation errors and warnings found.
376pub fn validate_with_options(
377    directives: &[Directive],
378    options: ValidationOptions,
379) -> Vec<ValidationError> {
380    let mut state = LedgerState::with_options(options);
381    let mut errors = Vec::new();
382
383    let today = Local::now().date_naive();
384
385    // Sort directives by date, then by type priority (parallel)
386    // (e.g., balance assertions before transactions on the same day)
387    let mut sorted: Vec<&Directive> = directives.iter().collect();
388    sorted.par_sort_by(|a, b| {
389        a.date()
390            .cmp(&b.date())
391            .then_with(|| a.priority().cmp(&b.priority()))
392    });
393
394    for directive in sorted {
395        let date = directive.date();
396
397        // Check for date ordering (info only - we sort anyway)
398        if let Some(last) = state.last_date {
399            if date < last {
400                errors.push(ValidationError::new(
401                    ErrorCode::DateOutOfOrder,
402                    format!("Directive date {date} is before previous directive {last}"),
403                    date,
404                ));
405            }
406        }
407        state.last_date = Some(date);
408
409        // Check for future dates if enabled
410        if state.options.warn_future_dates && date > today {
411            errors.push(ValidationError::new(
412                ErrorCode::FutureDate,
413                format!("Entry dated in the future: {date}"),
414                date,
415            ));
416        }
417
418        match directive {
419            Directive::Open(open) => {
420                validate_open(&mut state, open, &mut errors);
421            }
422            Directive::Close(close) => {
423                validate_close(&mut state, close, &mut errors);
424            }
425            Directive::Transaction(txn) => {
426                validate_transaction(&mut state, txn, &mut errors);
427            }
428            Directive::Balance(bal) => {
429                validate_balance(&mut state, bal, &mut errors);
430            }
431            Directive::Commodity(comm) => {
432                state.commodities.insert(comm.currency.clone());
433            }
434            Directive::Pad(pad) => {
435                validate_pad(&mut state, pad, &mut errors);
436            }
437            Directive::Document(doc) => {
438                validate_document(&state, doc, &mut errors);
439            }
440            _ => {}
441        }
442    }
443
444    // Check for unused pads (E2003)
445    for (account, pads) in &state.pending_pads {
446        for pad in pads {
447            if !pad.used {
448                errors.push(
449                    ValidationError::new(
450                        ErrorCode::PadWithoutBalance,
451                        format!("Pad directive for {account} has no subsequent balance assertion"),
452                        pad.date,
453                    )
454                    .with_context(format!("source account: {}", pad.source_account)),
455                );
456            }
457        }
458    }
459
460    errors
461}
462
463/// Valid account root types in beancount.
464const VALID_ACCOUNT_ROOTS: &[&str] = &["Assets", "Liabilities", "Equity", "Income", "Expenses"];
465
466/// Validate an account name according to beancount rules.
467/// Returns None if valid, or Some(reason) if invalid.
468fn validate_account_name(account: &str) -> Option<String> {
469    if account.is_empty() {
470        return Some("account name is empty".to_string());
471    }
472
473    let parts: Vec<&str> = account.split(':').collect();
474    if parts.is_empty() {
475        return Some("account name has no components".to_string());
476    }
477
478    // Check root account type
479    let root = parts[0];
480    if !VALID_ACCOUNT_ROOTS.contains(&root) {
481        return Some(format!(
482            "account must start with one of: {}",
483            VALID_ACCOUNT_ROOTS.join(", ")
484        ));
485    }
486
487    // Check each component
488    for (i, part) in parts.iter().enumerate() {
489        if part.is_empty() {
490            return Some(format!("component {} is empty", i + 1));
491        }
492
493        // First character must be uppercase letter or digit
494        // Safety: we just checked part.is_empty() above, so this is guaranteed to succeed
495        let Some(first_char) = part.chars().next() else {
496            // This branch is unreachable due to the is_empty check above,
497            // but we handle it defensively to avoid unwrap
498            return Some(format!("component {} is empty", i + 1));
499        };
500        if !first_char.is_ascii_uppercase() && !first_char.is_ascii_digit() {
501            return Some(format!(
502                "component '{part}' must start with uppercase letter or digit"
503            ));
504        }
505
506        // Remaining characters: letters, numbers, dashes
507        for c in part.chars().skip(1) {
508            if !c.is_ascii_alphanumeric() && c != '-' {
509                return Some(format!(
510                    "component '{part}' contains invalid character '{c}'"
511                ));
512            }
513        }
514    }
515
516    None // Valid
517}
518
519fn validate_open(state: &mut LedgerState, open: &Open, errors: &mut Vec<ValidationError>) {
520    // Validate account name format
521    if let Some(reason) = validate_account_name(&open.account) {
522        errors.push(
523            ValidationError::new(
524                ErrorCode::InvalidAccountName,
525                format!("Invalid account name \"{}\": {}", open.account, reason),
526                open.date,
527            )
528            .with_context(open.account.to_string()),
529        );
530        // Continue anyway to allow further validation
531    }
532
533    // Check if already open
534    if let Some(existing) = state.accounts.get(&open.account) {
535        errors.push(ValidationError::new(
536            ErrorCode::AccountAlreadyOpen,
537            format!(
538                "Account {} is already open (opened on {})",
539                open.account, existing.opened
540            ),
541            open.date,
542        ));
543        return;
544    }
545
546    let booking = open
547        .booking
548        .as_ref()
549        .and_then(|b| b.parse::<BookingMethod>().ok())
550        .unwrap_or_default();
551
552    state.accounts.insert(
553        open.account.clone(),
554        AccountState {
555            opened: open.date,
556            closed: None,
557            currencies: open.currencies.iter().cloned().collect(),
558            booking,
559        },
560    );
561
562    state
563        .inventories
564        .insert(open.account.clone(), Inventory::new());
565}
566
567fn validate_close(state: &mut LedgerState, close: &Close, errors: &mut Vec<ValidationError>) {
568    match state.accounts.get_mut(&close.account) {
569        Some(account_state) => {
570            if account_state.closed.is_some() {
571                errors.push(ValidationError::new(
572                    ErrorCode::AccountClosed,
573                    format!("Account {} already closed", close.account),
574                    close.date,
575                ));
576            } else {
577                // Check if account has non-zero balance (warning)
578                if let Some(inv) = state.inventories.get(&close.account) {
579                    if !inv.is_empty() {
580                        let positions: Vec<String> = inv
581                            .positions()
582                            .iter()
583                            .map(|p| format!("{} {}", p.units.number, p.units.currency))
584                            .collect();
585                        errors.push(
586                            ValidationError::new(
587                                ErrorCode::AccountCloseNotEmpty,
588                                format!(
589                                    "Cannot close account {} with non-zero balance",
590                                    close.account
591                                ),
592                                close.date,
593                            )
594                            .with_context(format!("balance: {}", positions.join(", "))),
595                        );
596                    }
597                }
598                account_state.closed = Some(close.date);
599            }
600        }
601        None => {
602            errors.push(ValidationError::new(
603                ErrorCode::AccountNotOpen,
604                format!("Account {} was never opened", close.account),
605                close.date,
606            ));
607        }
608    }
609}
610
611fn validate_transaction(
612    state: &mut LedgerState,
613    txn: &Transaction,
614    errors: &mut Vec<ValidationError>,
615) {
616    // Check transaction structure
617    if !validate_transaction_structure(txn, errors) {
618        return; // No point checking further if no postings
619    }
620
621    // Check each posting's account lifecycle and currency constraints
622    validate_posting_accounts(state, txn, errors);
623
624    // Check transaction balance
625    validate_transaction_balance(txn, errors);
626
627    // Update inventories with booking validation
628    update_inventories(state, txn, errors);
629}
630
631/// Validate transaction structure (must have postings).
632/// Returns false if validation should stop (no postings).
633fn validate_transaction_structure(txn: &Transaction, errors: &mut Vec<ValidationError>) -> bool {
634    if txn.postings.is_empty() {
635        errors.push(ValidationError::new(
636            ErrorCode::NoPostings,
637            "Transaction must have at least one posting".to_string(),
638            txn.date,
639        ));
640        return false;
641    }
642
643    if txn.postings.len() == 1 {
644        errors.push(ValidationError::new(
645            ErrorCode::SinglePosting,
646            "Transaction has only one posting".to_string(),
647            txn.date,
648        ));
649        // Continue validation - this is just a warning
650    }
651
652    true
653}
654
655/// Validate account lifecycle and currency constraints for each posting.
656fn validate_posting_accounts(
657    state: &LedgerState,
658    txn: &Transaction,
659    errors: &mut Vec<ValidationError>,
660) {
661    for posting in &txn.postings {
662        match state.accounts.get(&posting.account) {
663            Some(account_state) => {
664                validate_account_lifecycle(txn, posting, account_state, errors);
665                validate_posting_currency(state, txn, posting, account_state, errors);
666            }
667            None => {
668                errors.push(ValidationError::new(
669                    ErrorCode::AccountNotOpen,
670                    format!("Account {} was never opened", posting.account),
671                    txn.date,
672                ));
673            }
674        }
675    }
676}
677
678/// Validate that an account is open at transaction time and not closed.
679fn validate_account_lifecycle(
680    txn: &Transaction,
681    posting: &Posting,
682    account_state: &AccountState,
683    errors: &mut Vec<ValidationError>,
684) {
685    if txn.date < account_state.opened {
686        errors.push(ValidationError::new(
687            ErrorCode::AccountNotOpen,
688            format!(
689                "Account {} used on {} but not opened until {}",
690                posting.account, txn.date, account_state.opened
691            ),
692            txn.date,
693        ));
694    }
695
696    if let Some(closed) = account_state.closed {
697        if txn.date >= closed {
698            errors.push(ValidationError::new(
699                ErrorCode::AccountClosed,
700                format!(
701                    "Account {} used on {} but was closed on {}",
702                    posting.account, txn.date, closed
703                ),
704                txn.date,
705            ));
706        }
707    }
708}
709
710/// Validate currency constraints and commodity declarations for a posting.
711fn validate_posting_currency(
712    state: &LedgerState,
713    txn: &Transaction,
714    posting: &Posting,
715    account_state: &AccountState,
716    errors: &mut Vec<ValidationError>,
717) {
718    let Some(units) = posting.amount() else {
719        return;
720    };
721
722    // Check currency constraints
723    if !account_state.currencies.is_empty() && !account_state.currencies.contains(&units.currency) {
724        errors.push(ValidationError::new(
725            ErrorCode::CurrencyNotAllowed,
726            format!(
727                "Currency {} not allowed in account {}",
728                units.currency, posting.account
729            ),
730            txn.date,
731        ));
732    }
733
734    // Check commodity declaration
735    if state.options.require_commodities && !state.commodities.contains(&units.currency) {
736        errors.push(ValidationError::new(
737            ErrorCode::UndeclaredCurrency,
738            format!("Currency {} not declared", units.currency),
739            txn.date,
740        ));
741    }
742}
743
744/// Validate that the transaction balances within tolerance.
745fn validate_transaction_balance(txn: &Transaction, errors: &mut Vec<ValidationError>) {
746    let residuals = rustledger_booking::calculate_residual(txn);
747    for (currency, residual) in residuals {
748        // Use a default tolerance of 0.005 for now
749        if residual.abs() > Decimal::new(5, 3) {
750            errors.push(ValidationError::new(
751                ErrorCode::TransactionUnbalanced,
752                format!("Transaction does not balance: residual {residual} {currency}"),
753                txn.date,
754            ));
755        }
756    }
757}
758
759/// Update inventories with booking validation for each posting.
760fn update_inventories(
761    state: &mut LedgerState,
762    txn: &Transaction,
763    errors: &mut Vec<ValidationError>,
764) {
765    for posting in &txn.postings {
766        let Some(units) = posting.amount() else {
767            continue;
768        };
769        let Some(inv) = state.inventories.get_mut(&posting.account) else {
770            continue;
771        };
772
773        let booking_method = state
774            .accounts
775            .get(&posting.account)
776            .map(|a| a.booking)
777            .unwrap_or_default();
778
779        let is_reduction = units.number.is_sign_negative() && posting.cost.is_some();
780
781        if is_reduction {
782            process_inventory_reduction(inv, posting, units, booking_method, txn, errors);
783        } else {
784            process_inventory_addition(inv, posting, units, txn);
785        }
786    }
787}
788
789/// Process an inventory reduction (selling/removing units).
790fn process_inventory_reduction(
791    inv: &mut Inventory,
792    posting: &Posting,
793    units: &Amount,
794    booking_method: BookingMethod,
795    txn: &Transaction,
796    errors: &mut Vec<ValidationError>,
797) {
798    match inv.reduce(units, posting.cost.as_ref(), booking_method) {
799        Ok(_) => {}
800        Err(rustledger_core::BookingError::InsufficientUnits {
801            requested,
802            available,
803            ..
804        }) => {
805            errors.push(
806                ValidationError::new(
807                    ErrorCode::InsufficientUnits,
808                    format!(
809                        "Insufficient units in {}: requested {}, available {}",
810                        posting.account, requested, available
811                    ),
812                    txn.date,
813                )
814                .with_context(format!("currency: {}", units.currency)),
815            );
816        }
817        Err(rustledger_core::BookingError::NoMatchingLot { currency, .. }) => {
818            errors.push(
819                ValidationError::new(
820                    ErrorCode::NoMatchingLot,
821                    format!("No matching lot for {} in {}", currency, posting.account),
822                    txn.date,
823                )
824                .with_context(format!("cost spec: {:?}", posting.cost)),
825            );
826        }
827        Err(rustledger_core::BookingError::AmbiguousMatch {
828            currency,
829            num_matches,
830        }) => {
831            errors.push(
832                ValidationError::new(
833                    ErrorCode::AmbiguousLotMatch,
834                    format!(
835                        "Ambiguous lot match for {}: {} lots match in {}",
836                        currency, num_matches, posting.account
837                    ),
838                    txn.date,
839                )
840                .with_context("Specify cost, date, or label to disambiguate".to_string()),
841            );
842        }
843        Err(rustledger_core::BookingError::CurrencyMismatch { .. }) => {
844            // This shouldn't happen in normal validation
845        }
846    }
847}
848
849/// Process an inventory addition (buying/adding units).
850fn process_inventory_addition(
851    inv: &mut Inventory,
852    posting: &Posting,
853    units: &Amount,
854    txn: &Transaction,
855) {
856    let position = if let Some(cost_spec) = &posting.cost {
857        if let Some(cost) = cost_spec.resolve(units.number, txn.date) {
858            rustledger_core::Position::with_cost(units.clone(), cost)
859        } else {
860            rustledger_core::Position::simple(units.clone())
861        }
862    } else {
863        rustledger_core::Position::simple(units.clone())
864    };
865
866    inv.add(position);
867}
868
869fn validate_pad(state: &mut LedgerState, pad: &Pad, errors: &mut Vec<ValidationError>) {
870    // Check that the target account exists
871    if !state.accounts.contains_key(&pad.account) {
872        errors.push(ValidationError::new(
873            ErrorCode::AccountNotOpen,
874            format!("Pad target account {} was never opened", pad.account),
875            pad.date,
876        ));
877        return;
878    }
879
880    // Check that the source account exists
881    if !state.accounts.contains_key(&pad.source_account) {
882        errors.push(ValidationError::new(
883            ErrorCode::AccountNotOpen,
884            format!("Pad source account {} was never opened", pad.source_account),
885            pad.date,
886        ));
887        return;
888    }
889
890    // Add to pending pads list for this account
891    let pending_pad = PendingPad {
892        source_account: pad.source_account.clone(),
893        date: pad.date,
894        used: false,
895    };
896    state
897        .pending_pads
898        .entry(pad.account.clone())
899        .or_default()
900        .push(pending_pad);
901}
902
903fn validate_balance(state: &mut LedgerState, bal: &Balance, errors: &mut Vec<ValidationError>) {
904    // Check account exists
905    if !state.accounts.contains_key(&bal.account) {
906        errors.push(ValidationError::new(
907            ErrorCode::AccountNotOpen,
908            format!("Account {} was never opened", bal.account),
909            bal.date,
910        ));
911        return;
912    }
913
914    // Check if there are pending pads for this account
915    // Use get_mut instead of remove - a pad can apply to multiple currencies
916    if let Some(pending_pads) = state.pending_pads.get_mut(&bal.account) {
917        // Check for multiple pads (E2004) - only warn if none have been used yet
918        if pending_pads.len() > 1 && !pending_pads.iter().any(|p| p.used) {
919            errors.push(
920                ValidationError::new(
921                    ErrorCode::MultiplePadForBalance,
922                    format!(
923                        "Multiple pad directives for {} {} before balance assertion",
924                        bal.account, bal.amount.currency
925                    ),
926                    bal.date,
927                )
928                .with_context(format!(
929                    "pad dates: {}",
930                    pending_pads
931                        .iter()
932                        .map(|p| p.date.to_string())
933                        .collect::<Vec<_>>()
934                        .join(", ")
935                )),
936            );
937        }
938
939        // Use the most recent pad
940        if let Some(pending_pad) = pending_pads.last_mut() {
941            // Apply padding: calculate difference and add to both accounts
942            if let Some(inv) = state.inventories.get(&bal.account) {
943                let actual = inv.units(&bal.amount.currency);
944                let expected = bal.amount.number;
945                let difference = expected - actual;
946
947                if difference != Decimal::ZERO {
948                    // Add padding amount to target account
949                    if let Some(target_inv) = state.inventories.get_mut(&bal.account) {
950                        target_inv.add(Position::simple(Amount::new(
951                            difference,
952                            &bal.amount.currency,
953                        )));
954                    }
955
956                    // Subtract padding amount from source account
957                    if let Some(source_inv) = state.inventories.get_mut(&pending_pad.source_account)
958                    {
959                        source_inv.add(Position::simple(Amount::new(
960                            -difference,
961                            &bal.amount.currency,
962                        )));
963                    }
964                }
965            }
966            // Mark pad as used
967            pending_pad.used = true;
968        }
969        // After padding, the balance should match (no error needed)
970        return;
971    }
972
973    // Get inventory and check balance (no padding case)
974    if let Some(inv) = state.inventories.get(&bal.account) {
975        let actual = inv.units(&bal.amount.currency);
976        let expected = bal.amount.number;
977        let difference = (actual - expected).abs();
978
979        // Determine tolerance and whether it was explicitly specified
980        let (tolerance, is_explicit) = if let Some(t) = bal.tolerance {
981            (t, true)
982        } else {
983            (bal.amount.inferred_tolerance(), false)
984        };
985
986        if difference > tolerance {
987            // Use E2002 for explicit tolerance, E2001 for inferred
988            let error_code = if is_explicit {
989                ErrorCode::BalanceToleranceExceeded
990            } else {
991                ErrorCode::BalanceAssertionFailed
992            };
993
994            let message = if is_explicit {
995                format!(
996                    "Balance exceeds explicit tolerance for {}: expected {} {} ~ {}, got {} {} (difference: {})",
997                    bal.account, expected, bal.amount.currency, tolerance, actual, bal.amount.currency, difference
998                )
999            } else {
1000                format!(
1001                    "Balance assertion failed for {}: expected {} {}, got {} {}",
1002                    bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1003                )
1004            };
1005
1006            errors.push(
1007                ValidationError::new(error_code, message, bal.date)
1008                    .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1009            );
1010        }
1011    }
1012}
1013
1014fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1015    // Check account exists
1016    if !state.accounts.contains_key(&doc.account) {
1017        errors.push(ValidationError::new(
1018            ErrorCode::AccountNotOpen,
1019            format!("Account {} was never opened", doc.account),
1020            doc.date,
1021        ));
1022    }
1023
1024    // Check if document file exists (if enabled)
1025    if state.options.check_documents {
1026        let doc_path = Path::new(&doc.path);
1027
1028        let full_path = if doc_path.is_absolute() {
1029            doc_path.to_path_buf()
1030        } else if let Some(base) = &state.options.document_base {
1031            base.join(doc_path)
1032        } else {
1033            doc_path.to_path_buf()
1034        };
1035
1036        if !full_path.exists() {
1037            errors.push(
1038                ValidationError::new(
1039                    ErrorCode::DocumentNotFound,
1040                    format!("Document file not found: {}", doc.path),
1041                    doc.date,
1042                )
1043                .with_context(format!("resolved path: {}", full_path.display())),
1044            );
1045        }
1046    }
1047}
1048
1049#[cfg(test)]
1050mod tests {
1051    use super::*;
1052    use rust_decimal_macros::dec;
1053    use rustledger_core::{Amount, NaiveDate, Posting};
1054
1055    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1056        NaiveDate::from_ymd_opt(year, month, day).unwrap()
1057    }
1058
1059    #[test]
1060    fn test_validate_account_lifecycle() {
1061        let directives = vec![
1062            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1063            Directive::Transaction(
1064                Transaction::new(date(2024, 1, 15), "Test")
1065                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1066                    .with_posting(Posting::new(
1067                        "Income:Salary",
1068                        Amount::new(dec!(-100), "USD"),
1069                    )),
1070            ),
1071        ];
1072
1073        let errors = validate(&directives);
1074
1075        // Should have error: Income:Salary not opened
1076        assert!(errors
1077            .iter()
1078            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1079    }
1080
1081    #[test]
1082    fn test_validate_account_used_before_open() {
1083        let directives = vec![
1084            Directive::Transaction(
1085                Transaction::new(date(2024, 1, 1), "Test")
1086                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1087                    .with_posting(Posting::new(
1088                        "Income:Salary",
1089                        Amount::new(dec!(-100), "USD"),
1090                    )),
1091            ),
1092            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1093        ];
1094
1095        let errors = validate(&directives);
1096
1097        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1098    }
1099
1100    #[test]
1101    fn test_validate_account_used_after_close() {
1102        let directives = vec![
1103            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1104            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1105            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1106            Directive::Transaction(
1107                Transaction::new(date(2024, 7, 1), "Test")
1108                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1109                    .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1110            ),
1111        ];
1112
1113        let errors = validate(&directives);
1114
1115        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1116    }
1117
1118    #[test]
1119    fn test_validate_balance_assertion() {
1120        let directives = vec![
1121            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1122            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1123            Directive::Transaction(
1124                Transaction::new(date(2024, 1, 15), "Deposit")
1125                    .with_posting(Posting::new(
1126                        "Assets:Bank",
1127                        Amount::new(dec!(1000.00), "USD"),
1128                    ))
1129                    .with_posting(Posting::new(
1130                        "Income:Salary",
1131                        Amount::new(dec!(-1000.00), "USD"),
1132                    )),
1133            ),
1134            Directive::Balance(Balance::new(
1135                date(2024, 1, 16),
1136                "Assets:Bank",
1137                Amount::new(dec!(1000.00), "USD"),
1138            )),
1139        ];
1140
1141        let errors = validate(&directives);
1142        assert!(errors.is_empty(), "{errors:?}");
1143    }
1144
1145    #[test]
1146    fn test_validate_balance_assertion_failed() {
1147        let directives = vec![
1148            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1149            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1150            Directive::Transaction(
1151                Transaction::new(date(2024, 1, 15), "Deposit")
1152                    .with_posting(Posting::new(
1153                        "Assets:Bank",
1154                        Amount::new(dec!(1000.00), "USD"),
1155                    ))
1156                    .with_posting(Posting::new(
1157                        "Income:Salary",
1158                        Amount::new(dec!(-1000.00), "USD"),
1159                    )),
1160            ),
1161            Directive::Balance(Balance::new(
1162                date(2024, 1, 16),
1163                "Assets:Bank",
1164                Amount::new(dec!(500.00), "USD"), // Wrong!
1165            )),
1166        ];
1167
1168        let errors = validate(&directives);
1169        assert!(errors
1170            .iter()
1171            .any(|e| e.code == ErrorCode::BalanceAssertionFailed));
1172    }
1173
1174    #[test]
1175    fn test_validate_unbalanced_transaction() {
1176        let directives = vec![
1177            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1178            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1179            Directive::Transaction(
1180                Transaction::new(date(2024, 1, 15), "Unbalanced")
1181                    .with_posting(Posting::new(
1182                        "Assets:Bank",
1183                        Amount::new(dec!(-50.00), "USD"),
1184                    ))
1185                    .with_posting(Posting::new(
1186                        "Expenses:Food",
1187                        Amount::new(dec!(40.00), "USD"),
1188                    )), // Missing $10
1189            ),
1190        ];
1191
1192        let errors = validate(&directives);
1193        assert!(errors
1194            .iter()
1195            .any(|e| e.code == ErrorCode::TransactionUnbalanced));
1196    }
1197
1198    #[test]
1199    fn test_validate_currency_not_allowed() {
1200        let directives = vec![
1201            Directive::Open(
1202                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1203            ),
1204            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1205            Directive::Transaction(
1206                Transaction::new(date(2024, 1, 15), "Test")
1207                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
1208                    .with_posting(Posting::new(
1209                        "Income:Salary",
1210                        Amount::new(dec!(-100.00), "EUR"),
1211                    )),
1212            ),
1213        ];
1214
1215        let errors = validate(&directives);
1216        assert!(errors
1217            .iter()
1218            .any(|e| e.code == ErrorCode::CurrencyNotAllowed));
1219    }
1220
1221    #[test]
1222    fn test_validate_future_date_warning() {
1223        // Create a date in the future
1224        let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1225
1226        let directives = vec![Directive::Open(Open {
1227            date: future_date,
1228            account: "Assets:Bank".into(),
1229            currencies: vec![],
1230            booking: None,
1231            meta: Default::default(),
1232        })];
1233
1234        // Without warn_future_dates option, no warnings
1235        let errors = validate(&directives);
1236        assert!(
1237            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1238            "Should not warn about future dates by default"
1239        );
1240
1241        // With warn_future_dates option, should warn
1242        let options = ValidationOptions {
1243            warn_future_dates: true,
1244            ..Default::default()
1245        };
1246        let errors = validate_with_options(&directives, options);
1247        assert!(
1248            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1249            "Should warn about future dates when enabled"
1250        );
1251    }
1252
1253    #[test]
1254    fn test_validate_document_not_found() {
1255        let directives = vec![
1256            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1257            Directive::Document(Document {
1258                date: date(2024, 1, 15),
1259                account: "Assets:Bank".into(),
1260                path: "/nonexistent/path/to/document.pdf".to_string(),
1261                tags: vec![],
1262                links: vec![],
1263                meta: Default::default(),
1264            }),
1265        ];
1266
1267        // Without check_documents option, no error
1268        let errors = validate(&directives);
1269        assert!(
1270            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1271            "Should not check documents by default"
1272        );
1273
1274        // With check_documents option, should error
1275        let options = ValidationOptions {
1276            check_documents: true,
1277            ..Default::default()
1278        };
1279        let errors = validate_with_options(&directives, options);
1280        assert!(
1281            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1282            "Should report missing document when enabled"
1283        );
1284    }
1285
1286    #[test]
1287    fn test_validate_document_account_not_open() {
1288        let directives = vec![Directive::Document(Document {
1289            date: date(2024, 1, 15),
1290            account: "Assets:Unknown".into(),
1291            path: "receipt.pdf".to_string(),
1292            tags: vec![],
1293            links: vec![],
1294            meta: Default::default(),
1295        })];
1296
1297        let errors = validate(&directives);
1298        assert!(
1299            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1300            "Should error for document on unopened account"
1301        );
1302    }
1303
1304    #[test]
1305    fn test_error_code_is_warning() {
1306        assert!(!ErrorCode::AccountNotOpen.is_warning());
1307        assert!(!ErrorCode::DocumentNotFound.is_warning());
1308        assert!(ErrorCode::FutureDate.is_warning());
1309    }
1310
1311    #[test]
1312    fn test_validate_pad_basic() {
1313        let directives = vec![
1314            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1315            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1316            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1317            Directive::Balance(Balance::new(
1318                date(2024, 1, 2),
1319                "Assets:Bank",
1320                Amount::new(dec!(1000.00), "USD"),
1321            )),
1322        ];
1323
1324        let errors = validate(&directives);
1325        // Should have no errors - pad should satisfy the balance
1326        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1327    }
1328
1329    #[test]
1330    fn test_validate_pad_with_existing_balance() {
1331        let directives = vec![
1332            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1333            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1334            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1335            // Add some initial transactions
1336            Directive::Transaction(
1337                Transaction::new(date(2024, 1, 5), "Initial deposit")
1338                    .with_posting(Posting::new(
1339                        "Assets:Bank",
1340                        Amount::new(dec!(500.00), "USD"),
1341                    ))
1342                    .with_posting(Posting::new(
1343                        "Income:Salary",
1344                        Amount::new(dec!(-500.00), "USD"),
1345                    )),
1346            ),
1347            // Pad to reach the target balance
1348            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1349            Directive::Balance(Balance::new(
1350                date(2024, 1, 15),
1351                "Assets:Bank",
1352                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
1353            )),
1354        ];
1355
1356        let errors = validate(&directives);
1357        // Should have no errors - pad should add the missing 500
1358        assert!(
1359            errors.is_empty(),
1360            "Pad should add missing amount: {errors:?}"
1361        );
1362    }
1363
1364    #[test]
1365    fn test_validate_pad_account_not_open() {
1366        let directives = vec![
1367            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1368            // Assets:Bank not opened
1369            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1370        ];
1371
1372        let errors = validate(&directives);
1373        assert!(
1374            errors
1375                .iter()
1376                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1377            "Should error for pad on unopened account"
1378        );
1379    }
1380
1381    #[test]
1382    fn test_validate_pad_source_not_open() {
1383        let directives = vec![
1384            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1385            // Equity:Opening not opened
1386            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1387        ];
1388
1389        let errors = validate(&directives);
1390        assert!(
1391            errors.iter().any(
1392                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1393            ),
1394            "Should error for pad with unopened source account"
1395        );
1396    }
1397
1398    #[test]
1399    fn test_validate_pad_negative_adjustment() {
1400        // Test that pad can reduce a balance too
1401        let directives = vec![
1402            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1403            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1404            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1405            // Add more than needed
1406            Directive::Transaction(
1407                Transaction::new(date(2024, 1, 5), "Big deposit")
1408                    .with_posting(Posting::new(
1409                        "Assets:Bank",
1410                        Amount::new(dec!(2000.00), "USD"),
1411                    ))
1412                    .with_posting(Posting::new(
1413                        "Income:Salary",
1414                        Amount::new(dec!(-2000.00), "USD"),
1415                    )),
1416            ),
1417            // Pad to reach a lower target
1418            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1419            Directive::Balance(Balance::new(
1420                date(2024, 1, 15),
1421                "Assets:Bank",
1422                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
1423            )),
1424        ];
1425
1426        let errors = validate(&directives);
1427        assert!(
1428            errors.is_empty(),
1429            "Pad should handle negative adjustment: {errors:?}"
1430        );
1431    }
1432
1433    #[test]
1434    fn test_validate_insufficient_units() {
1435        use rustledger_core::CostSpec;
1436
1437        let cost_spec = CostSpec::empty()
1438            .with_number_per(dec!(150))
1439            .with_currency("USD");
1440
1441        let directives = vec![
1442            Directive::Open(
1443                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1444            ),
1445            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1446            // Buy 10 shares
1447            Directive::Transaction(
1448                Transaction::new(date(2024, 1, 15), "Buy")
1449                    .with_posting(
1450                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1451                            .with_cost(cost_spec.clone()),
1452                    )
1453                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1454            ),
1455            // Try to sell 15 shares (more than we have)
1456            Directive::Transaction(
1457                Transaction::new(date(2024, 6, 1), "Sell too many")
1458                    .with_posting(
1459                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1460                            .with_cost(cost_spec),
1461                    )
1462                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1463            ),
1464        ];
1465
1466        let errors = validate(&directives);
1467        assert!(
1468            errors
1469                .iter()
1470                .any(|e| e.code == ErrorCode::InsufficientUnits),
1471            "Should error for insufficient units: {errors:?}"
1472        );
1473    }
1474
1475    #[test]
1476    fn test_validate_no_matching_lot() {
1477        use rustledger_core::CostSpec;
1478
1479        let directives = vec![
1480            Directive::Open(
1481                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1482            ),
1483            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1484            // Buy at $150
1485            Directive::Transaction(
1486                Transaction::new(date(2024, 1, 15), "Buy")
1487                    .with_posting(
1488                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1489                            CostSpec::empty()
1490                                .with_number_per(dec!(150))
1491                                .with_currency("USD"),
1492                        ),
1493                    )
1494                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1495            ),
1496            // Try to sell at $160 (no lot at this price)
1497            Directive::Transaction(
1498                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1499                    .with_posting(
1500                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1501                            CostSpec::empty()
1502                                .with_number_per(dec!(160))
1503                                .with_currency("USD"),
1504                        ),
1505                    )
1506                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1507            ),
1508        ];
1509
1510        let errors = validate(&directives);
1511        assert!(
1512            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1513            "Should error for no matching lot: {errors:?}"
1514        );
1515    }
1516
1517    #[test]
1518    fn test_validate_ambiguous_lot_match() {
1519        use rustledger_core::CostSpec;
1520
1521        let cost_spec = CostSpec::empty()
1522            .with_number_per(dec!(150))
1523            .with_currency("USD");
1524
1525        let directives = vec![
1526            Directive::Open(
1527                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1528            ),
1529            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1530            // Buy at $150 on Jan 15
1531            Directive::Transaction(
1532                Transaction::new(date(2024, 1, 15), "Buy lot 1")
1533                    .with_posting(
1534                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1535                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1536                    )
1537                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1538            ),
1539            // Buy again at $150 on Feb 15 (creates second lot at same price)
1540            Directive::Transaction(
1541                Transaction::new(date(2024, 2, 15), "Buy lot 2")
1542                    .with_posting(
1543                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1544                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1545                    )
1546                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1547            ),
1548            // Try to sell with ambiguous cost (matches both lots - price only, no date)
1549            Directive::Transaction(
1550                Transaction::new(date(2024, 6, 1), "Sell ambiguous")
1551                    .with_posting(
1552                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1553                            .with_cost(cost_spec),
1554                    )
1555                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1556            ),
1557        ];
1558
1559        let errors = validate(&directives);
1560        assert!(
1561            errors
1562                .iter()
1563                .any(|e| e.code == ErrorCode::AmbiguousLotMatch),
1564            "Should error for ambiguous lot match: {errors:?}"
1565        );
1566    }
1567
1568    #[test]
1569    fn test_validate_successful_booking() {
1570        use rustledger_core::CostSpec;
1571
1572        let cost_spec = CostSpec::empty()
1573            .with_number_per(dec!(150))
1574            .with_currency("USD");
1575
1576        let directives = vec![
1577            Directive::Open(
1578                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1579            ),
1580            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1581            // Buy 10 shares
1582            Directive::Transaction(
1583                Transaction::new(date(2024, 1, 15), "Buy")
1584                    .with_posting(
1585                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1586                            .with_cost(cost_spec.clone()),
1587                    )
1588                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1589            ),
1590            // Sell 5 shares (should succeed with FIFO)
1591            Directive::Transaction(
1592                Transaction::new(date(2024, 6, 1), "Sell")
1593                    .with_posting(
1594                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1595                            .with_cost(cost_spec),
1596                    )
1597                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1598            ),
1599        ];
1600
1601        let errors = validate(&directives);
1602        // Filter out any balance errors (we're testing booking only)
1603        let booking_errors: Vec<_> = errors
1604            .iter()
1605            .filter(|e| {
1606                matches!(
1607                    e.code,
1608                    ErrorCode::InsufficientUnits
1609                        | ErrorCode::NoMatchingLot
1610                        | ErrorCode::AmbiguousLotMatch
1611                )
1612            })
1613            .collect();
1614        assert!(
1615            booking_errors.is_empty(),
1616            "Should have no booking errors: {booking_errors:?}"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_validate_account_already_open() {
1622        let directives = vec![
1623            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1624            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
1625        ];
1626
1627        let errors = validate(&directives);
1628        assert!(
1629            errors
1630                .iter()
1631                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1632            "Should error for duplicate open: {errors:?}"
1633        );
1634    }
1635
1636    #[test]
1637    fn test_validate_account_close_not_empty() {
1638        let directives = vec![
1639            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1640            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1641            Directive::Transaction(
1642                Transaction::new(date(2024, 1, 15), "Deposit")
1643                    .with_posting(Posting::new(
1644                        "Assets:Bank",
1645                        Amount::new(dec!(100.00), "USD"),
1646                    ))
1647                    .with_posting(Posting::new(
1648                        "Income:Salary",
1649                        Amount::new(dec!(-100.00), "USD"),
1650                    )),
1651            ),
1652            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
1653        ];
1654
1655        let errors = validate(&directives);
1656        assert!(
1657            errors
1658                .iter()
1659                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1660            "Should warn for closing account with balance: {errors:?}"
1661        );
1662    }
1663
1664    #[test]
1665    fn test_validate_no_postings() {
1666        let directives = vec![
1667            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1668            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1669        ];
1670
1671        let errors = validate(&directives);
1672        assert!(
1673            errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1674            "Should error for transaction with no postings: {errors:?}"
1675        );
1676    }
1677
1678    #[test]
1679    fn test_validate_single_posting() {
1680        let directives = vec![
1681            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1682            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1683                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1684            )),
1685        ];
1686
1687        let errors = validate(&directives);
1688        assert!(
1689            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1690            "Should warn for transaction with single posting: {errors:?}"
1691        );
1692        // Check it's a warning not error
1693        assert!(ErrorCode::SinglePosting.is_warning());
1694    }
1695
1696    #[test]
1697    fn test_validate_pad_without_balance() {
1698        let directives = vec![
1699            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1700            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1701            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1702            // No balance assertion follows!
1703        ];
1704
1705        let errors = validate(&directives);
1706        assert!(
1707            errors
1708                .iter()
1709                .any(|e| e.code == ErrorCode::PadWithoutBalance),
1710            "Should error for pad without subsequent balance: {errors:?}"
1711        );
1712    }
1713
1714    #[test]
1715    fn test_validate_multiple_pads_for_balance() {
1716        let directives = vec![
1717            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1718            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1719            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1720            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
1721            Directive::Balance(Balance::new(
1722                date(2024, 1, 3),
1723                "Assets:Bank",
1724                Amount::new(dec!(1000.00), "USD"),
1725            )),
1726        ];
1727
1728        let errors = validate(&directives);
1729        assert!(
1730            errors
1731                .iter()
1732                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1733            "Should error for multiple pads before balance: {errors:?}"
1734        );
1735    }
1736
1737    #[test]
1738    fn test_error_severity() {
1739        // Errors
1740        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1741        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1742        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1743
1744        // Warnings
1745        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1746        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1747        assert_eq!(
1748            ErrorCode::AccountCloseNotEmpty.severity(),
1749            Severity::Warning
1750        );
1751
1752        // Info
1753        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1754    }
1755
1756    #[test]
1757    fn test_validate_invalid_account_name() {
1758        // Test invalid root type
1759        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1760
1761        let errors = validate(&directives);
1762        assert!(
1763            errors
1764                .iter()
1765                .any(|e| e.code == ErrorCode::InvalidAccountName),
1766            "Should error for invalid account root: {errors:?}"
1767        );
1768    }
1769
1770    #[test]
1771    fn test_validate_account_lowercase_component() {
1772        // Test lowercase component (must start with uppercase or digit)
1773        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1774
1775        let errors = validate(&directives);
1776        assert!(
1777            errors
1778                .iter()
1779                .any(|e| e.code == ErrorCode::InvalidAccountName),
1780            "Should error for lowercase component: {errors:?}"
1781        );
1782    }
1783
1784    #[test]
1785    fn test_validate_valid_account_names() {
1786        // Valid account names should not error
1787        let valid_names = [
1788            "Assets:Bank",
1789            "Assets:Bank:Checking",
1790            "Liabilities:CreditCard",
1791            "Equity:Opening-Balances",
1792            "Income:Salary2024",
1793            "Expenses:Food:Restaurant",
1794            "Assets:401k", // Component starting with digit
1795        ];
1796
1797        for name in valid_names {
1798            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1799
1800            let errors = validate(&directives);
1801            let name_errors: Vec<_> = errors
1802                .iter()
1803                .filter(|e| e.code == ErrorCode::InvalidAccountName)
1804                .collect();
1805            assert!(
1806                name_errors.is_empty(),
1807                "Should accept valid account name '{name}': {name_errors:?}"
1808            );
1809        }
1810    }
1811}