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/core/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//! | E5003 | Invalid `precision` metadata on commodity directive (warning) |
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
47mod error;
48mod validators;
49
50pub use error::{ErrorCode, Severity, ValidationError, is_advisory_only_code};
51pub use validators::balance::balance_tolerance;
52
53/// Which phase of two-phase validation to run.
54///
55/// The loader pipeline splits validation around booking. Checks that
56/// don't need filled-in amounts (account presence, account lifecycle,
57/// structural integrity, date ordering, document presence, commodity
58/// metadata) run as [`Phase::Early`] AFTER synthesizer plugins
59/// (`auto_accounts`, `document_discovery`) but BEFORE booking, so
60/// they see elided postings to unopened accounts (with any Opens
61/// plugins injected) before booking drops zero-value interpolations.
62/// Checks that need filled-in amounts (currency constraints, balance
63/// residuals, inventory updates, balance assertions) run as
64/// [`Phase::Late`] AFTER booking AND after the regular plugin pass
65/// (so cost-spec-reading plugins like `implicit_prices` see filled
66/// per-unit values on the `CostNumber::PerUnitFromTotal` variant).
67///
68/// The pipeline is therefore:
69///     sort → synth-plugins → Early → book → regular-plugins → Late → finalize
70///
71/// Standalone callers (LSP, tests, FFI) that don't run booking between
72/// phases typically chain `Early` → `Late` → [`ValidationSession::finalize`]
73/// through a single session — there is no shortcut entry point anymore.
74///
75/// See the "Python Compatibility Policy" section in `CLAUDE.md` for the
76/// rationale on why we deliberately catch elided-zero-to-unopened-account
77/// references that Python beancount silently accepts.
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
79pub enum Phase {
80    /// Pre-booking checks: account presence (E1001), account lifecycle,
81    /// structural integrity, date ordering, future-date warnings,
82    /// document presence, commodity metadata.
83    Early,
84    /// Post-booking checks: currency constraints on filled postings,
85    /// transaction balance, balance assertions, inventory updates with
86    /// lot matching / capital gains, residual checks.
87    Late,
88}
89
90use validators::{
91    register_open_late, validate_balance_early, validate_balance_late, validate_close,
92    validate_close_late, validate_document, validate_note, validate_open, validate_pad,
93    validate_transaction_early, validate_transaction_late,
94};
95
96use rayon::prelude::*;
97use rustledger_core::NaiveDate;
98
99/// Threshold for using parallel sort. For small collections, sequential sort
100/// is faster due to reduced threading overhead.
101const PARALLEL_SORT_THRESHOLD: usize = 5000;
102
103/// Threshold for fanning the per-Document `Path::exists()` pre-pass
104/// out via rayon. Below this, the dispatch overhead outweighs the
105/// per-syscall savings.
106const PARALLEL_DOC_EXISTS_THRESHOLD: usize = 64;
107use rust_decimal::Decimal;
108use rustc_hash::{FxHashMap, FxHashSet};
109use rustledger_core::{Account, BookingMethod, Commodity, Currency, Directive, Inventory};
110use rustledger_parser::{SYNTHESIZED_FILE_ID, Spanned};
111use std::collections::BTreeSet;
112
113/// Account state for tracking lifecycle.
114#[derive(Debug, Clone)]
115struct AccountState {
116    /// Date opened.
117    opened: NaiveDate,
118    /// Date closed (if closed).
119    closed: Option<NaiveDate>,
120    /// Allowed currencies (empty = any).
121    currencies: FxHashSet<rustledger_core::Currency>,
122    /// Booking method for this account (from `open` directive).
123    /// Used by `update_inventories()` for lot matching during validation.
124    booking: BookingMethod,
125}
126
127/// Validation options.
128#[non_exhaustive]
129#[derive(Debug, Clone)]
130pub struct ValidationOptions {
131    /// Whether to require commodity declarations.
132    pub require_commodities: bool,
133    /// Whether to check if document files exist.
134    pub check_documents: bool,
135    /// Whether to warn about future-dated entries.
136    pub warn_future_dates: bool,
137    /// Base directory for resolving relative document paths.
138    pub document_base: Option<std::path::PathBuf>,
139    /// Document directories from `option "documents"`.
140    /// Relative document paths are resolved against these directories.
141    /// Paths are resolved against the ledger file's directory at load time.
142    pub document_dirs: Vec<std::path::PathBuf>,
143    /// Directory of each source file, indexed by `file_id` (the `u16` carried
144    /// by `Spanned<Directive>`). A relative `document` path with no
145    /// `document_base`/`documents` option is resolved against its own
146    /// directive's source-file directory — matching Beancount, which
147    /// normalizes the path at parse time, and `include`, which resolves
148    /// relative to the including file. Empty for callers that don't supply
149    /// source locations (the resolution then falls back to the process CWD,
150    /// the pre-fix behavior).
151    pub document_source_dirs: Vec<std::path::PathBuf>,
152    /// Valid account type prefixes (from options like `name_assets`, `name_liabilities`, etc.).
153    /// Defaults to `["Assets", "Liabilities", "Equity", "Income", "Expenses"]`.
154    pub account_types: Vec<String>,
155    /// Whether to infer tolerance from cost (matches Python beancount's `infer_tolerance_from_cost`).
156    /// When true, tolerance for cost-based postings is calculated as: `units_quantum * cost_per_unit`.
157    pub infer_tolerance_from_cost: bool,
158    /// Tolerance multiplier (matches Python beancount's `inferred_tolerance_multiplier`).
159    /// Default is 0.5.
160    pub tolerance_multiplier: Decimal,
161    /// Per-currency default tolerances (matches Python beancount's `inferred_tolerance_default`).
162    /// e.g., `{"GBP": 0.004}` means GBP transactions tolerate up to 0.004 residual.
163    pub inferred_tolerance_default: FxHashMap<String, Decimal>,
164    /// Default booking method for accounts without an explicit method on
165    /// their `open` directive. Sourced from the file-level
166    /// `option "booking_method"` (or the API-level `LoadOptions`
167    /// default). Mirrors the resolved `effective_method` the booking
168    /// engine sees — without this, the validator's per-account
169    /// lot-matching pass falls back to `BookingMethod::default()`
170    /// (i.e., STRICT) regardless of the file's stated method,
171    /// re-raising the very `NoMatchingLot`/`AmbiguousMatch` errors
172    /// the booker just decided to skip under `NONE` (issue #1182).
173    pub default_booking_method: BookingMethod,
174}
175
176impl Default for ValidationOptions {
177    fn default() -> Self {
178        Self {
179            require_commodities: false,
180            check_documents: true, // Python beancount validates document files by default
181            warn_future_dates: false,
182            document_base: None,
183            document_dirs: Vec::new(),
184            document_source_dirs: Vec::new(),
185            account_types: vec![
186                "Assets".to_string(),
187                "Liabilities".to_string(),
188                "Equity".to_string(),
189                "Income".to_string(),
190                "Expenses".to_string(),
191            ],
192            // Match Python beancount defaults
193            infer_tolerance_from_cost: false,
194            tolerance_multiplier: Decimal::new(5, 1), // 0.5
195            inferred_tolerance_default: FxHashMap::default(),
196            default_booking_method: BookingMethod::default(),
197        }
198    }
199}
200
201impl ValidationOptions {
202    /// Set account types.
203    #[must_use]
204    pub fn with_account_types(mut self, types: Vec<String>) -> Self {
205        self.account_types = types;
206        self
207    }
208
209    /// Set whether to require commodity declarations.
210    #[must_use]
211    pub const fn with_require_commodities(mut self, require: bool) -> Self {
212        self.require_commodities = require;
213        self
214    }
215
216    /// Set whether to check if document files exist.
217    #[must_use]
218    pub const fn with_check_documents(mut self, check: bool) -> Self {
219        self.check_documents = check;
220        self
221    }
222
223    /// Set whether to warn about future-dated entries.
224    #[must_use]
225    pub const fn with_warn_future_dates(mut self, warn: bool) -> Self {
226        self.warn_future_dates = warn;
227        self
228    }
229
230    /// Set document directories (resolved paths).
231    #[must_use]
232    pub fn with_document_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
233        self.document_dirs = dirs;
234        self
235    }
236
237    /// Set per-`file_id` source-file directories, used to resolve relative
238    /// `document` paths against their own directive's file (see the field doc
239    /// on [`ValidationOptions::document_source_dirs`]).
240    #[must_use]
241    pub fn with_document_source_dirs(mut self, dirs: Vec<std::path::PathBuf>) -> Self {
242        self.document_source_dirs = dirs;
243        self
244    }
245
246    /// Set whether to infer tolerance from cost.
247    #[must_use]
248    pub const fn with_infer_tolerance_from_cost(mut self, infer: bool) -> Self {
249        self.infer_tolerance_from_cost = infer;
250        self
251    }
252
253    /// Set tolerance multiplier.
254    #[must_use]
255    pub const fn with_tolerance_multiplier(mut self, multiplier: Decimal) -> Self {
256        self.tolerance_multiplier = multiplier;
257        self
258    }
259
260    /// Set per-currency default tolerances.
261    #[must_use]
262    pub fn with_inferred_tolerance_default(mut self, defaults: FxHashMap<String, Decimal>) -> Self {
263        self.inferred_tolerance_default = defaults;
264        self
265    }
266
267    /// Set the default booking method (file-level
268    /// `option "booking_method"`). Accounts without an explicit method
269    /// on their `open` directive inherit this rather than falling
270    /// through to `BookingMethod::default()`.
271    #[must_use]
272    pub const fn with_default_booking_method(mut self, method: BookingMethod) -> Self {
273        self.default_booking_method = method;
274        self
275    }
276}
277
278/// Pending pad directive info.
279#[derive(Debug, Clone)]
280struct PendingPad {
281    /// Source account for padding.
282    source_account: rustledger_core::Account,
283    /// Date of the pad directive.
284    date: NaiveDate,
285    /// Currencies for which this pad has already inserted padding.
286    /// A single Pad can serve multiple currency-specific Balance
287    /// assertions on the same target account (e.g. `pad → balance USD
288    /// → balance EUR`), so we track per-currency rather than a single
289    /// `used` flag. Empty set = no balance has consumed this pad yet
290    /// (drives E2003 in `check_unused_pads`).
291    padded_currencies: FxHashSet<rustledger_core::Currency>,
292    /// Source span + file id of the `pad` directive, when validating
293    /// `Spanned` directives. Carried so `check_unused_pads` can anchor the
294    /// deferred E2003 to the pad's own line instead of `<unknown>`.
295    location: Option<(rustledger_parser::Span, u16)>,
296}
297
298/// The computed result of a `balance` assertion, recorded during Late
299/// validation (#1663).
300///
301/// `diff` is `computed − asserted` in the asserted currency — zero means the
302/// assertion matched exactly. Exposed via [`ValidationSession::balance_actuals`]
303/// so the FFI `load` surface (and any consumer) can render per-assertion
304/// pass/fail without re-deriving the balance from scratch.
305#[derive(Debug, Clone)]
306pub struct BalanceActual {
307    /// The `balance` directive's date.
308    pub date: NaiveDate,
309    /// The asserted account.
310    pub account: Account,
311    /// The asserted currency.
312    pub currency: rustledger_core::Currency,
313    /// `computed − asserted` in `currency` (zero = exact match).
314    pub diff: rustledger_core::Decimal,
315}
316
317/// Ledger state for validation.
318#[derive(Debug, Default)]
319pub struct LedgerState {
320    /// Account states.
321    accounts: FxHashMap<rustledger_core::Account, AccountState>,
322    /// Account inventories.
323    inventories: FxHashMap<rustledger_core::Account, Inventory>,
324    /// Lexically-sorted view of the `inventories` keys — the sub-account prefix
325    /// index. Kept in lockstep with `inventories` (both gain a key only in
326    /// `validate_open`/`register_open_late`; keys are never removed). Lets
327    /// [`sum_account_subtree`] answer a balance assertion with a range query over
328    /// just the target's subtree, instead of an O(all-accounts) scan per
329    /// assertion (`Account`'s `Ord`/`Borrow<str>` are lexical).
330    inventory_accounts: BTreeSet<Account>,
331    /// Declared commodities.
332    commodities: FxHashSet<rustledger_core::Currency>,
333    /// Pending pad directives (account -> list of pads).
334    pending_pads: FxHashMap<rustledger_core::Account, Vec<PendingPad>>,
335    /// Validation options.
336    options: ValidationOptions,
337    /// Track previous directive date for out-of-order detection.
338    last_date: Option<NaiveDate>,
339    /// `(account, close_date)` pairs whose late-phase Close check has
340    /// already fired. Guards against duplicate same-day Close
341    /// directives running the non-empty-balance check twice (the early
342    /// phase only rejects the duplicate with `AccountClosed`; without
343    /// this set, `validate_close_late`'s `closed == Some(close.date)`
344    /// guard would let both through).
345    ///
346    /// Keyed by `(account, date)` rather than account alone so that if
347    /// reopen-after-close is ever supported, a legitimate later close on
348    /// the same account still runs the inventory check.
349    pub(crate) late_close_processed: FxHashSet<(rustledger_core::Account, NaiveDate)>,
350    /// Per-posting identities `(file_id, span)` for which the early phase already
351    /// emitted `AccountNotOpen` (E1001) on an *elided* posting to an unopened
352    /// account. Elided postings must be checked early — booking interpolates
353    /// them, so the account has to exist before booking (the Python
354    /// #877-equivalent case). Explicit postings are deferred to the late phase
355    /// so account-rewriting regular plugins (e.g. `rename_accounts`,
356    /// `split_expenses`), which run after early, aren't falsely flagged on their
357    /// pre-rewrite account name. The late phase consults this set to skip the
358    /// *same* posting (a booked-from-elided one still unopened after plugins),
359    /// keyed by source identity so a different posting that merely shares an
360    /// account/date is still reported.
361    pub(crate) account_not_open_early: FxHashSet<(u16, rustledger_core::Span)>,
362    /// Per-`balance`-assertion computed result recorded during Late validation:
363    /// `diff = computed − asserted`. Lets consumers render per-assertion pass/fail
364    /// without re-deriving the balance (#1663).
365    pub(crate) balance_actuals: Vec<BalanceActual>,
366}
367
368impl LedgerState {
369    /// Create a new ledger state.
370    #[must_use]
371    pub fn new() -> Self {
372        Self::default()
373    }
374
375    /// Create a new ledger state with options.
376    #[must_use]
377    pub fn with_options(options: ValidationOptions) -> Self {
378        Self {
379            options,
380            ..Default::default()
381        }
382    }
383
384    /// Set whether to require commodity declarations.
385    pub const fn set_require_commodities(&mut self, require: bool) {
386        self.options.require_commodities = require;
387    }
388
389    /// Set whether to check document files.
390    pub const fn set_check_documents(&mut self, check: bool) {
391        self.options.check_documents = check;
392    }
393
394    /// Set whether to warn about future dates.
395    pub const fn set_warn_future_dates(&mut self, warn: bool) {
396        self.options.warn_future_dates = warn;
397    }
398
399    /// Set the document base directory.
400    pub fn set_document_base(&mut self, base: impl Into<std::path::PathBuf>) {
401        self.options.document_base = Some(base.into());
402    }
403
404    /// Get the inventory for an account.
405    #[must_use]
406    pub fn inventory(&self, account: &str) -> Option<&Inventory> {
407        self.inventories.get(account)
408    }
409
410    /// Get all account names.
411    pub fn accounts(&self) -> impl Iterator<Item = &str> {
412        self.accounts.keys().map(rustledger_core::Account::as_str)
413    }
414
415    /// Import option warnings from the loader and convert them to validation errors.
416    ///
417    /// The loader collects option warnings (E7001 unknown option, E7002 invalid value,
418    /// E7003 duplicate option) during option processing. Call this method to include
419    /// those warnings as validation errors.
420    ///
421    /// Each tuple is `(code, message)` where code is "E7001", "E7002", or "E7003".
422    pub fn import_option_warnings(
423        &self,
424        warnings: &[(&str, &str)],
425        errors: &mut Vec<ValidationError>,
426    ) {
427        for &(code, message) in warnings {
428            let error_code = match code {
429                "E7001" => ErrorCode::UnknownOption,
430                "E7002" => ErrorCode::InvalidOptionValue,
431                "E7003" => ErrorCode::DuplicateOption,
432                _ => continue,
433            };
434            errors.push(ValidationError::new(
435                error_code,
436                message.to_string(),
437                // Options don't have dates — use epoch as sentinel
438                NaiveDate::default(),
439            ));
440        }
441    }
442}
443
444/// Internal trait that lets [`validate_phase_inner`] operate over both plain
445/// `Directive`s and `Spanned<Directive>`s without duplicating the loop
446/// body. The two inputs differ only in whether errors get a span/file
447/// stamp at the end of each iteration — encoded here as the return of
448/// [`Self::span_info`].
449///
450/// `Sync` bound: needed so `&D` is `Send`, which `rayon::par_sort_by`
451/// requires for the large-collection sort path.
452trait ValidatableDirective: Sync {
453    fn directive(&self) -> &Directive;
454    /// Span + file id for this directive's source location, if any.
455    /// Plain `Directive` always returns `None`; `Spanned<Directive>`
456    /// returns the carried info.
457    fn span_info(&self) -> Option<(rustledger_parser::Span, u16)>;
458}
459
460impl ValidatableDirective for Directive {
461    fn directive(&self) -> &Directive {
462        self
463    }
464    fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
465        None
466    }
467}
468
469impl ValidatableDirective for Spanned<Directive> {
470    fn directive(&self) -> &Directive {
471        &self.value
472    }
473    fn span_info(&self) -> Option<(rustledger_parser::Span, u16)> {
474        Some((self.span, self.file_id))
475    }
476}
477
478/// Sum the units of `currency` across `account` and all of its sub-accounts —
479/// the value a `balance` assertion checks (beancount includes sub-accounts).
480///
481/// Uses the `inventory_accounts` prefix index instead of scanning every account:
482/// the subtree of `Assets:Bank` is `Assets:Bank` itself plus the keys in the
483/// half-open range `["Assets:Bank:", "Assets:Bank;")` (`;` is the byte after
484/// `:`), which captures every `Assets:Bank:*` and nothing else — equivalent to
485/// [`rustledger_core::is_subaccount_or_equal`], answered by a `BTreeSet` range
486/// query in O(log A + subtree) rather than O(A) per assertion. Equivalence to
487/// the unindexed [`rustledger_core::sum_account_and_subaccounts`] is pinned by a
488/// parity test. Takes the two fields directly (not `&self`) so callers can hold
489/// a disjoint `&mut` borrow of another `LedgerState` field (e.g. `pending_pads`)
490/// at the same time.
491fn sum_account_subtree(
492    inventories: &FxHashMap<Account, Inventory>,
493    index: &BTreeSet<Account>,
494    account: &Account,
495    currency: &Currency,
496) -> Decimal {
497    let acct = account.as_str();
498    // The account itself (the `== A` arm of `is_subaccount_or_equal`).
499    let mut total = inventories
500        .get(account)
501        .map_or(Decimal::ZERO, |inv| inv.units(currency));
502    // Its sub-accounts: the contiguous `["A:", "A;")` range. The explicit
503    // `Bound` tuple gives `RangeBounds<str>` (a `&str..&str` range would be
504    // `RangeBounds<&str>`, which `range::<str>` doesn't accept). Build the two
505    // bound strings without `format!` — this runs per balance assertion.
506    let mut lower = String::with_capacity(acct.len() + 1);
507    lower.push_str(acct);
508    lower.push(':');
509    let mut upper = String::with_capacity(acct.len() + 1);
510    upper.push_str(acct);
511    upper.push(';');
512    let bounds = (
513        std::ops::Bound::Included(lower.as_str()),
514        std::ops::Bound::Excluded(upper.as_str()),
515    );
516    for sub in index.range::<str, _>(bounds) {
517        if let Some(inv) = inventories.get(sub) {
518            total += inv.units(currency);
519        }
520    }
521    total
522}
523
524/// Internal: run ONE validation phase over a sorted view of `directives`,
525/// reading from / writing to `state`.
526///
527/// The same `state` is threaded through `Early` then `Late` so the
528/// account/commodity/pad bookkeeping accumulated by `Early` is visible
529/// to `Late`'s balance/inventory checks.
530///
531/// Date-ordering and future-date checks run only in `Early` (date is
532/// independent of booking), so callers running both phases don't get
533/// duplicate `DateOutOfOrder` / `FutureDate` warnings.
534fn validate_phase_inner<D: ValidatableDirective>(
535    directives: &[D],
536    state: &mut LedgerState,
537    phase: Phase,
538    today: NaiveDate,
539) -> Vec<ValidationError> {
540    // Document existence is checked in the Early phase; skip the I/O
541    // pre-pass when we're running Late.
542    let document_exists_cache = if phase == Phase::Early {
543        build_document_exists_cache(directives, &state.options)
544    } else {
545        FxHashMap::default()
546    };
547
548    // Reset `last_date` at the start of each phase so the date-ordering
549    // check (which runs in Early) doesn't get confused by a previous
550    // Late pass having advanced past every directive.
551    if phase == Phase::Early {
552        state.last_date = None;
553    }
554
555    let mut errors = Vec::new();
556
557    // Sort directives by date, then by type priority
558    // (e.g., balance assertions before transactions on the same day).
559    // Parallel sort only for large collections (threading overhead
560    // otherwise).
561    let mut sorted: Vec<&D> = Vec::with_capacity(directives.len());
562    sorted.extend(directives.iter());
563    let sort_fn = |a: &&D, b: &&D| {
564        let ad = a.directive();
565        let bd = b.directive();
566        ad.date()
567            .cmp(&bd.date())
568            .then_with(|| ad.priority().cmp(&bd.priority()))
569            .then_with(|| ad.has_cost_reduction().cmp(&bd.has_cost_reduction()))
570    };
571    if sorted.len() >= PARALLEL_SORT_THRESHOLD {
572        sorted.par_sort_by(sort_fn);
573    } else {
574        sorted.sort_by(sort_fn);
575    }
576
577    for d in sorted {
578        let directive = d.directive();
579        let date = directive.date();
580
581        // Snapshot before ANY errors are pushed for this directive so the
582        // downstream patching loop can enrich every error tied to this
583        // directive — including the ordering / future-date checks below,
584        // not just the ones produced by the per-kind validators
585        // (issue #896). No cost for the unspanned path; the skip-then-
586        // patch loop is bypassed when `span_info()` returns `None`.
587        let error_count_before = errors.len();
588
589        // Date-ordering and future-date checks only run in Early. Date
590        // is independent of booking, and we don't want duplicate errors
591        // when both phases iterate.
592        if phase == Phase::Early {
593            if let Some(last) = state.last_date
594                && date < last
595            {
596                errors.push(ValidationError::new(
597                    ErrorCode::DateOutOfOrder,
598                    format!("Directive date {date} is before previous directive {last}"),
599                    date,
600                ));
601            }
602            state.last_date = Some(date);
603
604            if state.options.warn_future_dates && date > today {
605                errors.push(ValidationError::new(
606                    ErrorCode::FutureDate,
607                    format!("Entry dated in the future: {date}"),
608                    date,
609                ));
610            }
611        }
612
613        match (phase, directive) {
614            // ── Early-only kinds (state setup, structural / presence checks) ──
615            (Phase::Early, Directive::Open(open)) => {
616                validate_open(state, open, &mut errors);
617            }
618            // Late sees plugin-generated Opens (regular plugins run after early),
619            // so the deferred account-presence check on plugin-added postings
620            // recognizes them. No-op for originals already in state from early.
621            (Phase::Late, Directive::Open(open)) => {
622                register_open_late(state, open);
623            }
624            (Phase::Early, Directive::Close(close)) => {
625                validate_close(state, close, &mut errors);
626            }
627            (Phase::Late, Directive::Close(close)) => {
628                validate_close_late(state, close, &mut errors);
629            }
630            (Phase::Early, Directive::Commodity(comm)) => {
631                state.commodities.insert(comm.currency.clone());
632                validate_commodity_precision_meta(comm, &mut errors);
633            }
634            (Phase::Early, Directive::Pad(pad)) => {
635                validate_pad(state, pad, d.span_info(), &mut errors);
636            }
637            (Phase::Early, Directive::Document(doc)) => {
638                let file_id = d.span_info().map(|(_, fid)| fid);
639                validate_document(state, doc, file_id, &document_exists_cache, &mut errors);
640            }
641            (Phase::Early, Directive::Note(note)) => {
642                validate_note(state, note, &mut errors);
643            }
644            // ── Phase-split kinds ──
645            (Phase::Early, Directive::Transaction(txn)) => {
646                validate_transaction_early(state, txn, &mut errors);
647            }
648            (Phase::Late, Directive::Transaction(txn)) => {
649                validate_transaction_late(state, txn, &mut errors);
650            }
651            (Phase::Early, Directive::Balance(bal)) => {
652                validate_balance_early(state, bal, &mut errors);
653            }
654            (Phase::Late, Directive::Balance(bal)) => {
655                validate_balance_late(state, bal, &mut errors);
656            }
657            // ── Everything else: skipped in this phase ──
658            _ => {}
659        }
660
661        // Patch any new errors with location info from the current directive,
662        // and tag plugin-synthesized directives with an advisory note so users
663        // can trace errors that don't correspond to anything in their source
664        // files back to a plugin (see issue #896). Only runs for the
665        // spanned-input path; `Directive`'s `span_info()` returns `None`
666        // so this whole block is a no-op for the CLI / unspanned callers.
667        if let Some((span, file_id)) = d.span_info() {
668            for error in errors.iter_mut().skip(error_count_before) {
669                if error.span.is_none() {
670                    error.span = Some(span);
671                    error.file_id = Some(file_id);
672                }
673                if error.note.is_none() && file_id == SYNTHESIZED_FILE_ID {
674                    error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
675                }
676            }
677        }
678    }
679
680    errors
681}
682
683/// Collect unused-pad errors (E2003). Called once after both phases
684/// have run — pads can be marked `used` by either phase's balance
685/// applications.
686/// Advisory note attached to errors anchored to a plugin-synthesized directive
687/// (`file_id == SYNTHESIZED_FILE_ID`), so the user can trace an error that maps
688/// to nothing in their source files back to a plugin. Shared by the
689/// per-directive patching loop and the deferred [`check_unused_pads`].
690const SYNTHESIZED_DIRECTIVE_NOTE: &str = "directive was synthesized by a plugin (no source location \
691     in your files); the responsible plugin is either an \
692     enabled auto-plugin (e.g. `auto_accounts`, or document \
693     discovery via `option \"documents\"`) or one of your \
694     `plugin \"…\"` declarations";
695
696fn check_unused_pads(state: &LedgerState) -> Vec<ValidationError> {
697    let mut errors = Vec::new();
698    for (target_account, pads) in &state.pending_pads {
699        for pad in pads {
700            if pad.padded_currencies.is_empty() {
701                let mut error = ValidationError::new(
702                    ErrorCode::PadWithoutBalance,
703                    "Unused Pad entry".to_string(),
704                    pad.date,
705                )
706                .with_context(format!(
707                    "   {} pad {} {}",
708                    pad.date, target_account, pad.source_account
709                ));
710                // Anchor the deferred error to the pad's own line (when known)
711                // so it renders with a location instead of `<unknown>:`. A pad
712                // synthesized by a plugin gets the same advisory note the
713                // per-directive patching loop attaches to in-phase errors, so
714                // deferred and in-phase errors stay consistent.
715                if let Some((span, file_id)) = pad.location {
716                    error.span = Some(span);
717                    error.file_id = Some(file_id);
718                    if file_id == SYNTHESIZED_FILE_ID {
719                        error.note = Some(SYNTHESIZED_DIRECTIVE_NOTE.to_string());
720                    }
721                }
722                errors.push(error);
723            }
724        }
725    }
726    errors
727}
728
729/// Pre-resolve each unique `Document` directive's path so the main
730/// per-directive loop can answer "does this document exist?" with a
731/// hashmap lookup instead of a syscall.
732///
733/// Returns a `doc.path -> found` map. Resolution mirrors
734/// [`validators::document::validate_document`]: absolute paths check
735/// themselves; relative paths try `document_base`, then each entry of
736/// `document_dirs` in order with short-circuit on first hit, then fall
737/// back to the path as-is. Two `Document` directives with the same
738/// `path` resolve identically, so the map dedupes naturally.
739///
740/// The per-document resolutions run via [`rayon::par_iter`] above
741/// [`PARALLEL_DOC_EXISTS_THRESHOLD`]; below that, the dispatch
742/// overhead outweighs the I/O parallelism. Crucially the unit of
743/// parallel work is **one Document**, not one candidate path — this
744/// preserves the short-circuit on `document_dirs` so we don't issue
745/// more total syscalls than the pre-fix sequential code did. Caught
746/// by Copilot review on PR #1082.
747///
748/// When `check_documents` is disabled the function short-circuits to
749/// an empty map.
750fn build_document_exists_cache<'a, D: ValidatableDirective>(
751    directives: &'a [D],
752    options: &ValidationOptions,
753) -> FxHashMap<(&'a str, Option<u16>), bool> {
754    if !options.check_documents {
755        return FxHashMap::default();
756    }
757
758    // Collect unique (doc.path, file_id) pairs. Resolution depends on the
759    // directive's source file (see `document_file_exists`), so the key
760    // includes `file_id` — the same relative path in two differently-located
761    // files can resolve to different files. Deduping still saves syscalls
762    // when one (path, file) pair is referenced by multiple directives.
763    let mut keys: FxHashSet<(&str, Option<u16>)> = FxHashSet::default();
764    for d in directives {
765        if let Directive::Document(doc) = d.directive() {
766            let file_id = d.span_info().map(|(_, fid)| fid);
767            keys.insert((doc.path.as_str(), file_id));
768        }
769    }
770    let keys: Vec<(&str, Option<u16>)> = keys.into_iter().collect();
771
772    // One closure-per-key resolves it through the same priority chain the
773    // validator uses (see `document_file_exists`). Stops on the first hit so a
774    // Document found in `document_dirs[0]` still costs exactly one syscall —
775    // matching pre-fix sequential I/O cost, but in parallel across Documents.
776    // Keys borrow `&'a str` from the `directives` slice, so neither the cache
777    // build nor the validator lookup allocates a `String`.
778    let resolve = |(s, file_id): (&'a str, Option<u16>)| {
779        ((s, file_id), document_file_exists(s, file_id, options))
780    };
781
782    if keys.len() >= PARALLEL_DOC_EXISTS_THRESHOLD {
783        keys.into_par_iter().map(resolve).collect()
784    } else {
785        keys.into_iter().map(resolve).collect()
786    }
787}
788
789/// Resolve whether a `document` directive's file exists, using one priority
790/// chain shared by the pre-pass cache and the validator:
791///   1. absolute path → check as-is;
792///   2. `document_base` set → resolve against it;
793///   3. `documents` option dirs non-empty → found if any contains it;
794///   4. otherwise → resolve against the directive's own source-file directory
795///      (matching Beancount, which normalizes at parse time, and `include`),
796///      falling back to the process CWD only when the source directory is
797///      unknown (unspanned directives, or no source map supplied).
798fn document_file_exists(path: &str, file_id: Option<u16>, options: &ValidationOptions) -> bool {
799    let doc_path = std::path::Path::new(path);
800    if doc_path.is_absolute() {
801        doc_path.exists()
802    } else if let Some(base) = &options.document_base {
803        base.join(doc_path).exists()
804    } else if !options.document_dirs.is_empty() {
805        options
806            .document_dirs
807            .iter()
808            .any(|dir| dir.join(doc_path).exists())
809    } else if let Some(dir) = file_id.and_then(|id| options.document_source_dirs.get(id as usize)) {
810        dir.join(doc_path).exists()
811    } else {
812        doc_path.exists()
813    }
814}
815
816// ── Validation entry: [`ValidationSession`] ──────────────────────────────
817//
818// The single supported entry to the validator is [`ValidationSession`].
819// Callers that just want "validate this list of directives, give me all
820// errors" wire four calls: `ValidationSession::new(options)` (constructs
821// `Pending`), `run_early(_, today)` (consumes `Pending`, produces
822// `EarlyDone`), `run_late(_, today)` (consumes `EarlyDone`, produces
823// `LateDone`), `finalize()` (consumes `LateDone`). The visible verbosity
824// is deliberate: it surfaces the phase split so callers can choose
825// where to insert booking between phases (the loader does this) or run
826// all four back-to-back on already-booked input (LSP / FFI / tests do
827// this).
828//
829// Prior versions of this crate exposed `validate()`, `validate_with_options()`,
830// `validate_with_today()`, and spanned variants as free-function
831// shortcuts. They were removed in the validate-phase-split refactor
832// (#1115 / #1116). The runtime phase-ordering bitmask + `debug_assert!`
833// were then replaced with the typestate-driven `Pending` / `EarlyDone`
834// / `LateDone` markers (#1236) so the phase invariant is checked at
835// compile time rather than at runtime.
836
837/// Phantom-typed phase markers for [`ValidationSession`].
838///
839/// These markers track the session's lifecycle position at the type
840/// level. The phase transitions [`ValidationSession::run_early`],
841/// [`ValidationSession::run_late`], and [`ValidationSession::finalize`]
842/// consume the session by value and produce one bound to the next
843/// marker. A caller cannot call `run_late` before `run_early`, cannot
844/// call either phase twice, and cannot call `finalize` before `run_late`
845/// because the relevant method does not exist on the wrong-phase type.
846///
847/// Pre-#1236 the same invariant was enforced at runtime via a bitmask
848/// on `ValidationSession` (`debug_assert!` in debug builds, silent
849/// no-op in release). Compile-time enforcement closes the release-mode
850/// gap and makes the contract self-documenting at call sites.
851///
852/// Known follow-up scope (see issue #1236): the typestate guards the
853/// session lifecycle, but the directive list itself is still a plain
854/// `&[Directive]` / `&[Spanned<Directive>]`. A caller can still pass
855/// pre-booking directives to [`ValidationSession::<EarlyDone>::run_late`]
856/// without a compile-time error. That gap requires phase markers on
857/// the directive collection (mirroring `rustledger-loader`'s
858/// `Directives<Phase>`), which would cross the validate/loader crate
859/// boundary; deferred to a follow-up PR.
860pub mod phase {
861    mod sealed {
862        pub trait Sealed {}
863    }
864
865    /// Marker trait for [`super::ValidationSession`] phase markers.
866    /// Sealed: only the markers in this module implement it.
867    pub trait SessionPhase: sealed::Sealed {}
868
869    macro_rules! define_phase {
870        ($name:ident, $doc:expr) => {
871            #[doc = $doc]
872            #[derive(Debug, Clone, Copy, PartialEq, Eq)]
873            pub struct $name;
874            impl sealed::Sealed for $name {}
875            impl SessionPhase for $name {}
876        };
877    }
878
879    define_phase!(
880        Pending,
881        "Neither phase has run yet; the session was just constructed by [`super::ValidationSession::new`]."
882    );
883    define_phase!(
884        EarlyDone,
885        "[`super::Phase::Early`] has run; [`super::ValidationSession::run_late`] is the only legal next step."
886    );
887    define_phase!(
888        LateDone,
889        "Both phases have run; [`super::ValidationSession::finalize`] is the only legal next step."
890    );
891}
892
893pub use phase::{EarlyDone, LateDone, Pending, SessionPhase};
894
895/// Stateful two-phase validation harness for callers (like the loader)
896/// that need to interleave validation with other pipeline steps.
897///
898/// The session's phase is tracked at the type level via `P:`
899/// [`SessionPhase`] (see the [`phase`] module for the marker types and
900/// the rationale). The standard sequence is:
901///
902/// 1. [`ValidationSession::new`] returns `ValidationSession<Pending>`.
903/// 2. [`run_early`](Self::run_early) consumes `Pending` and returns
904///    `(ValidationSession<EarlyDone>, Vec<ValidationError>)`.
905/// 3. Booking (and the post-booking plugin pass) runs externally on
906///    the directive list.
907/// 4. [`run_late`](Self::run_late) consumes `EarlyDone` and returns
908///    `(ValidationSession<LateDone>, Vec<ValidationError>)`.
909/// 5. [`finalize`](Self::finalize) consumes `LateDone` and returns the
910///    deferred E2003 unused-pad warnings.
911///
912/// Standalone callers that don't run booking between phases (LSP,
913/// FFI, tests) run all four calls back-to-back against the same
914/// directive list. The verbosity is intentional: it surfaces the
915/// phase split so callers explicitly choose whether to interleave
916/// booking between Early and Late.
917///
918/// # Spanned vs. unspanned
919///
920/// Each transition has a `_spanned` variant
921/// ([`run_early_spanned`](ValidationSession::<Pending>::run_early_spanned),
922/// [`run_late_spanned`](ValidationSession::<EarlyDone>::run_late_spanned))
923/// for `&[Spanned<Directive>]` input. The spanned variants preserve
924/// source-location info on emitted errors so callers (LSP, loader,
925/// FFI) can render `file:line:column` diagnostics directly.
926///
927/// # Migration from pre-#1236
928///
929/// Replace:
930///
931/// ```ignore
932/// let mut session = ValidationSession::new(options);
933/// let mut errors = session.run_phase(&directives, Phase::Early, today);
934/// errors.extend(session.run_phase(&directives, Phase::Late, today));
935/// errors.extend(session.finalize());
936/// ```
937///
938/// with:
939///
940/// ```ignore
941/// let session = ValidationSession::new(options);
942/// let (session, mut errors) = session.run_early(&directives, today);
943/// let (session, late_errors) = session.run_late(&directives, today);
944/// errors.extend(late_errors);
945/// errors.extend(session.finalize());
946/// ```
947///
948/// The compile-time enforcement replaces the pre-#1236 runtime
949/// `debug_assert!` + release-mode no-op for phase ordering.
950///
951/// # Example
952///
953/// ```
954/// use rustledger_validate::{ValidationOptions, ValidationSession};
955/// use rustledger_core::{Directive, naive_date};
956///
957/// let directives: Vec<Directive> = vec![];
958/// let today = naive_date(2030, 1, 1).unwrap();
959///
960/// let session = ValidationSession::new(ValidationOptions::default());
961/// let (session, mut errors) = session.run_early(&directives, today);
962/// // ... booking runs here; plugins ran BEFORE Early ...
963/// let (session, late_errors) = session.run_late(&directives, today);
964/// errors.extend(late_errors);
965/// errors.extend(session.finalize());
966/// ```
967pub struct ValidationSession<P: SessionPhase = Pending> {
968    state: LedgerState,
969    _phase: std::marker::PhantomData<P>,
970}
971
972impl<P: SessionPhase> ValidationSession<P> {
973    /// The per-`balance`-assertion computed results recorded during Late
974    /// validation (`diff = computed − asserted`). Populated by the Late balance
975    /// check; call after `run_late`/`run_late_spanned`. Lets consumers (the FFI
976    /// `load` surface) render per-assertion pass/fail without re-deriving the
977    /// balance — #1663.
978    #[must_use]
979    pub fn balance_actuals(&self) -> &[BalanceActual] {
980        &self.state.balance_actuals
981    }
982}
983
984impl ValidationSession<Pending> {
985    /// Create a new session with the given validation options. The
986    /// returned session is bound to the [`Pending`] marker; the only
987    /// legal next step is [`run_early`](Self::run_early) (or its
988    /// spanned variant).
989    #[must_use]
990    pub fn new(options: ValidationOptions) -> Self {
991        Self {
992            state: LedgerState::with_options(options),
993            _phase: std::marker::PhantomData,
994        }
995    }
996
997    /// Run [`Phase::Early`] over a slice of raw [`Directive`]s.
998    ///
999    /// `Early` runs account/structural checks that don't need filled-in
1000    /// amounts. The session's internal `LedgerState` is updated so
1001    /// [`run_late`](ValidationSession::<EarlyDone>::run_late) sees the
1002    /// accumulated state (open accounts, commodities, pending pads).
1003    ///
1004    /// Consumes the session and returns it bound to [`EarlyDone`]
1005    /// alongside the errors collected during the phase. The new phase
1006    /// marker prevents a second `run_early` call at compile time.
1007    #[must_use = "ValidationSession::run_early returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1008    pub fn run_early(
1009        self,
1010        directives: &[Directive],
1011        today: NaiveDate,
1012    ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1013        self.run_phase_internal(directives, Phase::Early, today)
1014    }
1015
1016    /// Variant of [`run_early`](Self::run_early) for
1017    /// `Spanned<Directive>` slices. Preserves source-location info on
1018    /// emitted errors.
1019    #[must_use = "ValidationSession::run_early_spanned returns the next-phase session; dropping it loses the LedgerState built up during Early and any deferred state for Late/finalize"]
1020    pub fn run_early_spanned(
1021        self,
1022        directives: &[Spanned<Directive>],
1023        today: NaiveDate,
1024    ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1025        self.run_phase_internal(directives, Phase::Early, today)
1026    }
1027
1028    /// Internal: run a validation phase and advance to [`EarlyDone`].
1029    ///
1030    /// Threads the underlying `LedgerState` from `Pending` into
1031    /// `EarlyDone` through the shared `validate_phase_inner` engine.
1032    /// The `phase` parameter is always [`Phase::Early`] here; it's
1033    /// passed through so `validate_phase_inner` can dispatch per-phase
1034    /// validator selection inside.
1035    fn run_phase_internal<D: ValidatableDirective>(
1036        mut self,
1037        directives: &[D],
1038        phase: Phase,
1039        today: NaiveDate,
1040    ) -> (ValidationSession<EarlyDone>, Vec<ValidationError>) {
1041        let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1042        (
1043            ValidationSession {
1044                state: self.state,
1045                _phase: std::marker::PhantomData,
1046            },
1047            errors,
1048        )
1049    }
1050}
1051
1052impl ValidationSession<EarlyDone> {
1053    /// Run [`Phase::Late`] over a slice of raw [`Directive`]s.
1054    ///
1055    /// `Late` runs balance/inventory/currency checks that need
1056    /// filled-in amounts. Must be called AFTER booking has run on the
1057    /// directive list (and after the post-booking plugin pass, if any).
1058    ///
1059    /// Consumes the session and returns it bound to [`LateDone`]
1060    /// alongside the errors collected during the phase. The new phase
1061    /// marker prevents a second `run_late` call at compile time.
1062    #[must_use = "ValidationSession::run_late returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1063    pub fn run_late(
1064        self,
1065        directives: &[Directive],
1066        today: NaiveDate,
1067    ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1068        self.run_phase_internal(directives, Phase::Late, today)
1069    }
1070
1071    /// Variant of [`run_late`](Self::run_late) for
1072    /// `Spanned<Directive>` slices. Preserves source-location info on
1073    /// emitted errors.
1074    #[must_use = "ValidationSession::run_late_spanned returns the next-phase session; dropping it discards the deferred E2003 unused-pad warnings that `finalize` would surface"]
1075    pub fn run_late_spanned(
1076        self,
1077        directives: &[Spanned<Directive>],
1078        today: NaiveDate,
1079    ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1080        self.run_phase_internal(directives, Phase::Late, today)
1081    }
1082
1083    /// Internal: run a validation phase and advance to [`LateDone`].
1084    /// See [`ValidationSession::<Pending>::run_phase_internal`] for the
1085    /// rationale on the inner-engine dispatch shape.
1086    fn run_phase_internal<D: ValidatableDirective>(
1087        mut self,
1088        directives: &[D],
1089        phase: Phase,
1090        today: NaiveDate,
1091    ) -> (ValidationSession<LateDone>, Vec<ValidationError>) {
1092        let errors = validate_phase_inner(directives, &mut self.state, phase, today);
1093        (
1094            ValidationSession {
1095                state: self.state,
1096                _phase: std::marker::PhantomData,
1097            },
1098            errors,
1099        )
1100    }
1101}
1102
1103impl ValidationSession<LateDone> {
1104    /// Flush deferred end-of-validation checks. Currently emits unused
1105    /// pad warnings (E2003). Consumes the session because deferred
1106    /// state is per-session.
1107    #[must_use]
1108    pub fn finalize(self) -> Vec<ValidationError> {
1109        check_unused_pads(&self.state)
1110    }
1111}
1112
1113/// Validate the rledger-specific `precision` metadata key on a commodity directive.
1114///
1115/// Per #991, `precision: N` on a `commodity` directive sets a fixed display
1116/// precision for that currency. The loader silently ignores invalid values;
1117/// this validator is the channel that surfaces the problem to the user.
1118fn validate_commodity_precision_meta(comm: &Commodity, errors: &mut Vec<ValidationError>) {
1119    let Some(value) = comm.meta.get("precision") else {
1120        return;
1121    };
1122    if let Err(reason) = rustledger_core::parse_precision_meta(value) {
1123        errors.push(ValidationError::new(
1124            ErrorCode::InvalidPrecisionMetadata,
1125            format!(
1126                "invalid `precision` metadata on commodity {}: {reason}; this declaration is ignored — display precision falls back to `option \"display_precision\"` if set, otherwise to inference",
1127                comm.currency
1128            ),
1129            comm.date,
1130        ));
1131    }
1132}
1133
1134#[cfg(test)]
1135mod tests {
1136    use super::*;
1137    use rust_decimal_macros::dec;
1138    use rustledger_core::{
1139        Amount, Balance, Close, Document, MetaValue, NaiveDate, Open, Pad, Posting, Transaction,
1140    };
1141
1142    fn date(year: i32, month: u32, day: u32) -> NaiveDate {
1143        rustledger_core::naive_date(year, month, day).unwrap()
1144    }
1145
1146    /// Default "today" for tests that don't otherwise care. Set in the
1147    /// past relative to most fixtures so the future-date warning
1148    /// doesn't fire unexpectedly.
1149    fn test_today() -> NaiveDate {
1150        date(2030, 1, 1)
1151    }
1152
1153    /// Test-only convenience: run both phases through a fresh
1154    /// `ValidationSession` and return the combined error list.
1155    /// Mirrors the deleted public `validate()` shortcut. Kept inside
1156    /// `mod tests` so it stays out of the crate's public API.
1157    fn validate(directives: &[Directive]) -> Vec<ValidationError> {
1158        validate_with_options(directives, ValidationOptions::default())
1159    }
1160
1161    /// Test-only convenience: same as [`validate`] but with caller-
1162    /// supplied [`ValidationOptions`].
1163    fn validate_with_options(
1164        directives: &[Directive],
1165        options: ValidationOptions,
1166    ) -> Vec<ValidationError> {
1167        validate_with_today(directives, options, test_today())
1168    }
1169
1170    /// Test-only convenience: same as [`validate_with_options`] but with
1171    /// caller-supplied "today" date (covers tests that exercise
1172    /// future-date / date-ordering behavior).
1173    fn validate_with_today(
1174        directives: &[Directive],
1175        options: ValidationOptions,
1176        today: NaiveDate,
1177    ) -> Vec<ValidationError> {
1178        let session = ValidationSession::new(options);
1179        let (session, mut errors) = session.run_early(directives, today);
1180        let (session, late_errors) = session.run_late(directives, today);
1181        errors.extend(late_errors);
1182        errors.extend(session.finalize());
1183        errors
1184    }
1185
1186    #[test]
1187    fn sum_account_subtree_matches_scan_and_excludes_prefix_siblings() {
1188        // Build inventories + the prefix index exactly as `validate_open` does.
1189        let mut state = LedgerState::default();
1190        let fixture = [
1191            ("Assets:Bank", dec!(10)),
1192            ("Assets:Bank:Checking", dec!(40)),
1193            ("Assets:Bank:Savings", dec!(5)),
1194            ("Assets:BankAlias", dec!(99)), // prefix sibling — must be excluded
1195            ("Assets:Other", dec!(7)),
1196        ];
1197        for (name, amt) in fixture {
1198            let acct = Account::from(name);
1199            let mut inv = Inventory::new();
1200            inv.add(rustledger_core::Position::simple(Amount::new(amt, "USD")));
1201            state.inventories.insert(acct.clone(), inv);
1202            state.inventory_accounts.insert(acct);
1203        }
1204
1205        let cur = Currency::from("USD");
1206        // The indexed sum must equal the unindexed core scan for every target.
1207        for name in [
1208            "Assets:Bank",
1209            "Assets:Bank:Checking",
1210            "Assets:BankAlias",
1211            "Assets:Other",
1212            "Assets:Missing",
1213        ] {
1214            let acct = Account::from(name);
1215            let indexed =
1216                sum_account_subtree(&state.inventories, &state.inventory_accounts, &acct, &cur);
1217            let scan =
1218                rustledger_core::sum_account_and_subaccounts(state.inventories.iter(), name, &cur);
1219            assert_eq!(indexed, scan, "indexed vs scan disagree for {name}");
1220        }
1221
1222        // Parent sums itself + sub-accounts (10 + 40 + 5 = 55), NOT BankAlias.
1223        let bank = sum_account_subtree(
1224            &state.inventories,
1225            &state.inventory_accounts,
1226            &Account::from("Assets:Bank"),
1227            &cur,
1228        );
1229        assert_eq!(
1230            bank,
1231            dec!(55),
1232            "Assets:Bank must sum its subtree, excluding the Assets:BankAlias prefix sibling"
1233        );
1234    }
1235
1236    #[test]
1237    fn test_validate_account_lifecycle() {
1238        let directives = vec![
1239            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1240            Directive::Transaction(
1241                Transaction::new(date(2024, 1, 15), "Test")
1242                    .with_synthesized_posting(Posting::new(
1243                        "Assets:Bank",
1244                        Amount::new(dec!(100), "USD"),
1245                    ))
1246                    .with_synthesized_posting(Posting::new(
1247                        "Income:Salary",
1248                        Amount::new(dec!(-100), "USD"),
1249                    )),
1250            ),
1251        ];
1252
1253        let errors = validate(&directives);
1254
1255        // Should have error: Income:Salary not opened
1256        assert!(errors
1257            .iter()
1258            .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Income:Salary")));
1259    }
1260
1261    #[test]
1262    fn test_validate_account_used_before_open() {
1263        let directives = vec![
1264            Directive::Transaction(
1265                Transaction::new(date(2024, 1, 1), "Test")
1266                    .with_synthesized_posting(Posting::new(
1267                        "Assets:Bank",
1268                        Amount::new(dec!(100), "USD"),
1269                    ))
1270                    .with_synthesized_posting(Posting::new(
1271                        "Income:Salary",
1272                        Amount::new(dec!(-100), "USD"),
1273                    )),
1274            ),
1275            Directive::Open(Open::new(date(2024, 1, 15), "Assets:Bank")),
1276        ];
1277
1278        let errors = validate(&directives);
1279
1280        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen));
1281    }
1282
1283    #[test]
1284    fn test_validate_account_used_after_close() {
1285        let directives = vec![
1286            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1287            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1288            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
1289            Directive::Transaction(
1290                Transaction::new(date(2024, 7, 1), "Test")
1291                    .with_synthesized_posting(Posting::new(
1292                        "Assets:Bank",
1293                        Amount::new(dec!(-50), "USD"),
1294                    ))
1295                    .with_synthesized_posting(Posting::new(
1296                        "Expenses:Food",
1297                        Amount::new(dec!(50), "USD"),
1298                    )),
1299            ),
1300        ];
1301
1302        let errors = validate(&directives);
1303
1304        assert!(errors.iter().any(|e| e.code == ErrorCode::AccountClosed));
1305    }
1306
1307    #[test]
1308    fn test_validate_balance_assertion() {
1309        let directives = vec![
1310            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1311            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1312            Directive::Transaction(
1313                Transaction::new(date(2024, 1, 15), "Deposit")
1314                    .with_synthesized_posting(Posting::new(
1315                        "Assets:Bank",
1316                        Amount::new(dec!(1000.00), "USD"),
1317                    ))
1318                    .with_synthesized_posting(Posting::new(
1319                        "Income:Salary",
1320                        Amount::new(dec!(-1000.00), "USD"),
1321                    )),
1322            ),
1323            Directive::Balance(Balance::new(
1324                date(2024, 1, 16),
1325                "Assets:Bank",
1326                Amount::new(dec!(1000.00), "USD"),
1327            )),
1328        ];
1329
1330        let errors = validate(&directives);
1331        assert!(errors.is_empty(), "{errors:?}");
1332    }
1333
1334    #[test]
1335    fn test_validate_balance_assertion_failed() {
1336        let directives = vec![
1337            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1338            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1339            Directive::Transaction(
1340                Transaction::new(date(2024, 1, 15), "Deposit")
1341                    .with_synthesized_posting(Posting::new(
1342                        "Assets:Bank",
1343                        Amount::new(dec!(1000.00), "USD"),
1344                    ))
1345                    .with_synthesized_posting(Posting::new(
1346                        "Income:Salary",
1347                        Amount::new(dec!(-1000.00), "USD"),
1348                    )),
1349            ),
1350            Directive::Balance(Balance::new(
1351                date(2024, 1, 16),
1352                "Assets:Bank",
1353                Amount::new(dec!(500.00), "USD"), // Wrong!
1354            )),
1355        ];
1356
1357        let errors = validate(&directives);
1358        assert!(
1359            errors
1360                .iter()
1361                .any(|e| e.code == ErrorCode::BalanceAssertionFailed)
1362        );
1363    }
1364
1365    /// Test that balance assertions use inferred tolerance (matching Python beancount).
1366    ///
1367    /// Tolerance is derived from the balance assertion amount's precision, then multiplied by 2.
1368    /// See: <https://github.com/beancount/beancount/blob/master/beancount/ops/balance.py>
1369    /// Balance assertion with 2 decimal places: tolerance = 0.5 * 2 * 10^(-2) = 0.01.
1370    #[test]
1371    fn test_validate_balance_assertion_within_tolerance() {
1372        // Actual balance is 70.538, assertion is 70.53 (2 decimal places)
1373        // Tolerance is derived from balance assertion: 0.5 * 2 * 10^(-2) = 0.01
1374        // Difference is 0.008, which is less than tolerance (0.01)
1375        // This should PASS (matching Python beancount behavior from issue #251)
1376        let directives = vec![
1377            Directive::Open(
1378                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1379            ),
1380            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1381            Directive::Transaction(
1382                Transaction::new(date(2024, 1, 15), "Deposit")
1383                    .with_synthesized_posting(Posting::new(
1384                        "Assets:Bank",
1385                        Amount::new(dec!(70.538), "ABC"), // 3 decimal places in transaction
1386                    ))
1387                    .with_synthesized_posting(Posting::new(
1388                        "Expenses:Misc",
1389                        Amount::new(dec!(-70.538), "ABC"),
1390                    )),
1391            ),
1392            Directive::Balance(Balance::new(
1393                date(2024, 1, 16),
1394                "Assets:Bank",
1395                Amount::new(dec!(70.53), "ABC"), // 2 decimal places → tolerance = 0.01, diff = 0.008 < 0.01
1396            )),
1397        ];
1398
1399        let errors = validate(&directives);
1400        assert!(
1401            errors.is_empty(),
1402            "Balance within tolerance should pass: {errors:?}"
1403        );
1404    }
1405
1406    /// Test that balance assertions fail when exceeding tolerance.
1407    #[test]
1408    fn test_validate_balance_assertion_exceeds_tolerance() {
1409        // Actual balance is 70.538, assertion is 70.53 with explicit precision
1410        // Balance assertion has 2 decimal places: tolerance = 0.5 * 2 * 10^(-2) = 0.01
1411        // Difference is 0.012, which exceeds tolerance
1412        // This should FAIL
1413        let directives = vec![
1414            Directive::Open(
1415                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["ABC".into()]),
1416            ),
1417            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Misc")),
1418            Directive::Transaction(
1419                Transaction::new(date(2024, 1, 15), "Deposit")
1420                    .with_synthesized_posting(Posting::new(
1421                        "Assets:Bank",
1422                        Amount::new(dec!(70.542), "ABC"),
1423                    ))
1424                    .with_synthesized_posting(Posting::new(
1425                        "Expenses:Misc",
1426                        Amount::new(dec!(-70.542), "ABC"),
1427                    )),
1428            ),
1429            Directive::Balance(Balance::new(
1430                date(2024, 1, 16),
1431                "Assets:Bank",
1432                Amount::new(dec!(70.53), "ABC"), // 2 decimal places → tolerance = 0.01, diff = 0.012 > 0.01
1433            )),
1434        ];
1435
1436        let errors = validate(&directives);
1437        assert!(
1438            errors
1439                .iter()
1440                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
1441            "Balance exceeding tolerance should fail"
1442        );
1443    }
1444
1445    #[test]
1446    fn test_validate_unbalanced_transaction() {
1447        let directives = vec![
1448            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1449            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
1450            Directive::Transaction(
1451                Transaction::new(date(2024, 1, 15), "Unbalanced")
1452                    .with_synthesized_posting(Posting::new(
1453                        "Assets:Bank",
1454                        Amount::new(dec!(-50.00), "USD"),
1455                    ))
1456                    .with_synthesized_posting(Posting::new(
1457                        "Expenses:Food",
1458                        Amount::new(dec!(40.00), "USD"),
1459                    )), // Missing $10
1460            ),
1461        ];
1462
1463        let errors = validate(&directives);
1464        assert!(
1465            errors
1466                .iter()
1467                .any(|e| e.code == ErrorCode::TransactionUnbalanced)
1468        );
1469    }
1470
1471    #[test]
1472    fn test_validate_currency_not_allowed() {
1473        let directives = vec![
1474            Directive::Open(
1475                Open::new(date(2024, 1, 1), "Assets:Bank").with_currencies(vec!["USD".into()]),
1476            ),
1477            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1478            Directive::Transaction(
1479                Transaction::new(date(2024, 1, 15), "Test")
1480                    .with_synthesized_posting(Posting::new("Assets:Bank", Amount::new(dec!(100.00), "EUR"))) // EUR not allowed!
1481                    .with_synthesized_posting(Posting::new(
1482                        "Income:Salary",
1483                        Amount::new(dec!(-100.00), "EUR"),
1484                    )),
1485            ),
1486        ];
1487
1488        let errors = validate(&directives);
1489        assert!(
1490            errors
1491                .iter()
1492                .any(|e| e.code == ErrorCode::CurrencyNotAllowed)
1493        );
1494    }
1495
1496    #[test]
1497    fn test_validate_balance_wrong_currency() {
1498        // #1668: a balance asserted in a currency the account doesn't allow is
1499        // flagged with a dedicated diagnostic (not only "Balance failed").
1500        let directives = vec![
1501            Directive::Open(
1502                Open::new(date(2024, 1, 1), "Assets:Cash").with_currencies(vec!["USD".into()]),
1503            ),
1504            Directive::Balance(Balance::new(
1505                date(2024, 3, 1),
1506                "Assets:Cash",
1507                Amount::new(dec!(100), "EUR"),
1508            )),
1509        ];
1510        let errors = validate(&directives);
1511        assert!(
1512            errors
1513                .iter()
1514                .any(|e| e.code == ErrorCode::CurrencyNotAllowed
1515                    && e.message.contains("for Balance directive")),
1516            "balance in a non-allowed currency should be flagged (#1668); got: {:?}",
1517            errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1518        );
1519    }
1520
1521    #[test]
1522    fn test_validate_balance_currency_allowed_when_unconstrained() {
1523        // An account opened without a currency constraint allows any balance
1524        // currency — no false positive (#1668).
1525        let directives = vec![
1526            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1527            Directive::Balance(Balance::new(
1528                date(2024, 3, 1),
1529                "Assets:Cash",
1530                Amount::new(dec!(0), "EUR"),
1531            )),
1532        ];
1533        let errors = validate(&directives);
1534        assert!(
1535            !errors
1536                .iter()
1537                .any(|e| e.code == ErrorCode::CurrencyNotAllowed),
1538            "unconstrained account must not flag balance currency (#1668); got: {:?}",
1539            errors.iter().map(|e| &e.message).collect::<Vec<_>>()
1540        );
1541    }
1542
1543    #[test]
1544    fn test_validate_future_date_warning() {
1545        // Anchor "today" so this test isn't time-dependent. The
1546        // directive is 30 days after the anchor — unambiguously in
1547        // the future from `today`'s perspective.
1548        let today = date(2024, 1, 1);
1549        let future_date = today.checked_add(jiff::ToSpan::days(30)).unwrap();
1550
1551        let directives = vec![Directive::Open(Open {
1552            date: future_date,
1553            account: "Assets:Bank".into(),
1554            currencies: vec![],
1555            booking: None,
1556            meta: Default::default(),
1557        })];
1558
1559        // Without warn_future_dates option, no warnings
1560        let errors = validate_with_today(&directives, ValidationOptions::default(), today);
1561        assert!(
1562            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1563            "Should not warn about future dates by default"
1564        );
1565
1566        // With warn_future_dates option, should warn
1567        let options = ValidationOptions::default().with_warn_future_dates(true);
1568        let errors = validate_with_today(&directives, options, today);
1569        assert!(
1570            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1571            "Should warn about future dates when enabled"
1572        );
1573    }
1574
1575    /// `validate_with_today` is the LSP-friendly entry point that
1576    /// accepts the "today" date as a parameter instead of calling
1577    /// `jiff::Zoned::now()` internally. Verify it threads the parameter
1578    /// through correctly: with `today` set BEFORE the directive's date,
1579    /// the directive is in the future relative to `today`; with `today`
1580    /// set AFTER, the directive is in the past.
1581    #[test]
1582    fn test_validate_with_today_threads_today_parameter() {
1583        let directives = vec![Directive::Open(Open {
1584            date: date(2024, 6, 15),
1585            account: "Assets:Bank".into(),
1586            currencies: vec![],
1587            booking: None,
1588            meta: Default::default(),
1589        })];
1590        let options = ValidationOptions::default().with_warn_future_dates(true);
1591
1592        // today = 2024-01-01 → directive at 2024-06-15 is in the future
1593        let errors = validate_with_today(&directives, options.clone(), date(2024, 1, 1));
1594        assert!(
1595            errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1596            "with today=2024-01-01 the 2024-06-15 directive must trigger a FutureDate warning"
1597        );
1598
1599        // today = 2025-01-01 → directive at 2024-06-15 is in the past
1600        let errors = validate_with_today(&directives, options, date(2025, 1, 1));
1601        assert!(
1602            !errors.iter().any(|e| e.code == ErrorCode::FutureDate),
1603            "with today=2025-01-01 the 2024-06-15 directive must not trigger a FutureDate warning"
1604        );
1605    }
1606
1607    #[test]
1608    fn test_validate_document_not_found() {
1609        let directives = vec![
1610            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1611            Directive::Document(Document {
1612                date: date(2024, 1, 15),
1613                account: "Assets:Bank".into(),
1614                path: "/nonexistent/path/to/document.pdf".to_string(),
1615                tags: vec![],
1616                links: vec![],
1617                meta: Default::default(),
1618            }),
1619        ];
1620
1621        // With default options (check_documents: true), should error
1622        let errors = validate(&directives);
1623        assert!(
1624            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1625            "Should check documents by default"
1626        );
1627
1628        // With check_documents disabled, should not error
1629        let options = ValidationOptions::default().with_check_documents(false);
1630        let errors = validate_with_options(&directives, options);
1631        assert!(
1632            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1633            "Should not report missing document when disabled"
1634        );
1635    }
1636
1637    #[test]
1638    fn test_validate_document_account_not_open() {
1639        let directives = vec![Directive::Document(Document {
1640            date: date(2024, 1, 15),
1641            account: "Assets:Unknown".into(),
1642            path: "receipt.pdf".to_string(),
1643            tags: vec![],
1644            links: vec![],
1645            meta: Default::default(),
1646        })];
1647
1648        let errors = validate(&directives);
1649        assert!(
1650            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen),
1651            "Should error for document on unopened account"
1652        );
1653    }
1654
1655    #[test]
1656    fn test_validate_document_relative_path_in_document_dirs() {
1657        // Use a unique filename so the CWD fallback (triggered when
1658        // document_dirs is empty) doesn't pick up a same-named file that
1659        // happens to exist in the test runner's working directory.
1660        let filename = "rustledger_test_889_relative_receipt.pdf";
1661        let dir = tempfile::tempdir().unwrap();
1662        let doc_subdir = dir.path().join("documents");
1663        std::fs::create_dir_all(&doc_subdir).unwrap();
1664        std::fs::write(doc_subdir.join(filename), "test").unwrap();
1665
1666        let directives = vec![
1667            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1668            Directive::Document(Document {
1669                date: date(2024, 1, 15),
1670                account: "Assets:Bank".into(),
1671                path: filename.to_string(),
1672                tags: vec![],
1673                links: vec![],
1674                meta: Default::default(),
1675            }),
1676        ];
1677
1678        // Without document_dirs, should fail
1679        let errors = validate(&directives);
1680        assert!(
1681            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1682            "Should error when document_dirs not set"
1683        );
1684
1685        // With document_dirs pointing to the directory, should pass
1686        let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1687        let errors = validate_with_options(&directives, options);
1688        assert!(
1689            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1690            "Should find document in document_dirs: {errors:?}"
1691        );
1692    }
1693
1694    #[test]
1695    fn test_validate_document_relative_path_not_found_in_dirs() {
1696        // Use a unique filename — see comment in the sibling test above.
1697        let filename = "rustledger_test_889_nonexistent.pdf";
1698        let dir = tempfile::tempdir().unwrap();
1699        let doc_subdir = dir.path().join("documents");
1700        std::fs::create_dir_all(&doc_subdir).unwrap();
1701
1702        let directives = vec![
1703            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1704            Directive::Document(Document {
1705                date: date(2024, 1, 15),
1706                account: "Assets:Bank".into(),
1707                path: filename.to_string(),
1708                tags: vec![],
1709                links: vec![],
1710                meta: Default::default(),
1711            }),
1712        ];
1713
1714        let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1715        let errors = validate_with_options(&directives, options);
1716        assert!(
1717            errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1718            "Should error when file not found in any document_dir"
1719        );
1720    }
1721
1722    #[test]
1723    fn test_validate_document_absolute_path_ignores_document_dirs() {
1724        let filename = "rustledger_test_889_absolute_receipt.pdf";
1725        let dir = tempfile::tempdir().unwrap();
1726        let doc_subdir = dir.path().join("documents");
1727        std::fs::create_dir_all(&doc_subdir).unwrap();
1728        std::fs::write(doc_subdir.join(filename), "test").unwrap();
1729
1730        let directives = vec![
1731            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1732            Directive::Document(Document {
1733                date: date(2024, 1, 15),
1734                account: "Assets:Bank".into(),
1735                path: doc_subdir.join(filename).display().to_string(),
1736                tags: vec![],
1737                links: vec![],
1738                meta: Default::default(),
1739            }),
1740        ];
1741
1742        // Absolute path should work regardless of document_dirs
1743        let options = ValidationOptions::default()
1744            .with_document_dirs(vec![std::path::PathBuf::from("/nonexistent/path")]);
1745        let errors = validate_with_options(&directives, options);
1746        assert!(
1747            !errors.iter().any(|e| e.code == ErrorCode::DocumentNotFound),
1748            "Absolute path should work even with wrong document_dirs: {errors:?}"
1749        );
1750    }
1751
1752    /// Regression test for the parallel `Path::exists()` pre-pass.
1753    /// Constructs enough Document directives (mix of found + missing)
1754    /// to cross `PARALLEL_DOC_EXISTS_THRESHOLD` and confirms that:
1755    ///
1756    /// 1. The found documents validate without `DocumentNotFound`.
1757    /// 2. The missing documents still report `DocumentNotFound`.
1758    /// 3. The error-context "searched: ..." message survives the
1759    ///    cache-routed code path (was constructed inline before).
1760    #[test]
1761    fn test_validate_document_parallel_batch_check() {
1762        let dir = tempfile::tempdir().unwrap();
1763        let doc_subdir = dir.path().join("docs");
1764        std::fs::create_dir_all(&doc_subdir).unwrap();
1765
1766        // PARALLEL_DOC_EXISTS_THRESHOLD = 64. Generate 100 documents:
1767        // even-numbered exist, odd-numbered don't.
1768        let mut directives: Vec<Directive> =
1769            vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank"))];
1770        for i in 0..100 {
1771            let filename = format!("receipt_{i}.pdf");
1772            if i % 2 == 0 {
1773                std::fs::write(doc_subdir.join(&filename), "x").unwrap();
1774            }
1775            directives.push(Directive::Document(Document {
1776                date: date(2024, 1, 15),
1777                account: "Assets:Bank".into(),
1778                path: filename,
1779                tags: vec![],
1780                links: vec![],
1781                meta: Default::default(),
1782            }));
1783        }
1784
1785        let options = ValidationOptions::default().with_document_dirs(vec![doc_subdir]);
1786        let errors = validate_with_options(&directives, options);
1787
1788        let not_found_count = errors
1789            .iter()
1790            .filter(|e| e.code == ErrorCode::DocumentNotFound)
1791            .count();
1792        assert_eq!(
1793            not_found_count, 50,
1794            "exactly 50 of 100 documents should error as not-found"
1795        );
1796
1797        // Spot-check that the error context message still mentions the
1798        // searched document_dirs path (it's built from
1799        // state.options.document_dirs, independently of the cache).
1800        let example = errors
1801            .iter()
1802            .find(|e| e.code == ErrorCode::DocumentNotFound)
1803            .expect("should have at least one not-found error");
1804        assert!(
1805            example
1806                .context
1807                .as_deref()
1808                .is_some_and(|c| c.contains("searched")),
1809            "error context should mention the searched dirs, got: {:?}",
1810            example.context
1811        );
1812    }
1813
1814    #[test]
1815    fn test_error_code_is_warning() {
1816        assert!(!ErrorCode::AccountNotOpen.is_warning());
1817        assert!(!ErrorCode::DocumentNotFound.is_warning());
1818        assert!(ErrorCode::FutureDate.is_warning());
1819    }
1820
1821    #[test]
1822    fn test_validate_pad_basic() {
1823        let directives = vec![
1824            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1825            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1826            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1827            Directive::Balance(Balance::new(
1828                date(2024, 1, 2),
1829                "Assets:Bank",
1830                Amount::new(dec!(1000.00), "USD"),
1831            )),
1832        ];
1833
1834        let errors = validate(&directives);
1835        // Should have no errors - pad should satisfy the balance
1836        assert!(errors.is_empty(), "Pad should satisfy balance: {errors:?}");
1837    }
1838
1839    #[test]
1840    fn test_validate_pad_with_existing_balance() {
1841        let directives = vec![
1842            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1843            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1844            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1845            // Add some initial transactions
1846            Directive::Transaction(
1847                Transaction::new(date(2024, 1, 5), "Initial deposit")
1848                    .with_synthesized_posting(Posting::new(
1849                        "Assets:Bank",
1850                        Amount::new(dec!(500.00), "USD"),
1851                    ))
1852                    .with_synthesized_posting(Posting::new(
1853                        "Income:Salary",
1854                        Amount::new(dec!(-500.00), "USD"),
1855                    )),
1856            ),
1857            // Pad to reach the target balance
1858            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1859            Directive::Balance(Balance::new(
1860                date(2024, 1, 15),
1861                "Assets:Bank",
1862                Amount::new(dec!(1000.00), "USD"), // Need to add 500 more
1863            )),
1864        ];
1865
1866        let errors = validate(&directives);
1867        // Should have no errors - pad should add the missing 500
1868        assert!(
1869            errors.is_empty(),
1870            "Pad should add missing amount: {errors:?}"
1871        );
1872    }
1873
1874    #[test]
1875    fn test_validate_pad_account_not_open() {
1876        let directives = vec![
1877            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1878            // Assets:Bank not opened
1879            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1880        ];
1881
1882        let errors = validate(&directives);
1883        assert!(
1884            errors
1885                .iter()
1886                .any(|e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Assets:Bank")),
1887            "Should error for pad on unopened account"
1888        );
1889    }
1890
1891    #[test]
1892    fn test_validate_pad_source_not_open() {
1893        let directives = vec![
1894            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1895            // Equity:Opening not opened
1896            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
1897        ];
1898
1899        let errors = validate(&directives);
1900        assert!(
1901            errors.iter().any(
1902                |e| e.code == ErrorCode::AccountNotOpen && e.message.contains("Equity:Opening")
1903            ),
1904            "Should error for pad with unopened source account"
1905        );
1906    }
1907
1908    #[test]
1909    fn test_validate_pad_negative_adjustment() {
1910        // Test that pad can reduce a balance too
1911        let directives = vec![
1912            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
1913            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
1914            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
1915            // Add more than needed
1916            Directive::Transaction(
1917                Transaction::new(date(2024, 1, 5), "Big deposit")
1918                    .with_synthesized_posting(Posting::new(
1919                        "Assets:Bank",
1920                        Amount::new(dec!(2000.00), "USD"),
1921                    ))
1922                    .with_synthesized_posting(Posting::new(
1923                        "Income:Salary",
1924                        Amount::new(dec!(-2000.00), "USD"),
1925                    )),
1926            ),
1927            // Pad to reach a lower target
1928            Directive::Pad(Pad::new(date(2024, 1, 10), "Assets:Bank", "Equity:Opening")),
1929            Directive::Balance(Balance::new(
1930                date(2024, 1, 15),
1931                "Assets:Bank",
1932                Amount::new(dec!(1000.00), "USD"), // Need to remove 1000
1933            )),
1934        ];
1935
1936        let errors = validate(&directives);
1937        assert!(
1938            errors.is_empty(),
1939            "Pad should handle negative adjustment: {errors:?}"
1940        );
1941    }
1942
1943    #[test]
1944    fn test_validate_insufficient_units() {
1945        use rustledger_core::CostSpec;
1946
1947        let cost_spec = CostSpec::empty()
1948            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
1949            .with_currency("USD");
1950
1951        let directives = vec![
1952            Directive::Open(
1953                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1954            ),
1955            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
1956            // Buy 10 shares
1957            Directive::Transaction(
1958                Transaction::new(date(2024, 1, 15), "Buy")
1959                    .with_synthesized_posting(
1960                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
1961                            .with_cost(cost_spec.clone()),
1962                    )
1963                    .with_synthesized_posting(Posting::new(
1964                        "Assets:Cash",
1965                        Amount::new(dec!(-1500), "USD"),
1966                    )),
1967            ),
1968            // Try to sell 15 shares (more than we have)
1969            Directive::Transaction(
1970                Transaction::new(date(2024, 6, 1), "Sell too many")
1971                    .with_synthesized_posting(
1972                        Posting::new("Assets:Stock", Amount::new(dec!(-15), "AAPL"))
1973                            .with_cost(cost_spec),
1974                    )
1975                    .with_synthesized_posting(Posting::new(
1976                        "Assets:Cash",
1977                        Amount::new(dec!(2250), "USD"),
1978                    )),
1979            ),
1980        ];
1981
1982        let errors = validate(&directives);
1983        assert!(
1984            errors
1985                .iter()
1986                .any(|e| e.code == ErrorCode::InsufficientUnits),
1987            "Should error for insufficient units: {errors:?}"
1988        );
1989    }
1990
1991    #[test]
1992    fn test_validate_no_matching_lot() {
1993        use rustledger_core::CostSpec;
1994
1995        let directives = vec![
1996            Directive::Open(
1997                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
1998            ),
1999            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2000            // Buy at $150
2001            Directive::Transaction(
2002                Transaction::new(date(2024, 1, 15), "Buy")
2003                    .with_synthesized_posting(
2004                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL")).with_cost(
2005                            CostSpec::empty()
2006                                .with_number(rustledger_core::CostNumber::PerUnit {
2007                                    value: dec!(150),
2008                                })
2009                                .with_currency("USD"),
2010                        ),
2011                    )
2012                    .with_synthesized_posting(Posting::new(
2013                        "Assets:Cash",
2014                        Amount::new(dec!(-1500), "USD"),
2015                    )),
2016            ),
2017            // Try to sell at $160 (no lot at this price)
2018            Directive::Transaction(
2019                Transaction::new(date(2024, 6, 1), "Sell at wrong price")
2020                    .with_synthesized_posting(
2021                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL")).with_cost(
2022                            CostSpec::empty()
2023                                .with_number(rustledger_core::CostNumber::PerUnit {
2024                                    value: dec!(160),
2025                                })
2026                                .with_currency("USD"),
2027                        ),
2028                    )
2029                    .with_synthesized_posting(Posting::new(
2030                        "Assets:Cash",
2031                        Amount::new(dec!(800), "USD"),
2032                    )),
2033            ),
2034        ];
2035
2036        let errors = validate(&directives);
2037        assert!(
2038            errors.iter().any(|e| e.code == ErrorCode::NoMatchingLot),
2039            "Should error for no matching lot: {errors:?}"
2040        );
2041    }
2042
2043    #[test]
2044    fn test_validate_multiple_lot_match_uses_fifo() {
2045        // In Python beancount, when multiple lots match the same cost spec,
2046        // STRICT mode falls back to FIFO order rather than erroring.
2047        use rustledger_core::CostSpec;
2048
2049        let cost_spec = CostSpec::empty()
2050            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2051            .with_currency("USD");
2052
2053        let directives = vec![
2054            Directive::Open(
2055                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("STRICT".to_string()),
2056            ),
2057            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2058            // Buy at $150 on Jan 15
2059            Directive::Transaction(
2060                Transaction::new(date(2024, 1, 15), "Buy lot 1")
2061                    .with_synthesized_posting(
2062                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2063                            .with_cost(cost_spec.clone().with_date(date(2024, 1, 15))),
2064                    )
2065                    .with_synthesized_posting(Posting::new(
2066                        "Assets:Cash",
2067                        Amount::new(dec!(-1500), "USD"),
2068                    )),
2069            ),
2070            // Buy again at $150 on Feb 15 (creates second lot at same price)
2071            Directive::Transaction(
2072                Transaction::new(date(2024, 2, 15), "Buy lot 2")
2073                    .with_synthesized_posting(
2074                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2075                            .with_cost(cost_spec.clone().with_date(date(2024, 2, 15))),
2076                    )
2077                    .with_synthesized_posting(Posting::new(
2078                        "Assets:Cash",
2079                        Amount::new(dec!(-1500), "USD"),
2080                    )),
2081            ),
2082            // Sell with cost spec that matches both lots - STRICT falls back to FIFO
2083            Directive::Transaction(
2084                Transaction::new(date(2024, 6, 1), "Sell using FIFO fallback")
2085                    .with_synthesized_posting(
2086                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2087                            .with_cost(cost_spec),
2088                    )
2089                    .with_synthesized_posting(Posting::new(
2090                        "Assets:Cash",
2091                        Amount::new(dec!(750), "USD"),
2092                    )),
2093            ),
2094        ];
2095
2096        let errors = validate(&directives);
2097        // Filter out only booking errors - balance may or may not match
2098        let booking_errors: Vec<_> = errors
2099            .iter()
2100            .filter(|e| {
2101                matches!(
2102                    e.code,
2103                    ErrorCode::InsufficientUnits
2104                        | ErrorCode::NoMatchingLot
2105                        | ErrorCode::AmbiguousLotMatch
2106                )
2107            })
2108            .collect();
2109        assert!(
2110            booking_errors.is_empty(),
2111            "Should not have booking errors when multiple lots match (FIFO fallback): {booking_errors:?}"
2112        );
2113    }
2114
2115    #[test]
2116    fn test_validate_successful_booking() {
2117        use rustledger_core::CostSpec;
2118
2119        let cost_spec = CostSpec::empty()
2120            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2121            .with_currency("USD");
2122
2123        let directives = vec![
2124            Directive::Open(
2125                Open::new(date(2024, 1, 1), "Assets:Stock").with_booking("FIFO".to_string()),
2126            ),
2127            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Cash")),
2128            // Buy 10 shares
2129            Directive::Transaction(
2130                Transaction::new(date(2024, 1, 15), "Buy")
2131                    .with_synthesized_posting(
2132                        Posting::new("Assets:Stock", Amount::new(dec!(10), "AAPL"))
2133                            .with_cost(cost_spec.clone()),
2134                    )
2135                    .with_synthesized_posting(Posting::new(
2136                        "Assets:Cash",
2137                        Amount::new(dec!(-1500), "USD"),
2138                    )),
2139            ),
2140            // Sell 5 shares (should succeed with FIFO)
2141            Directive::Transaction(
2142                Transaction::new(date(2024, 6, 1), "Sell")
2143                    .with_synthesized_posting(
2144                        Posting::new("Assets:Stock", Amount::new(dec!(-5), "AAPL"))
2145                            .with_cost(cost_spec),
2146                    )
2147                    .with_synthesized_posting(Posting::new(
2148                        "Assets:Cash",
2149                        Amount::new(dec!(750), "USD"),
2150                    )),
2151            ),
2152        ];
2153
2154        let errors = validate(&directives);
2155        // Filter out any balance errors (we're testing booking only)
2156        let booking_errors: Vec<_> = errors
2157            .iter()
2158            .filter(|e| {
2159                matches!(
2160                    e.code,
2161                    ErrorCode::InsufficientUnits
2162                        | ErrorCode::NoMatchingLot
2163                        | ErrorCode::AmbiguousLotMatch
2164                )
2165            })
2166            .collect();
2167        assert!(
2168            booking_errors.is_empty(),
2169            "Should have no booking errors: {booking_errors:?}"
2170        );
2171    }
2172
2173    #[test]
2174    fn test_validate_account_already_open() {
2175        let directives = vec![
2176            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2177            Directive::Open(Open::new(date(2024, 6, 1), "Assets:Bank")), // Duplicate!
2178        ];
2179
2180        let errors = validate(&directives);
2181        assert!(
2182            errors
2183                .iter()
2184                .any(|e| e.code == ErrorCode::AccountAlreadyOpen),
2185            "Should error for duplicate open: {errors:?}"
2186        );
2187    }
2188
2189    #[test]
2190    fn test_validate_account_close_not_empty() {
2191        let directives = vec![
2192            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2193            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2194            Directive::Transaction(
2195                Transaction::new(date(2024, 1, 15), "Deposit")
2196                    .with_synthesized_posting(Posting::new(
2197                        "Assets:Bank",
2198                        Amount::new(dec!(100.00), "USD"),
2199                    ))
2200                    .with_synthesized_posting(Posting::new(
2201                        "Income:Salary",
2202                        Amount::new(dec!(-100.00), "USD"),
2203                    )),
2204            ),
2205            Directive::Close(Close::new(date(2024, 12, 31), "Assets:Bank")), // Still has 100 USD
2206        ];
2207
2208        let errors = validate(&directives);
2209        assert!(
2210            errors
2211                .iter()
2212                .any(|e| e.code == ErrorCode::AccountCloseNotEmpty),
2213            "Should warn for closing account with balance: {errors:?}"
2214        );
2215    }
2216
2217    #[test]
2218    fn test_validate_no_postings_allowed() {
2219        // Python beancount allows transactions with no postings (metadata-only).
2220        // We match this behavior.
2221        let directives = vec![
2222            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2223            Directive::Transaction(Transaction::new(date(2024, 1, 15), "Empty")),
2224        ];
2225
2226        let errors = validate(&directives);
2227        assert!(
2228            !errors.iter().any(|e| e.code == ErrorCode::NoPostings),
2229            "Should NOT error for transaction with no postings: {errors:?}"
2230        );
2231    }
2232
2233    #[test]
2234    fn test_validate_single_posting() {
2235        let directives = vec![
2236            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2237            Directive::Transaction(
2238                Transaction::new(date(2024, 1, 15), "Single").with_synthesized_posting(
2239                    Posting::new("Assets:Bank", Amount::new(dec!(100.00), "USD")),
2240                ),
2241            ),
2242        ];
2243
2244        let errors = validate(&directives);
2245        assert!(
2246            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2247            "Should warn for transaction with single posting: {errors:?}"
2248        );
2249        // Check it's a warning not error
2250        assert!(ErrorCode::SinglePosting.is_warning());
2251    }
2252
2253    #[test]
2254    fn test_validate_single_posting_zero_cost_no_warning() {
2255        // A transaction with a single posting that has {0 USD} cost should not
2256        // warn about single posting — the counterpart was removed during
2257        // zero-cost interpolation.
2258        let directives = vec![
2259            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2260            Directive::Transaction(
2261                Transaction::new(date(2024, 1, 15), "Grant").with_synthesized_posting(
2262                    Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2263                        rustledger_core::CostSpec::empty()
2264                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(0) })
2265                            .with_currency("USD"),
2266                    ),
2267                ),
2268            ),
2269        ];
2270
2271        let errors = validate(&directives);
2272        assert!(
2273            !errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2274            "Should NOT warn for zero-cost single posting: {errors:?}"
2275        );
2276    }
2277
2278    #[test]
2279    fn test_validate_single_posting_nonzero_cost_still_warns() {
2280        // A single posting with a NON-zero cost should still warn
2281        let directives = vec![
2282            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Stock")),
2283            Directive::Transaction(
2284                Transaction::new(date(2024, 1, 15), "Buy").with_synthesized_posting(
2285                    Posting::new("Assets:Stock", Amount::new(dec!(100), "AAPL")).with_cost(
2286                        rustledger_core::CostSpec::empty()
2287                            .with_number(rustledger_core::CostNumber::PerUnit { value: dec!(150) })
2288                            .with_currency("USD"),
2289                    ),
2290                ),
2291            ),
2292        ];
2293
2294        let errors = validate(&directives);
2295        assert!(
2296            errors.iter().any(|e| e.code == ErrorCode::SinglePosting),
2297            "Should warn for single posting with non-zero cost: {errors:?}"
2298        );
2299    }
2300
2301    #[test]
2302    fn test_validate_pad_without_balance() {
2303        let directives = vec![
2304            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2305            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2306            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2307            // No balance assertion follows!
2308        ];
2309
2310        let errors = validate(&directives);
2311        assert!(
2312            errors
2313                .iter()
2314                .any(|e| e.code == ErrorCode::PadWithoutBalance),
2315            "Should error for pad without subsequent balance: {errors:?}"
2316        );
2317    }
2318
2319    #[test]
2320    fn test_validate_multiple_pads_for_balance() {
2321        let directives = vec![
2322            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2323            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2324            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2325            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")), // Second pad!
2326            Directive::Balance(Balance::new(
2327                date(2024, 1, 3),
2328                "Assets:Bank",
2329                Amount::new(dec!(1000.00), "USD"),
2330            )),
2331        ];
2332
2333        let errors = validate(&directives);
2334        assert!(
2335            errors
2336                .iter()
2337                .any(|e| e.code == ErrorCode::MultiplePadForBalance),
2338            "Should error for multiple pads before balance: {errors:?}"
2339        );
2340    }
2341
2342    #[test]
2343    fn test_e2004_fires_after_prior_balance_consumed_a_pad() {
2344        // Pinning the post-#1116-self-review semantics: a successfully
2345        // applied pad gets drained from `pending_pads`, so a later
2346        // sequence of two unused pads correctly triggers E2004 even
2347        // when an earlier pad already served a previous balance.
2348        // Pre-#1116 the `!any(used)` clause suppressed this case.
2349        let directives = vec![
2350            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2351            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2352            // First Pad → Balance pair: pad gets used, then drained.
2353            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2354            Directive::Balance(Balance::new(
2355                date(2024, 1, 2),
2356                "Assets:Bank",
2357                Amount::new(dec!(100.00), "USD"),
2358            )),
2359            // Two more unused pads, then a balance — this is the
2360            // ambiguous case E2004 is meant to flag.
2361            Directive::Pad(Pad::new(date(2024, 2, 1), "Assets:Bank", "Equity:Opening")),
2362            Directive::Pad(Pad::new(date(2024, 2, 2), "Assets:Bank", "Equity:Opening")),
2363            Directive::Balance(Balance::new(
2364                date(2024, 2, 3),
2365                "Assets:Bank",
2366                Amount::new(dec!(200.00), "USD"),
2367            )),
2368        ];
2369
2370        let errors = validate(&directives);
2371        let multi_pad_count = errors
2372            .iter()
2373            .filter(|e| e.code == ErrorCode::MultiplePadForBalance)
2374            .count();
2375        assert_eq!(
2376            multi_pad_count, 1,
2377            "E2004 must fire exactly once on the second balance; got {errors:?}"
2378        );
2379    }
2380
2381    #[test]
2382    fn test_pad_serves_multi_currency_balances_on_same_day() {
2383        // A single Pad must remain available to subsequent Balance
2384        // assertions in DIFFERENT currencies on the same target
2385        // account. Pre-#1116 the `any(used)` clause kept the pad
2386        // visible after the first currency consumed it. The retain
2387        // change in 05fcba8b broke this by dropping the pad as soon
2388        // as the first currency was padded.
2389        let directives = vec![
2390            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2391            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2392            Directive::Pad(Pad::new(date(2024, 1, 1), "Assets:Bank", "Equity:Opening")),
2393            // Two balances on the same day, different currencies.
2394            Directive::Balance(Balance::new(
2395                date(2024, 1, 2),
2396                "Assets:Bank",
2397                Amount::new(dec!(100.00), "USD"),
2398            )),
2399            Directive::Balance(Balance::new(
2400                date(2024, 1, 2),
2401                "Assets:Bank",
2402                Amount::new(dec!(50.00), "EUR"),
2403            )),
2404        ];
2405
2406        let errors = validate(&directives);
2407        assert!(
2408            !errors
2409                .iter()
2410                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2411            "pad should serve both USD and EUR; got {errors:?}"
2412        );
2413        assert!(
2414            !errors
2415                .iter()
2416                .any(|e| e.code == ErrorCode::PadWithoutBalance),
2417            "pad serves at least one balance; should not be E2003; got {errors:?}"
2418        );
2419    }
2420
2421    #[test]
2422    fn test_same_day_pad_does_not_apply_to_same_day_balance() {
2423        // Python beancount semantics: a Pad on date D only takes
2424        // effect for the NEXT Balance dated strictly after D. So a
2425        // same-day Pad+Balance leaves the Balance unpadded (regular
2426        // assertion runs) AND the Pad orphaned (E2003).
2427        let directives = vec![
2428            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2429            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2430            Directive::Pad(Pad::new(date(2024, 1, 2), "Assets:Bank", "Equity:Opening")),
2431            Directive::Balance(Balance::new(
2432                date(2024, 1, 2),
2433                "Assets:Bank",
2434                Amount::new(dec!(100.00), "USD"),
2435            )),
2436        ];
2437
2438        let errors = validate(&directives);
2439        // The pad is ignored, so the balance assertion runs against
2440        // the unpadded inventory (0 USD) and fails against the
2441        // asserted 100 USD.
2442        assert!(
2443            errors
2444                .iter()
2445                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2446            "same-day pad should NOT apply; balance fails on bare inventory; got {errors:?}"
2447        );
2448        // The pad never serves a balance, so E2003 fires.
2449        assert!(
2450            errors
2451                .iter()
2452                .any(|e| e.code == ErrorCode::PadWithoutBalance),
2453            "same-day pad never consumed; expected E2003; got {errors:?}"
2454        );
2455    }
2456
2457    #[test]
2458    fn test_future_pad_does_not_apply_to_earlier_balance() {
2459        // The date-filter in `validate_balance_late` must prevent a
2460        // later-dated Pad from being silently consumed by an earlier
2461        // Balance — a regression that would surface as the wrong
2462        // source account being debited. Regression test for commit
2463        // 83369fd8.
2464        let directives = vec![
2465            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2466            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
2467            Directive::Balance(Balance::new(
2468                date(2024, 1, 2),
2469                "Assets:Bank",
2470                Amount::new(dec!(0.00), "USD"),
2471            )),
2472            Directive::Pad(Pad::new(date(2024, 6, 1), "Assets:Bank", "Equity:Opening")),
2473        ];
2474
2475        let errors = validate(&directives);
2476        // The future pad must NOT consume the earlier balance; balance
2477        // asserts 0 USD against an empty inventory, which matches.
2478        assert!(
2479            !errors
2480                .iter()
2481                .any(|e| e.code == ErrorCode::BalanceAssertionFailed),
2482            "future pad should not influence earlier balance; got {errors:?}"
2483        );
2484        // The pad never gets used, so E2003 fires.
2485        assert!(
2486            errors
2487                .iter()
2488                .any(|e| e.code == ErrorCode::PadWithoutBalance),
2489            "future-dated pad without subsequent balance should fire E2003; got {errors:?}"
2490        );
2491    }
2492
2493    #[test]
2494    fn test_error_severity() {
2495        // Errors
2496        assert_eq!(ErrorCode::AccountNotOpen.severity(), Severity::Error);
2497        assert_eq!(ErrorCode::TransactionUnbalanced.severity(), Severity::Error);
2498        assert_eq!(ErrorCode::NoMatchingLot.severity(), Severity::Error);
2499
2500        // Warnings
2501        assert_eq!(ErrorCode::FutureDate.severity(), Severity::Warning);
2502        assert_eq!(ErrorCode::SinglePosting.severity(), Severity::Warning);
2503        assert_eq!(
2504            ErrorCode::AccountCloseNotEmpty.severity(),
2505            Severity::Warning
2506        );
2507
2508        // Info
2509        assert_eq!(ErrorCode::DateOutOfOrder.severity(), Severity::Info);
2510    }
2511
2512    #[test]
2513    fn test_validate_invalid_account_name() {
2514        // Test invalid root type
2515        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Invalid:Bank"))];
2516
2517        let errors = validate(&directives);
2518        assert!(
2519            errors
2520                .iter()
2521                .any(|e| e.code == ErrorCode::InvalidAccountName),
2522            "Should error for invalid account root: {errors:?}"
2523        );
2524    }
2525
2526    #[test]
2527    fn test_validate_account_lowercase_component() {
2528        // Test lowercase component (must start with uppercase or digit)
2529        let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), "Assets:bank"))];
2530
2531        let errors = validate(&directives);
2532        assert!(
2533            errors
2534                .iter()
2535                .any(|e| e.code == ErrorCode::InvalidAccountName),
2536            "Should error for lowercase component: {errors:?}"
2537        );
2538    }
2539
2540    #[test]
2541    fn test_validate_valid_account_names() {
2542        // Valid account names should not error
2543        let valid_names = [
2544            "Assets:Bank",
2545            "Assets:Bank:Checking",
2546            "Liabilities:CreditCard",
2547            "Equity:Opening-Balances",
2548            "Income:Salary2024",
2549            "Expenses:Food:Restaurant",
2550            "Assets:401k",     // Component starting with digit
2551            "Assets:沪深300",  // CJK characters
2552            "Assets:Café",     // Non-ASCII letter (é)
2553            "Assets:日本銀行", // Full non-ASCII component
2554            "Assets:Капитал",  // Cyrillic sub-account
2555        ];
2556
2557        for name in valid_names {
2558            let directives = vec![Directive::Open(Open::new(date(2024, 1, 1), name))];
2559
2560            let errors = validate(&directives);
2561            let name_errors: Vec<_> = errors
2562                .iter()
2563                .filter(|e| e.code == ErrorCode::InvalidAccountName)
2564                .collect();
2565            assert!(
2566                name_errors.is_empty(),
2567                "Should accept valid account name '{name}': {name_errors:?}"
2568            );
2569        }
2570    }
2571
2572    // =========================================================================
2573    // Error code coverage tests (spring 2026 audit)
2574    // =========================================================================
2575
2576    #[test]
2577    fn test_e2002_balance_exceeds_explicit_tolerance() {
2578        // E2002: When a balance directive specifies an explicit tolerance and the
2579        // actual balance exceeds it, we should get BalanceToleranceExceeded.
2580        let directives = vec![
2581            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2582            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2583            Directive::Transaction(
2584                Transaction::new(date(2024, 1, 15), "Deposit")
2585                    .with_synthesized_posting(Posting::new(
2586                        "Assets:Bank",
2587                        Amount::new(dec!(1000.00), "USD"),
2588                    ))
2589                    .with_synthesized_posting(Posting::new(
2590                        "Income:Salary",
2591                        Amount::new(dec!(-1000.00), "USD"),
2592                    )),
2593            ),
2594            // Balance assertion with explicit tolerance of 0.01,
2595            // but actual is 1000.00 vs expected 999.00 (difference = 1.00)
2596            Directive::Balance(
2597                Balance::new(
2598                    date(2024, 1, 16),
2599                    "Assets:Bank",
2600                    Amount::new(dec!(999.00), "USD"),
2601                )
2602                .with_tolerance(dec!(0.01)),
2603            ),
2604        ];
2605
2606        let errors = validate(&directives);
2607
2608        assert!(
2609            errors
2610                .iter()
2611                .any(|e| e.code == ErrorCode::BalanceToleranceExceeded),
2612            "Expected E2002 BalanceToleranceExceeded, got: {errors:?}"
2613        );
2614    }
2615
2616    #[test]
2617    fn test_e2002_balance_within_explicit_tolerance_passes() {
2618        // When within explicit tolerance, no error should be raised
2619        let directives = vec![
2620            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2621            Directive::Open(Open::new(date(2024, 1, 1), "Income:Salary")),
2622            Directive::Transaction(
2623                Transaction::new(date(2024, 1, 15), "Deposit")
2624                    .with_synthesized_posting(Posting::new(
2625                        "Assets:Bank",
2626                        Amount::new(dec!(1000.00), "USD"),
2627                    ))
2628                    .with_synthesized_posting(Posting::new(
2629                        "Income:Salary",
2630                        Amount::new(dec!(-1000.00), "USD"),
2631                    )),
2632            ),
2633            // Balance assertion with tolerance of 5.00, difference is only 1.00
2634            Directive::Balance(
2635                Balance::new(
2636                    date(2024, 1, 16),
2637                    "Assets:Bank",
2638                    Amount::new(dec!(999.00), "USD"),
2639                )
2640                .with_tolerance(dec!(5.00)),
2641            ),
2642        ];
2643
2644        let errors = validate(&directives);
2645
2646        assert!(
2647            !errors
2648                .iter()
2649                .any(|e| e.code == ErrorCode::BalanceToleranceExceeded
2650                    || e.code == ErrorCode::BalanceAssertionFailed),
2651            "Expected no balance errors, got: {errors:?}"
2652        );
2653    }
2654
2655    #[test]
2656    fn test_e5001_undeclared_currency() {
2657        // E5001: When require_commodities=true, using a currency without a
2658        // commodity directive should raise UndeclaredCurrency.
2659        use rustledger_core::Commodity;
2660
2661        let directives = vec![
2662            Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2663            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2664            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2665            Directive::Transaction(
2666                Transaction::new(date(2024, 1, 15), "Lunch")
2667                    .with_synthesized_posting(Posting::new(
2668                        "Expenses:Food",
2669                        Amount::new(dec!(20.00), "EUR"), // EUR not declared
2670                    ))
2671                    .with_synthesized_posting(Posting::new(
2672                        "Assets:Bank",
2673                        Amount::new(dec!(-20.00), "EUR"),
2674                    )),
2675            ),
2676        ];
2677
2678        let options = ValidationOptions::default().with_require_commodities(true);
2679        let errors = validate_with_options(&directives, options);
2680
2681        assert!(
2682            errors
2683                .iter()
2684                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2685            "Expected E5001 UndeclaredCurrency for EUR, got: {errors:?}"
2686        );
2687    }
2688
2689    #[test]
2690    fn test_e5001_declared_currency_passes() {
2691        // When the currency is declared, no E5001 error
2692        use rustledger_core::Commodity;
2693
2694        let directives = vec![
2695            Directive::Commodity(Commodity::new(date(2024, 1, 1), "USD")),
2696            Directive::Commodity(Commodity::new(date(2024, 1, 1), "EUR")),
2697            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2698            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2699            Directive::Transaction(
2700                Transaction::new(date(2024, 1, 15), "Lunch")
2701                    .with_synthesized_posting(Posting::new(
2702                        "Expenses:Food",
2703                        Amount::new(dec!(20.00), "EUR"),
2704                    ))
2705                    .with_synthesized_posting(Posting::new(
2706                        "Assets:Bank",
2707                        Amount::new(dec!(-20.00), "EUR"),
2708                    )),
2709            ),
2710        ];
2711
2712        let options = ValidationOptions::default().with_require_commodities(true);
2713        let errors = validate_with_options(&directives, options);
2714
2715        assert!(
2716            !errors
2717                .iter()
2718                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2719            "Expected no E5001 errors, got: {errors:?}"
2720        );
2721    }
2722
2723    #[test]
2724    fn test_e5001_not_raised_without_require_commodities() {
2725        // Without require_commodities=true, undeclared currencies are fine
2726        let directives = vec![
2727            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2728            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2729            Directive::Transaction(
2730                Transaction::new(date(2024, 1, 15), "Lunch")
2731                    .with_synthesized_posting(Posting::new(
2732                        "Expenses:Food",
2733                        Amount::new(dec!(20.00), "XYZ"), // Totally made up
2734                    ))
2735                    .with_synthesized_posting(Posting::new(
2736                        "Assets:Bank",
2737                        Amount::new(dec!(-20.00), "XYZ"),
2738                    )),
2739            ),
2740        ];
2741
2742        let errors = validate(&directives);
2743
2744        assert!(
2745            !errors
2746                .iter()
2747                .any(|e| e.code == ErrorCode::UndeclaredCurrency),
2748            "Should not raise E5001 without require_commodities, got: {errors:?}"
2749        );
2750    }
2751
2752    #[test]
2753    fn test_e3002_multiple_missing_amounts() {
2754        // E3002: Multiple postings with missing amounts is ambiguous
2755        let directives = vec![
2756            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2757            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2758            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Drinks")),
2759            Directive::Transaction(
2760                Transaction::new(date(2024, 1, 15), "Lunch")
2761                    .with_synthesized_posting(Posting::new(
2762                        "Assets:Bank",
2763                        Amount::new(dec!(-50.00), "USD"),
2764                    ))
2765                    // Two postings with no amount — ambiguous interpolation
2766                    .with_synthesized_posting(Posting {
2767                        account: "Expenses:Food".into(),
2768                        units: None,
2769                        cost: None,
2770                        price: None,
2771                        flag: None,
2772                        meta: Default::default(),
2773                        comments: vec![],
2774                        trailing_comments: vec![],
2775                    })
2776                    .with_synthesized_posting(Posting {
2777                        account: "Expenses:Drinks".into(),
2778                        units: None,
2779                        cost: None,
2780                        price: None,
2781                        flag: None,
2782                        meta: Default::default(),
2783                        comments: vec![],
2784                        trailing_comments: vec![],
2785                    }),
2786            ),
2787        ];
2788
2789        let errors = validate(&directives);
2790
2791        assert!(
2792            errors
2793                .iter()
2794                .any(|e| e.code == ErrorCode::MultipleInterpolation),
2795            "Expected E3002 MultipleInterpolation, got: {errors:?}"
2796        );
2797    }
2798
2799    #[test]
2800    fn test_e3002_single_missing_amount_ok() {
2801        // A single missing amount is fine (can be interpolated)
2802        let directives = vec![
2803            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
2804            Directive::Open(Open::new(date(2024, 1, 1), "Expenses:Food")),
2805            Directive::Transaction(
2806                Transaction::new(date(2024, 1, 15), "Lunch")
2807                    .with_synthesized_posting(Posting::new(
2808                        "Assets:Bank",
2809                        Amount::new(dec!(-50.00), "USD"),
2810                    ))
2811                    .with_synthesized_posting(Posting {
2812                        account: "Expenses:Food".into(),
2813                        units: None,
2814                        cost: None,
2815                        price: None,
2816                        flag: None,
2817                        meta: Default::default(),
2818                        comments: vec![],
2819                        trailing_comments: vec![],
2820                    }),
2821            ),
2822        ];
2823
2824        let errors = validate(&directives);
2825
2826        assert!(
2827            !errors
2828                .iter()
2829                .any(|e| e.code == ErrorCode::MultipleInterpolation),
2830            "Should not raise E3002 with single missing amount, got: {errors:?}"
2831        );
2832    }
2833
2834    #[test]
2835    fn test_e7001_unknown_option() {
2836        // E7001: import_option_warnings converts loader warnings to validation errors
2837        let state = LedgerState::new();
2838        let mut errors = Vec::new();
2839
2840        state.import_option_warnings(&[("E7001", "Invalid option \"bogus_option\"")], &mut errors);
2841
2842        assert_eq!(errors.len(), 1);
2843        assert_eq!(errors[0].code, ErrorCode::UnknownOption);
2844        assert!(errors[0].message.contains("bogus_option"));
2845    }
2846
2847    #[test]
2848    fn test_e7002_invalid_option_value() {
2849        let state = LedgerState::new();
2850        let mut errors = Vec::new();
2851
2852        state.import_option_warnings(
2853            &[("E7002", "Invalid leaf account name: 'not-valid'")],
2854            &mut errors,
2855        );
2856
2857        assert_eq!(errors.len(), 1);
2858        assert_eq!(errors[0].code, ErrorCode::InvalidOptionValue);
2859    }
2860
2861    #[test]
2862    fn test_e7003_duplicate_option() {
2863        let state = LedgerState::new();
2864        let mut errors = Vec::new();
2865
2866        state.import_option_warnings(
2867            &[("E7003", "Option \"title\" can only be specified once")],
2868            &mut errors,
2869        );
2870
2871        assert_eq!(errors.len(), 1);
2872        assert_eq!(errors[0].code, ErrorCode::DuplicateOption);
2873    }
2874
2875    // ----- E5003: invalid `precision` metadata on commodity (issue #991) ----
2876
2877    fn commodity_with_precision(value: MetaValue) -> Directive {
2878        let mut meta = rustledger_core::Metadata::default();
2879        meta.insert("precision".into(), value);
2880        Directive::Commodity(
2881            rustledger_core::Commodity::new(date(2024, 1, 1), "USD").with_meta(meta),
2882        )
2883    }
2884
2885    #[test]
2886    fn precision_meta_valid_integer_emits_no_warning() {
2887        let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2)))];
2888        let errors = validate(&directives);
2889        assert!(
2890            errors
2891                .iter()
2892                .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata),
2893            "valid precision must not produce a warning, got: {errors:?}"
2894        );
2895    }
2896
2897    #[test]
2898    fn precision_meta_zero_is_valid() {
2899        let directives = vec![commodity_with_precision(MetaValue::Number(dec!(0)))];
2900        let errors = validate(&directives);
2901        assert!(
2902            errors
2903                .iter()
2904                .all(|e| e.code != ErrorCode::InvalidPrecisionMetadata)
2905        );
2906    }
2907
2908    #[test]
2909    fn precision_meta_negative_emits_e5003() {
2910        let directives = vec![commodity_with_precision(MetaValue::Number(dec!(-1)))];
2911        let errors = validate(&directives);
2912        let warnings: Vec<_> = errors
2913            .iter()
2914            .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2915            .collect();
2916        assert_eq!(warnings.len(), 1, "expected one E5003");
2917        assert_eq!(warnings[0].code.severity(), Severity::Warning);
2918        assert!(warnings[0].message.contains("non-negative"));
2919    }
2920
2921    #[test]
2922    fn precision_meta_non_integer_emits_e5003() {
2923        let directives = vec![commodity_with_precision(MetaValue::Number(dec!(2.5)))];
2924        let errors = validate(&directives);
2925        let warnings: Vec<_> = errors
2926            .iter()
2927            .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2928            .collect();
2929        assert_eq!(warnings.len(), 1);
2930        assert!(warnings[0].message.contains("integer"));
2931    }
2932
2933    #[test]
2934    fn precision_meta_string_value_emits_e5003() {
2935        let directives = vec![commodity_with_precision(MetaValue::String("abc".into()))];
2936        let errors = validate(&directives);
2937        let warnings: Vec<_> = errors
2938            .iter()
2939            .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2940            .collect();
2941        assert_eq!(warnings.len(), 1);
2942        assert!(warnings[0].message.contains("string"));
2943    }
2944
2945    #[test]
2946    fn precision_meta_out_of_u32_range_emits_e5003() {
2947        // 2^33 — too big for u32.
2948        let directives = vec![commodity_with_precision(MetaValue::Number(dec!(
2949            8589934592
2950        )))];
2951        let errors = validate(&directives);
2952        let warnings: Vec<_> = errors
2953            .iter()
2954            .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2955            .collect();
2956        assert_eq!(warnings.len(), 1);
2957        assert!(warnings[0].message.contains("exceeds"));
2958    }
2959
2960    #[test]
2961    fn precision_meta_valid_then_invalid_same_currency_warns_only_once() {
2962        // Two commodity directives for USD: first valid (2), second invalid
2963        // (-1). The validator must surface the bad one as E5003 even though
2964        // the loader pins the earlier valid override. This pairs with the
2965        // loader-side test `precision_metadata_valid_then_invalid_keeps_first`.
2966        let directives = vec![
2967            commodity_with_precision(MetaValue::Number(dec!(2))),
2968            commodity_with_precision(MetaValue::Number(dec!(-1))),
2969        ];
2970        let warnings: Vec<_> = validate(&directives)
2971            .into_iter()
2972            .filter(|e| e.code == ErrorCode::InvalidPrecisionMetadata)
2973            .collect();
2974        assert_eq!(
2975            warnings.len(),
2976            1,
2977            "exactly one E5003 expected (only the invalid declaration)"
2978        );
2979        assert!(warnings[0].message.contains("non-negative"));
2980    }
2981
2982    #[test]
2983    fn precision_meta_e5003_is_warning_severity() {
2984        // Pin the severity classification — InvalidPrecisionMetadata must be
2985        // a warning (loading does not fail). Used by CLI / LSP renderers to
2986        // pick the right color and exit code.
2987        assert_eq!(
2988            ErrorCode::InvalidPrecisionMetadata.severity(),
2989            Severity::Warning
2990        );
2991        assert_eq!(ErrorCode::InvalidPrecisionMetadata.code(), "E5003");
2992    }
2993
2994    // ─── Phase-split (refs #1115) ────────────────────────────────────────
2995
2996    /// `validate_early` must catch E1001 on a posting to an account that
2997    /// was never opened — even when the posting is elided (no units), so
2998    /// the loader's pre-booking validation can see it before booking
2999    /// drops zero-value interpolations. This is the load-bearing test
3000    /// for the rustledger#877 strictness deviation from Python beancount.
3001    #[test]
3002    fn test_validate_early_emits_e1001_on_elided_posting() {
3003        let directives = vec![
3004            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3005            Directive::Transaction(
3006                Transaction::new(date(2024, 1, 15), "Zero to unopened")
3007                    .with_synthesized_posting(Posting::new(
3008                        "Assets:Bank",
3009                        Amount::new(dec!(0.00), "USD"),
3010                    ))
3011                    .with_synthesized_posting(Posting::auto("Expenses:NeverOpened")),
3012            ),
3013        ];
3014
3015        let session = ValidationSession::new(ValidationOptions::default());
3016        let (_session, errors) = session.run_early(&directives, date(2026, 1, 1));
3017
3018        assert!(
3019            errors.iter().any(|e| e.code == ErrorCode::AccountNotOpen
3020                && e.to_string().contains("Expenses:NeverOpened")),
3021            "early phase must emit E1001 on elided posting to unopened account; got: {errors:?}"
3022        );
3023    }
3024
3025    /// An *explicit* posting to an unopened account is reported in the LATE
3026    /// phase (deferred from early so account-rewriting plugins run first) —
3027    /// exactly once across phases, never duplicated.
3028    #[test]
3029    fn test_validate_late_does_not_duplicate_e1001() {
3030        let directives = vec![
3031            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3032            Directive::Transaction(
3033                Transaction::new(date(2024, 1, 15), "To unopened")
3034                    .with_synthesized_posting(Posting::new(
3035                        "Assets:Bank",
3036                        Amount::new(dec!(100), "USD"),
3037                    ))
3038                    .with_synthesized_posting(Posting::new(
3039                        "Expenses:NeverOpened",
3040                        Amount::new(dec!(-100), "USD"),
3041                    )),
3042            ),
3043        ];
3044
3045        let session = ValidationSession::new(ValidationOptions::default());
3046        let (session, early) = session.run_early(&directives, date(2026, 1, 1));
3047        let (_session, late) = session.run_late(&directives, date(2026, 1, 1));
3048
3049        let early_e1001 = early
3050            .iter()
3051            .filter(|e| e.code == ErrorCode::AccountNotOpen)
3052            .count();
3053        let late_e1001 = late
3054            .iter()
3055            .filter(|e| e.code == ErrorCode::AccountNotOpen)
3056            .count();
3057
3058        assert_eq!(
3059            early_e1001, 0,
3060            "explicit posting: early phase defers E1001 to late; got: {early:?}"
3061        );
3062        assert_eq!(
3063            late_e1001, 1,
3064            "explicit posting: late phase emits E1001 exactly once; got: {late:?}"
3065        );
3066    }
3067
3068    /// The legacy convenience entry `validate()` chains `Early` then
3069    /// `Late` internally. Its error list must match what you'd get from
3070    /// explicitly running both phases against the same input — so
3071    /// existing callers (LSP, FFI, direct test code) don't observe a
3072    /// behavior change after the phase split.
3073    #[test]
3074    fn test_validate_chained_matches_explicit_phases() {
3075        // A mix that exercises both phases: an Open, a Transaction with
3076        // an unopened account, a same-day Balance that needs late-phase
3077        // inventory state.
3078        let directives = vec![
3079            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3080            Directive::Transaction(
3081                Transaction::new(date(2024, 1, 15), "Mixed")
3082                    .with_synthesized_posting(Posting::new(
3083                        "Assets:Bank",
3084                        Amount::new(dec!(50), "USD"),
3085                    ))
3086                    .with_synthesized_posting(Posting::new(
3087                        "Income:Salary",
3088                        Amount::new(dec!(-50), "USD"),
3089                    )),
3090            ),
3091            Directive::Balance(Balance::new(
3092                date(2024, 1, 16),
3093                "Assets:Bank",
3094                Amount::new(dec!(50), "USD"),
3095            )),
3096        ];
3097
3098        // Legacy single-call.
3099        let chained = validate(&directives);
3100
3101        // Explicit phase split.
3102        let session = ValidationSession::new(ValidationOptions::default());
3103        let (session, mut explicit) = session.run_early(&directives, date(2026, 1, 1));
3104        let (session, late_errs) = session.run_late(&directives, date(2026, 1, 1));
3105        explicit.extend(late_errs);
3106        explicit.extend(session.finalize());
3107
3108        // Same set of (code, date, message) tuples in the same order.
3109        // String comparison sidesteps the ValidationError struct's
3110        // non-pub fields and matches what users actually see.
3111        let chained_strs: Vec<String> = chained.iter().map(ToString::to_string).collect();
3112        let explicit_strs: Vec<String> = explicit.iter().map(ToString::to_string).collect();
3113        assert_eq!(
3114            chained_strs, explicit_strs,
3115            "legacy `validate()` and explicit `Early` + `Late` must produce identical error lists"
3116        );
3117    }
3118
3119    #[test]
3120    fn test_phase_order_early_then_late_then_finalize() {
3121        // Pin the error emission ordering across phases:
3122        //   1. Early-phase errors  (E1001 AccountNotOpen)
3123        //   2. Late-phase errors   (E2002 BalanceAssertionFailed)
3124        //   3. Finalize errors     (E2003 PadWithoutBalance)
3125        // Stable ordering matters for LSP diagnostics and CLI output;
3126        // accidental reordering of the pipeline would surface here.
3127        let directives = vec![
3128            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3129            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Other")),
3130            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3131            // Early: posting to unopened Income:Salary → E1001.
3132            Directive::Transaction(
3133                Transaction::new(date(2024, 1, 5), "early")
3134                    .with_synthesized_posting(Posting::new(
3135                        "Assets:Bank",
3136                        Amount::new(dec!(100), "USD"),
3137                    ))
3138                    .with_synthesized_posting(Posting::new(
3139                        "Income:Salary",
3140                        Amount::new(dec!(-100), "USD"),
3141                    )),
3142            ),
3143            // Finalize: pad on Assets:Other has no following Balance → E2003.
3144            Directive::Pad(Pad::new(
3145                date(2024, 1, 10),
3146                "Assets:Other",
3147                "Equity:Opening",
3148            )),
3149            // Late: wrong amount → E2002. (Posted balance is 100 USD.)
3150            Directive::Balance(Balance::new(
3151                date(2024, 2, 1),
3152                "Assets:Bank",
3153                Amount::new(dec!(999), "USD"),
3154            )),
3155        ];
3156
3157        let errors = validate(&directives);
3158        let codes: Vec<ErrorCode> = errors.iter().map(|e| e.code).collect();
3159
3160        let early_pos = codes
3161            .iter()
3162            .position(|c| *c == ErrorCode::AccountNotOpen)
3163            .unwrap_or_else(|| panic!("expected E1001 in {codes:?}"));
3164        let late_pos = codes
3165            .iter()
3166            .position(|c| *c == ErrorCode::BalanceAssertionFailed)
3167            .unwrap_or_else(|| panic!("expected E2002 in {codes:?}"));
3168        let finalize_pos = codes
3169            .iter()
3170            .position(|c| *c == ErrorCode::PadWithoutBalance)
3171            .unwrap_or_else(|| panic!("expected E2003 in {codes:?}"));
3172
3173        assert!(
3174            early_pos < late_pos,
3175            "early-phase errors must precede late-phase; got {codes:?}"
3176        );
3177        assert!(
3178            late_pos < finalize_pos,
3179            "late-phase errors must precede finalize; got {codes:?}"
3180        );
3181    }
3182
3183    #[test]
3184    fn test_duplicate_same_day_close_emits_close_not_empty_once() {
3185        // Regression for the Copilot inline review on PR #1116: two
3186        // Close directives for the same account on the same date used
3187        // to bypass the `validate_close_late` guard, double-emitting
3188        // `AccountCloseNotEmpty`. The early phase rejects the duplicate
3189        // with `AccountClosed`; the late phase should run the
3190        // non-empty-balance check exactly once.
3191        let directives = vec![
3192            Directive::Open(Open::new(date(2024, 1, 1), "Assets:Bank")),
3193            // Leave a non-zero balance on Assets:Bank so the late-phase
3194            // non-empty check actually fires.
3195            Directive::Transaction(
3196                Transaction::new(date(2024, 1, 10), "leave residue")
3197                    .with_synthesized_posting(Posting::new(
3198                        "Assets:Bank",
3199                        Amount::new(dec!(50), "USD"),
3200                    ))
3201                    .with_synthesized_posting(Posting::new(
3202                        "Equity:Opening",
3203                        Amount::new(dec!(-50), "USD"),
3204                    )),
3205            ),
3206            Directive::Open(Open::new(date(2024, 1, 1), "Equity:Opening")),
3207            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3208            Directive::Close(Close::new(date(2024, 6, 1), "Assets:Bank")),
3209        ];
3210
3211        let errors = validate(&directives);
3212        let close_not_empty_count = errors
3213            .iter()
3214            .filter(|e| e.code == ErrorCode::AccountCloseNotEmpty)
3215            .count();
3216        assert_eq!(
3217            close_not_empty_count, 1,
3218            "AccountCloseNotEmpty must fire exactly once for duplicate same-day closes; got {errors:?}"
3219        );
3220        // And the duplicate still gets its early-phase `AccountClosed` flag.
3221        let account_closed_count = errors
3222            .iter()
3223            .filter(|e| e.code == ErrorCode::AccountClosed)
3224            .count();
3225        assert_eq!(
3226            account_closed_count, 1,
3227            "duplicate close should still report AccountClosed once; got {errors:?}"
3228        );
3229    }
3230
3231    // Pre-#1236 these were two `#[should_panic]` tests that asserted
3232    // the `debug_assert!` calls in `ValidationSession::check_phase_ordering`
3233    // fired on out-of-order or duplicate phase calls. The typestate
3234    // refactor moved that enforcement to the type system: calling
3235    // `run_late` before `run_early`, or either phase twice, is now a
3236    // compile error rather than a runtime panic.
3237    //
3238    // We deliberately do not keep the runtime panic-tests as a parallel
3239    // safety net: there is no longer a runtime code path that could
3240    // panic, so a runtime test would simply be unreachable.
3241
3242    /// Compile-time pin for the typestate ordering: `run_late` is not
3243    /// callable on a `ValidationSession<Pending>` (the only `new()`
3244    /// output). This test is type-level only and runs at compile time.
3245    ///
3246    /// Coverage is limited to the happy-path direction: the helper
3247    /// functions below assert that the by-value transitions resolve to
3248    /// the documented next-phase types. Compiler rejection of the
3249    /// inverse misuse (`run_late` on `Pending`, double-`run_early`,
3250    /// `finalize` on `EarlyDone`, etc.) is exercised today by ordinary
3251    /// development — the missing methods produce E0599 the moment a
3252    /// caller tries them. Pinning these as `trybuild`-style `compile_fail`
3253    /// tests is a candidate follow-up; the dependency adds rustc-version-
3254    /// sensitive `.stderr` snapshots that aren't justified by the
3255    /// already-structural type-system enforcement.
3256    #[test]
3257    fn typestate_pins_phase_ordering_at_compile_time() {
3258        // A `Pending` session has `run_early` but not `run_late`. The
3259        // following commented-out lines would fail to compile if
3260        // uncommented; they're documentation, not executable code.
3261        //
3262        //     let session = ValidationSession::new(ValidationOptions::default());
3263        //     let (_, _) = session.run_late(&[], date(2024, 1, 1));
3264        //     // error[E0599]: no method named `run_late` found for struct
3265        //     //               `ValidationSession<Pending>` in the current scope
3266        //
3267        // The helper functions below pin the happy-path transitions
3268        // via signatures the type-checker validates at compile time.
3269        fn _expect_pending_returns_early(
3270            s: ValidationSession<Pending>,
3271        ) -> ValidationSession<EarlyDone> {
3272            let (s, _errors) = s.run_early(&[] as &[Directive], date(2024, 1, 1));
3273            s
3274        }
3275        fn _expect_early_returns_late(
3276            s: ValidationSession<EarlyDone>,
3277        ) -> ValidationSession<LateDone> {
3278            let (s, _errors) = s.run_late(&[] as &[Directive], date(2024, 1, 1));
3279            s
3280        }
3281        fn _expect_late_finalizes(s: ValidationSession<LateDone>) -> Vec<ValidationError> {
3282            s.finalize()
3283        }
3284    }
3285}