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