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