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