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,
998                    expected,
999                    bal.amount.currency,
1000                    tolerance,
1001                    actual,
1002                    bal.amount.currency,
1003                    difference
1004                )
1005            } else {
1006                format!(
1007                    "Balance assertion failed for {}: expected {} {}, got {} {}",
1008                    bal.account, expected, bal.amount.currency, actual, bal.amount.currency
1009                )
1010            };
1011
1012            errors.push(
1013                ValidationError::new(error_code, message, bal.date)
1014                    .with_context(format!("difference: {difference}, tolerance: {tolerance}")),
1015            );
1016        }
1017    }
1018}
1019
1020fn validate_document(state: &LedgerState, doc: &Document, errors: &mut Vec<ValidationError>) {
1021    // Check account exists
1022    if !state.accounts.contains_key(&doc.account) {
1023        errors.push(ValidationError::new(
1024            ErrorCode::AccountNotOpen,
1025            format!("Account {} was never opened", doc.account),
1026            doc.date,
1027        ));
1028    }
1029
1030    // Check if document file exists (if enabled)
1031    if state.options.check_documents {
1032        let doc_path = Path::new(&doc.path);
1033
1034        let full_path = if doc_path.is_absolute() {
1035            doc_path.to_path_buf()
1036        } else if let Some(base) = &state.options.document_base {
1037            base.join(doc_path)
1038        } else {
1039            doc_path.to_path_buf()
1040        };
1041
1042        if !full_path.exists() {
1043            errors.push(
1044                ValidationError::new(
1045                    ErrorCode::DocumentNotFound,
1046                    format!("Document file not found: {}", doc.path),
1047                    doc.date,
1048                )
1049                .with_context(format!("resolved path: {}", full_path.display())),
1050            );
1051        }
1052    }
1053}
1054
1055#[cfg(test)]
1056mod tests {
1057    use super::*;
1058    use rust_decimal_macros::dec;
1059    use rustledger_core::{Amount, NaiveDate, Posting};
1060
1061    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1062        NaiveDate::from_ymd_opt(year, month, day).unwrap()
1063    }
1064
1065    #[test]
1066    fn test_validate_account_lifecycle() {
1067        let directives = vec![
1068            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1069            Directive::Transaction(
1070                Transaction::new(date(2024, 1, 15), "Test")
1071                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1072                    .with_posting(Posting::new(
1073                        "Income:Salary",
1074                        Amount::new(dec!(-100), "USD"),
1075                    )),
1076            ),
1077        ];
1078
1079        let errors = validate(&directives);
1080
1081        // Should have error: Income:Salary not opened
1082        assert!(errors
1083            .iter()
1084            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1085    }
1086
1087    #[test]
1088    fn test_validate_account_used_before_open() {
1089        let directives = vec![
1090            Directive::Transaction(
1091                Transaction::new(date(2024, 1, 1), "Test")
1092                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100), "USD")))
1093                    .with_posting(Posting::new(
1094                        "Income:Salary",
1095                        Amount::new(dec!(-100), "USD"),
1096                    )),
1097            ),
1098            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1099        ];
1100
1101        let errors = validate(&directives);
1102
1103        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1104    }
1105
1106    #[test]
1107    fn test_validate_account_used_after_close() {
1108        let directives = vec![
1109            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1110            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1111            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1112            Directive::Transaction(
1113                Transaction::new(date(2024, 7, 1), "Test")
1114                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(-50), "USD")))
1115                    .with_posting(Posting::new("Expenses:Food", Amount::new(dec!(50), "USD"))),
1116            ),
1117        ];
1118
1119        let errors = validate(&directives);
1120
1121        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1122    }
1123
1124    #[test]
1125    fn test_validate_balance_assertion() {
1126        let directives = vec![
1127            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1128            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1129            Directive::Transaction(
1130                Transaction::new(date(2024, 1, 15), "Deposit")
1131                    .with_posting(Posting::new(
1132                        "Assets:Bank",
1133                        Amount::new(dec!(1000.00), "USD"),
1134                    ))
1135                    .with_posting(Posting::new(
1136                        "Income:Salary",
1137                        Amount::new(dec!(-1000.00), "USD"),
1138                    )),
1139            ),
1140            Directive::Balance(Balance::new(
1141                date(2024, 1, 16),
1142                "Assets:Bank",
1143                Amount::new(dec!(1000.00), "USD"),
1144            )),
1145        ];
1146
1147        let errors = validate(&directives);
1148        assert!(errors.is_empty(), "{errors:?}");
1149    }
1150
1151    #[test]
1152    fn test_validate_balance_assertion_failed() {
1153        let directives = vec![
1154            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1155            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1156            Directive::Transaction(
1157                Transaction::new(date(2024, 1, 15), "Deposit")
1158                    .with_posting(Posting::new(
1159                        "Assets:Bank",
1160                        Amount::new(dec!(1000.00), "USD"),
1161                    ))
1162                    .with_posting(Posting::new(
1163                        "Income:Salary",
1164                        Amount::new(dec!(-1000.00), "USD"),
1165                    )),
1166            ),
1167            Directive::Balance(Balance::new(
1168                date(2024, 1, 16),
1169                "Assets:Bank",
1170                Amount::new(dec!(500.00), "USD"), // Wrong!
1171            )),
1172        ];
1173
1174        let errors = validate(&directives);
1175        assert!(
1176            errors
1177                .iter()
1178                .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1179        );
1180    }
1181
1182    #[test]
1183    fn test_validate_unbalanced_transaction() {
1184        let directives = vec![
1185            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1186            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1187            Directive::Transaction(
1188                Transaction::new(date(2024, 1, 15), "Unbalanced")
1189                    .with_posting(Posting::new(
1190                        "Assets:Bank",
1191                        Amount::new(dec!(-50.00), "USD"),
1192                    ))
1193                    .with_posting(Posting::new(
1194                        "Expenses:Food",
1195                        Amount::new(dec!(40.00), "USD"),
1196                    )), // Missing $10
1197            ),
1198        ];
1199
1200        let errors = validate(&directives);
1201        assert!(
1202            errors
1203                .iter()
1204                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1205        );
1206    }
1207
1208    #[test]
1209    fn test_validate_currency_not_allowed() {
1210        let directives = vec![
1211            Directive::Open(
1212                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1213            ),
1214            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1215            Directive::Transaction(
1216                Transaction::new(date(2024, 1, 15), "Test")
1217                    .with_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
1218                    .with_posting(Posting::new(
1219                        "Income:Salary",
1220                        Amount::new(dec!(-100.00), "EUR"),
1221                    )),
1222            ),
1223        ];
1224
1225        let errors = validate(&directives);
1226        assert!(
1227            errors
1228                .iter()
1229                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1230        );
1231    }
1232
1233    #[test]
1234    fn test_validate_future_date_warning() {
1235        // Create a date in the future
1236        let future_date = Local::now().date_naive() + chrono::Duration::days(30);
1237
1238        let directives = vec![Directive::Open(Open {
1239            date: future_date,
1240            account: "Assets:Bank".into(),
1241            currencies: vec![],
1242            booking: None,
1243            meta: Default::default(),
1244        })];
1245
1246        // Without warn_future_dates option, no warnings
1247        let errors = validate(&directives);
1248        assert!(
1249            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1250            "Should not warn about future dates by default"
1251        );
1252
1253        // With warn_future_dates option, should warn
1254        let options = ValidationOptions {
1255            warn_future_dates: true,
1256            ..Default::default()
1257        };
1258        let errors = validate_with_options(&directives, options);
1259        assert!(
1260            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1261            "Should warn about future dates when enabled"
1262        );
1263    }
1264
1265    #[test]
1266    fn test_validate_document_not_found() {
1267        let directives = vec![
1268            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1269            Directive::Document(Document {
1270                date: date(2024, 1, 15),
1271                account: "Assets:Bank".into(),
1272                path: "/nonexistent/path/to/document.pdf".to_string(),
1273                tags: vec![],
1274                links: vec![],
1275                meta: Default::default(),
1276            }),
1277        ];
1278
1279        // Without check_documents option, no error
1280        let errors = validate(&directives);
1281        assert!(
1282            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1283            "Should not check documents by default"
1284        );
1285
1286        // With check_documents option, should error
1287        let options = ValidationOptions {
1288            check_documents: true,
1289            ..Default::default()
1290        };
1291        let errors = validate_with_options(&directives, options);
1292        assert!(
1293            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1294            "Should report missing document when enabled"
1295        );
1296    }
1297
1298    #[test]
1299    fn test_validate_document_account_not_open() {
1300        let directives = vec![Directive::Document(Document {
1301            date: date(2024, 1, 15),
1302            account: "Assets:Unknown".into(),
1303            path: "receipt.pdf".to_string(),
1304            tags: vec![],
1305            links: vec![],
1306            meta: Default::default(),
1307        })];
1308
1309        let errors = validate(&directives);
1310        assert!(
1311            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1312            "Should error for document on unopened account"
1313        );
1314    }
1315
1316    #[test]
1317    fn test_error_code_is_warning() {
1318        assert!(!ErrorCode::AccountNotOpen.is_warning());
1319        assert!(!ErrorCode::DocumentNotFound.is_warning());
1320        assert!(ErrorCode::FutureDate.is_warning());
1321    }
1322
1323    #[test]
1324    fn test_validate_pad_basic() {
1325        let directives = vec![
1326            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1327            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1328            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1329            Directive::Balance(Balance::new(
1330                date(2024, 1, 2),
1331                "Assets:Bank",
1332                Amount::new(dec!(1000.00), "USD"),
1333            )),
1334        ];
1335
1336        let errors = validate(&directives);
1337        // Should have no errors - pad should satisfy the balance
1338        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1339    }
1340
1341    #[test]
1342    fn test_validate_pad_with_existing_balance() {
1343        let directives = vec![
1344            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1345            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1346            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1347            // Add some initial transactions
1348            Directive::Transaction(
1349                Transaction::new(date(2024, 1, 5), "Initial deposit")
1350                    .with_posting(Posting::new(
1351                        "Assets:Bank",
1352                        Amount::new(dec!(500.00), "USD"),
1353                    ))
1354                    .with_posting(Posting::new(
1355                        "Income:Salary",
1356                        Amount::new(dec!(-500.00), "USD"),
1357                    )),
1358            ),
1359            // Pad to reach the target balance
1360            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1361            Directive::Balance(Balance::new(
1362                date(2024, 1, 15),
1363                "Assets:Bank",
1364                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
1365            )),
1366        ];
1367
1368        let errors = validate(&directives);
1369        // Should have no errors - pad should add the missing 500
1370        assert!(
1371            errors.is_empty(),
1372            "Pad should add missing amount: {errors:?}"
1373        );
1374    }
1375
1376    #[test]
1377    fn test_validate_pad_account_not_open() {
1378        let directives = vec![
1379            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1380            // Assets:Bank not opened
1381            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1382        ];
1383
1384        let errors = validate(&directives);
1385        assert!(
1386            errors
1387                .iter()
1388                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1389            "Should error for pad on unopened account"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_validate_pad_source_not_open() {
1395        let directives = vec![
1396            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1397            // Equity:Opening not opened
1398            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1399        ];
1400
1401        let errors = validate(&directives);
1402        assert!(
1403            errors.iter().any(
1404                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1405            ),
1406            "Should error for pad with unopened source account"
1407        );
1408    }
1409
1410    #[test]
1411    fn test_validate_pad_negative_adjustment() {
1412        // Test that pad can reduce a balance too
1413        let directives = vec![
1414            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1415            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1416            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1417            // Add more than needed
1418            Directive::Transaction(
1419                Transaction::new(date(2024, 1, 5), "Big deposit")
1420                    .with_posting(Posting::new(
1421                        "Assets:Bank",
1422                        Amount::new(dec!(2000.00), "USD"),
1423                    ))
1424                    .with_posting(Posting::new(
1425                        "Income:Salary",
1426                        Amount::new(dec!(-2000.00), "USD"),
1427                    )),
1428            ),
1429            // Pad to reach a lower target
1430            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1431            Directive::Balance(Balance::new(
1432                date(2024, 1, 15),
1433                "Assets:Bank",
1434                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
1435            )),
1436        ];
1437
1438        let errors = validate(&directives);
1439        assert!(
1440            errors.is_empty(),
1441            "Pad should handle negative adjustment: {errors:?}"
1442        );
1443    }
1444
1445    #[test]
1446    fn test_validate_insufficient_units() {
1447        use rustledger_core::CostSpec;
1448
1449        let cost_spec = CostSpec::empty()
1450            .with_number_per(dec!(150))
1451            .with_currency("USD");
1452
1453        let directives = vec![
1454            Directive::Open(
1455                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1456            ),
1457            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1458            // Buy 10 shares
1459            Directive::Transaction(
1460                Transaction::new(date(2024, 1, 15), "Buy")
1461                    .with_posting(
1462                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1463                            .with_cost(cost_spec.clone()),
1464                    )
1465                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1466            ),
1467            // Try to sell 15 shares (more than we have)
1468            Directive::Transaction(
1469                Transaction::new(date(2024, 6, 1), "Sell too many")
1470                    .with_posting(
1471                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1472                            .with_cost(cost_spec),
1473                    )
1474                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(2250), "USD"))),
1475            ),
1476        ];
1477
1478        let errors = validate(&directives);
1479        assert!(
1480            errors
1481                .iter()
1482                .any(|e| e.code == ErrorCode::InsufficientUnits),
1483            "Should error for insufficient units: {errors:?}"
1484        );
1485    }
1486
1487    #[test]
1488    fn test_validate_no_matching_lot() {
1489        use rustledger_core::CostSpec;
1490
1491        let directives = vec![
1492            Directive::Open(
1493                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1494            ),
1495            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1496            // Buy at $150
1497            Directive::Transaction(
1498                Transaction::new(date(2024, 1, 15), "Buy")
1499                    .with_posting(
1500                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
1501                            CostSpec::empty()
1502                                .with_number_per(dec!(150))
1503                                .with_currency("USD"),
1504                        ),
1505                    )
1506                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1507            ),
1508            // Try to sell at $160 (no lot at this price)
1509            Directive::Transaction(
1510                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
1511                    .with_posting(
1512                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
1513                            CostSpec::empty()
1514                                .with_number_per(dec!(160))
1515                                .with_currency("USD"),
1516                        ),
1517                    )
1518                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(800), "USD"))),
1519            ),
1520        ];
1521
1522        let errors = validate(&directives);
1523        assert!(
1524            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
1525            "Should error for no matching lot: {errors:?}"
1526        );
1527    }
1528
1529    #[test]
1530    fn test_validate_ambiguous_lot_match() {
1531        use rustledger_core::CostSpec;
1532
1533        let cost_spec = CostSpec::empty()
1534            .with_number_per(dec!(150))
1535            .with_currency("USD");
1536
1537        let directives = vec![
1538            Directive::Open(
1539                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1540            ),
1541            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1542            // Buy at $150 on Jan 15
1543            Directive::Transaction(
1544                Transaction::new(date(2024, 1, 15), "Buy lot 1")
1545                    .with_posting(
1546                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1547                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
1548                    )
1549                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1550            ),
1551            // Buy again at $150 on Feb 15 (creates second lot at same price)
1552            Directive::Transaction(
1553                Transaction::new(date(2024, 2, 15), "Buy lot 2")
1554                    .with_posting(
1555                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1556                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
1557                    )
1558                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1559            ),
1560            // Try to sell with ambiguous cost (matches both lots - price only, no date)
1561            Directive::Transaction(
1562                Transaction::new(date(2024, 6, 1), "Sell ambiguous")
1563                    .with_posting(
1564                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1565                            .with_cost(cost_spec),
1566                    )
1567                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1568            ),
1569        ];
1570
1571        let errors = validate(&directives);
1572        assert!(
1573            errors
1574                .iter()
1575                .any(|e| e.code == ErrorCode::AmbiguousLotMatch),
1576            "Should error for ambiguous lot match: {errors:?}"
1577        );
1578    }
1579
1580    #[test]
1581    fn test_validate_successful_booking() {
1582        use rustledger_core::CostSpec;
1583
1584        let cost_spec = CostSpec::empty()
1585            .with_number_per(dec!(150))
1586            .with_currency("USD");
1587
1588        let directives = vec![
1589            Directive::Open(
1590                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
1591            ),
1592            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1593            // Buy 10 shares
1594            Directive::Transaction(
1595                Transaction::new(date(2024, 1, 15), "Buy")
1596                    .with_posting(
1597                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1598                            .with_cost(cost_spec.clone()),
1599                    )
1600                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(-1500), "USD"))),
1601            ),
1602            // Sell 5 shares (should succeed with FIFO)
1603            Directive::Transaction(
1604                Transaction::new(date(2024, 6, 1), "Sell")
1605                    .with_posting(
1606                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
1607                            .with_cost(cost_spec),
1608                    )
1609                    .with_posting(Posting::new("Assets:Cash", Amount::new(dec!(750), "USD"))),
1610            ),
1611        ];
1612
1613        let errors = validate(&directives);
1614        // Filter out any balance errors (we're testing booking only)
1615        let booking_errors: Vec<_> = errors
1616            .iter()
1617            .filter(|e| {
1618                matches!(
1619                    e.code,
1620                    ErrorCode::InsufficientUnits
1621                        | ErrorCode::NoMatchingLot
1622                        | ErrorCode::AmbiguousLotMatch
1623                )
1624            })
1625            .collect();
1626        assert!(
1627            booking_errors.is_empty(),
1628            "Should have no booking errors: {booking_errors:?}"
1629        );
1630    }
1631
1632    #[test]
1633    fn test_validate_account_already_open() {
1634        let directives = vec![
1635            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1636            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
1637        ];
1638
1639        let errors = validate(&directives);
1640        assert!(
1641            errors
1642                .iter()
1643                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
1644            "Should error for duplicate open: {errors:?}"
1645        );
1646    }
1647
1648    #[test]
1649    fn test_validate_account_close_not_empty() {
1650        let directives = vec![
1651            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1652            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1653            Directive::Transaction(
1654                Transaction::new(date(2024, 1, 15), "Deposit")
1655                    .with_posting(Posting::new(
1656                        "Assets:Bank",
1657                        Amount::new(dec!(100.00), "USD"),
1658                    ))
1659                    .with_posting(Posting::new(
1660                        "Income:Salary",
1661                        Amount::new(dec!(-100.00), "USD"),
1662                    )),
1663            ),
1664            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
1665        ];
1666
1667        let errors = validate(&directives);
1668        assert!(
1669            errors
1670                .iter()
1671                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
1672            "Should warn for closing account with balance: {errors:?}"
1673        );
1674    }
1675
1676    #[test]
1677    fn test_validate_no_postings() {
1678        let directives = vec![
1679            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1680            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
1681        ];
1682
1683        let errors = validate(&directives);
1684        assert!(
1685            errors.iter().any(|e| e.code == ErrorCode::NoPostings),
1686            "Should error for transaction with no postings: {errors:?}"
1687        );
1688    }
1689
1690    #[test]
1691    fn test_validate_single_posting() {
1692        let directives = vec![
1693            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1694            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Single").with_posting(
1695                Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
1696            )),
1697        ];
1698
1699        let errors = validate(&directives);
1700        assert!(
1701            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
1702            "Should warn for transaction with single posting: {errors:?}"
1703        );
1704        // Check it's a warning not error
1705        assert!(ErrorCode::SinglePosting.is_warning());
1706    }
1707
1708    #[test]
1709    fn test_validate_pad_without_balance() {
1710        let directives = vec![
1711            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1712            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1713            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1714            // No balance assertion follows!
1715        ];
1716
1717        let errors = validate(&directives);
1718        assert!(
1719            errors
1720                .iter()
1721                .any(|e| e.code == ErrorCode::PadWithoutBalance),
1722            "Should error for pad without subsequent balance: {errors:?}"
1723        );
1724    }
1725
1726    #[test]
1727    fn test_validate_multiple_pads_for_balance() {
1728        let directives = vec![
1729            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1730            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1731            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1732            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
1733            Directive::Balance(Balance::new(
1734                date(2024, 1, 3),
1735                "Assets:Bank",
1736                Amount::new(dec!(1000.00), "USD"),
1737            )),
1738        ];
1739
1740        let errors = validate(&directives);
1741        assert!(
1742            errors
1743                .iter()
1744                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
1745            "Should error for multiple pads before balance: {errors:?}"
1746        );
1747    }
1748
1749    #[test]
1750    fn test_error_severity() {
1751        // Errors
1752        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
1753        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
1754        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
1755
1756        // Warnings
1757        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
1758        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
1759        assert_eq!(
1760            ErrorCode::AccountCloseNotEmpty.severity(),
1761            Severity::Warning
1762        );
1763
1764        // Info
1765        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
1766    }
1767
1768    #[test]
1769    fn test_validate_invalid_account_name() {
1770        // Test invalid root type
1771        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
1772
1773        let errors = validate(&directives);
1774        assert!(
1775            errors
1776                .iter()
1777                .any(|e| e.code == ErrorCode::InvalidAccountName),
1778            "Should error for invalid account root: {errors:?}"
1779        );
1780    }
1781
1782    #[test]
1783    fn test_validate_account_lowercase_component() {
1784        // Test lowercase component (must start with uppercase or digit)
1785        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
1786
1787        let errors = validate(&directives);
1788        assert!(
1789            errors
1790                .iter()
1791                .any(|e| e.code == ErrorCode::InvalidAccountName),
1792            "Should error for lowercase component: {errors:?}"
1793        );
1794    }
1795
1796    #[test]
1797    fn test_validate_valid_account_names() {
1798        // Valid account names should not error
1799        let valid_names = [
1800            "Assets:Bank",
1801            "Assets:Bank:Checking",
1802            "Liabilities:CreditCard",
1803            "Equity:Opening-Balances",
1804            "Income:Salary2024",
1805            "Expenses:Food:Restaurant",
1806            "Assets:401k", // Component starting with digit
1807        ];
1808
1809        for name in valid_names {
1810            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
1811
1812            let errors = validate(&directives);
1813            let name_errors: Vec<_> = errors
1814                .iter()
1815                .filter(|e| e.code == ErrorCode::InvalidAccountName)
1816                .collect();
1817            assert!(
1818                name_errors.is_empty(),
1819                "Should accept valid account name '{name}': {name_errors:?}"
1820            );
1821        }
1822    }
1823}