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