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