Skip to main content

rustledger_validate/
lib.rs

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