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