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