Skip to main content

rustledger_loader/
process.rs

1//! Processing pipeline: sort → synth-plugins → Early → book → regular-plugins → Late → finalize.
2//!
3//! This module orchestrates the full processing pipeline for a beancount ledger,
4//! equivalent to Python's `loader.load_file()` function.
5
6// ratchet: fxhash-only — hot path; use FxHashMap/FxHashSet, not std SipHash collections (#1237).
7use crate::{LoadError, LoadResult, Options, Plugin, SourceMap};
8use rustledger_core::{BookingMethod, Directive, DisplayContext};
9use rustledger_parser::Spanned;
10use std::path::Path;
11use thiserror::Error;
12
13/// A CLI-supplied (or programmatic) extra plugin invocation.
14///
15/// Bundles the plugin name with its optional config string so the two
16/// can't drift apart — the previous parallel-Vec representation could
17/// silently misalign a config with the wrong plugin.
18#[derive(Debug, Clone)]
19pub struct ExtraPlugin {
20    /// Plugin name (short or fully-qualified module path).
21    pub name: String,
22    /// Plugin-specific config string, if any.
23    pub config: Option<String>,
24}
25
26/// Options for loading and processing a ledger.
27#[derive(Debug, Clone)]
28pub struct LoadOptions {
29    /// Booking method for lot matching (default: Strict).
30    pub booking_method: BookingMethod,
31    /// Run plugins declared in the file (default: true).
32    pub run_plugins: bool,
33    /// Run `auto_accounts` plugin (default: false).
34    pub auto_accounts: bool,
35    /// Additional plugins to run (CLI `--plugin` or programmatic API),
36    /// each with an optional config string.
37    pub extra_plugins: Vec<ExtraPlugin>,
38    /// Run validation after processing (default: true).
39    pub validate: bool,
40    /// Enable path security (prevent include traversal).
41    pub path_security: bool,
42    /// Collect realized capital gains into [`Ledger::capital_gains`] during the
43    /// booking pass (default: false). The gains are computed by booking regardless;
44    /// this only controls whether they are retained. Off by default so consumers
45    /// that never read them (`check`, BQL, holdings, the FFI component) don't carry
46    /// the vector — only the capgains report opts in.
47    pub collect_capital_gains: bool,
48}
49
50impl Default for LoadOptions {
51    fn default() -> Self {
52        Self {
53            booking_method: BookingMethod::Strict,
54            run_plugins: true,
55            auto_accounts: false,
56            extra_plugins: Vec::new(),
57            validate: true,
58            path_security: false,
59            collect_capital_gains: false,
60        }
61    }
62}
63
64impl LoadOptions {
65    /// Create options for minimal processing: no plugins, no validation, and no
66    /// capital-gains retention. Booking always runs (it is mandatory — a loader that
67    /// cannot book cannot resolve costs or match lots); for truly unbooked directives
68    /// use the parser or [`load_raw`] instead.
69    #[must_use]
70    pub const fn raw() -> Self {
71        Self {
72            booking_method: BookingMethod::Strict,
73            run_plugins: false,
74            auto_accounts: false,
75            extra_plugins: Vec::new(),
76            validate: false,
77            path_security: false,
78            collect_capital_gains: false,
79        }
80    }
81}
82
83/// Errors that can occur during ledger processing.
84#[derive(Debug, Error)]
85pub enum ProcessError {
86    /// Loading failed.
87    #[error("loading failed: {0}")]
88    Load(#[from] LoadError),
89
90    /// Booking/interpolation error.
91    #[error("booking error: {message}")]
92    Booking {
93        /// Error message.
94        message: String,
95        /// Date of the transaction.
96        date: rustledger_core::NaiveDate,
97        /// Narration of the transaction.
98        narration: String,
99    },
100
101    /// Plugin execution error.
102    #[cfg(feature = "plugins")]
103    #[error("plugin error: {0}")]
104    Plugin(String),
105
106    /// Validation error.
107    #[cfg(feature = "validation")]
108    #[error("validation error: {0}")]
109    Validation(String),
110
111    /// Plugin output conversion error.
112    #[cfg(feature = "plugins")]
113    #[error("failed to convert plugin output: {0}")]
114    PluginConversion(String),
115}
116
117/// A fully processed ledger.
118///
119/// This is the result of loading and processing a beancount file,
120/// equivalent to the tuple returned by Python's `loader.load_file()`.
121#[derive(Debug)]
122pub struct Ledger {
123    /// Processed directives in source-faithful form: sorted by date,
124    /// booked (cost specs resolved, interpolations applied), and
125    /// plugin-rewritten. **`Pad` directives remain as `Pad`**; they
126    /// are not pre-expanded into synthesized transactions.
127    ///
128    /// Consumers split into two groups:
129    ///
130    /// - **Source-faithful consumers** (stats, journal, formatter,
131    ///   LSP, BQL `FROM #entries WHERE type = 'pad'` audits,
132    ///   source-mapped diagnostics) iterate this field directly.
133    ///   Pads count as Pads.
134    /// - **Balance-computing consumers** (holdings, balances,
135    ///   balsheet, networth, income, FFI `query.execute`/`batch`,
136    ///   WASM `expandPads`/`query`) call [`Ledger::balance_view`]
137    ///   to get the directive stream MERGED with synthesized P-flag
138    ///   transactions for each pad-balance pair. This is the only
139    ///   way to get pad effects into per-account inventory math.
140    ///
141    /// The two views are derived from the same source; there is no
142    /// drift possible because [`Ledger::balance_view`] is a pure
143    /// function of `self.directives`.
144    pub directives: Vec<Spanned<Directive>>,
145    /// Options parsed from the file.
146    pub options: Options,
147    /// Plugins declared in the file.
148    pub plugins: Vec<Plugin>,
149    /// Source map for error reporting.
150    pub source_map: SourceMap,
151    /// Errors encountered during processing.
152    pub errors: Vec<LedgerError>,
153    /// Display context for formatting numbers.
154    pub display_context: DisplayContext,
155    /// Realized capital gains/losses, one per disposed tax lot, captured during
156    /// the loader's single canonical booking pass (in booking order, with the
157    /// ledger's own method, before `@@` normalization). Consumers — e.g. the
158    /// capgains report — read these directly rather than re-booking the stream
159    /// and re-deriving them, so they cannot drift from `rledger check`.
160    pub capital_gains: Vec<rustledger_booking::CapitalGain>,
161}
162
163impl Ledger {
164    /// Return the directive stream merged with synthesized
165    /// pad-equivalent transactions, suitable for inventory /
166    /// balance math.
167    ///
168    /// For each `Pad` directive followed (in date order) by a
169    /// `Balance` assertion on the same account, a `Transaction`
170    /// with `flag = 'P'` is added to the view carrying the
171    /// postings needed to make the balance match. A multi-currency
172    /// pad produces one synth transaction per currency.
173    ///
174    /// **Original `Pad` directives are preserved in the view.**
175    /// Synth transactions are added alongside, not in place of.
176    /// This matters for two reasons:
177    ///
178    /// 1. BQL queries against the `#entries` table
179    ///    (`SELECT * FROM #entries WHERE type = 'pad'`) can still
180    ///    enumerate the pad directives the user authored. A
181    ///    REPLACE-style expansion would silently zero those out.
182    ///    (BQL's default SELECT path operates on postings; pads
183    ///    have no postings, so a default SELECT never matches them
184    ///    regardless of this view shape.)
185    /// 2. Multi-pad cases (issue #1300) produce exactly one synth
186    ///    per pad-balance pair:
187    ///    `rustledger_booking::process_pads` (which
188    ///    `merge_with_padding` delegates to) only retains the most
189    ///    recent same-account pad in its pending-pads map, so
190    ///    earlier same-account pads are silently shadowed and
191    ///    their `source_account` does NOT contribute to the synth.
192    ///    The validator emits `E2003` for shadowed pads
193    ///    independently; this view reflects only the effective pad.
194    ///
195    /// Inventory-walking consumers iterate `Directive::Transaction`
196    /// and ignore `Pad` directives, so the preserved Pads are
197    /// invisible to them.
198    ///
199    /// **When to use this vs. [`Ledger.directives`](Self::directives):**
200    /// any consumer that maintains running per-account inventory
201    /// state and asks "what is the balance" needs this view. Any
202    /// consumer that asks "what did the user write" wants the raw
203    /// `directives` field.
204    ///
205    /// # Performance
206    ///
207    /// Each call clones every source directive once (`O(n)`).
208    /// Inlines the merge logic from
209    /// [`rustledger_booking::merge_with_padding`] so the already-
210    /// owned `booked` vector can be moved into the merged output
211    /// instead of cloned a second time. For short-lived CLI
212    /// invocations the single clone is negligible. Long-lived
213    /// processes (FFI servers, LSPs) that query the same ledger
214    /// repeatedly should hoist the result above their loop.
215    /// `TODO(perf):` memoize internally once a benchmark shows it
216    /// matters.
217    #[must_use]
218    pub fn balance_view(&self) -> Vec<Directive> {
219        let booked: Vec<Directive> = self.directives.iter().map(|s| s.value.clone()).collect();
220
221        // Call the canonical placement rule rather than re-deriving it.
222        // This used to inline the merge so `booked` could be moved instead of
223        // cloned a second time; `merge_with_padding_owned` gives the same
224        // saving without the copy. The copy had already drifted — it still
225        // prepended synths after the shared rule learned to place them
226        // relative to a same-date `Balance`.
227        debug_assert!(
228            !booked.iter().any(|d| matches!(d, Directive::Transaction(t) if rustledger_booking::is_synthesized_pad(t))),
229            "balance_view called on a Ledger whose directives already contain synth pad transactions",
230        );
231        rustledger_booking::merge_with_padding_owned(booked)
232    }
233}
234
235/// Unified error type for ledger processing.
236///
237/// This encompasses all error types that can occur during loading,
238/// booking, plugin execution, and validation.
239#[derive(Debug)]
240#[non_exhaustive]
241pub struct LedgerError {
242    /// Error severity.
243    pub severity: ErrorSeverity,
244    /// Error code (e.g., "E0001", "W8002").
245    pub code: String,
246    /// Human-readable error message.
247    pub message: String,
248    /// Source location, if available.
249    pub location: Option<ErrorLocation>,
250    /// Byte span (inclusive start, exclusive end) in the source file,
251    /// used by rich renderers (e.g. miette) to draw a snippet around
252    /// the offending directive. Consumers that only need `file:line:col`
253    /// should use `location`; those that want to show the surrounding
254    /// source text want this.
255    pub source_span: Option<(usize, usize)>,
256    /// Source file ID — index into the ledger's [`SourceMap`]. Used
257    /// alongside `source_span` for snippet rendering.
258    pub file_id: Option<u16>,
259    /// Processing phase that produced this error: "parse", "validate", or "plugin".
260    pub phase: String,
261}
262
263/// Error severity level.
264#[derive(Debug, Clone, Copy, PartialEq, Eq)]
265pub enum ErrorSeverity {
266    /// Error - indicates a problem that should be fixed.
267    Error,
268    /// Warning - indicates a potential issue.
269    Warning,
270}
271
272/// Source location for an error.
273#[derive(Debug, Clone)]
274pub struct ErrorLocation {
275    /// File path.
276    pub file: std::path::PathBuf,
277    /// Line number (1-indexed).
278    pub line: usize,
279    /// Column number (1-indexed).
280    pub column: usize,
281}
282
283impl LedgerError {
284    /// Create a new error with the given phase.
285    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
286        Self {
287            severity: ErrorSeverity::Error,
288            code: code.into(),
289            message: message.into(),
290            location: None,
291            source_span: None,
292            file_id: None,
293            phase: "validate".to_string(),
294        }
295    }
296
297    /// Create a new warning.
298    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
299        Self {
300            severity: ErrorSeverity::Warning,
301            code: code.into(),
302            message: message.into(),
303            location: None,
304            source_span: None,
305            file_id: None,
306            phase: "validate".to_string(),
307        }
308    }
309
310    /// Attach a source span and file ID so rich renderers can draw a snippet.
311    #[must_use]
312    pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
313        self.source_span = Some(span);
314        self.file_id = Some(file_id);
315        self
316    }
317
318    /// Set the processing phase for this error.
319    #[must_use]
320    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
321        self.phase = phase.into();
322        self
323    }
324
325    /// Add a location to this error.
326    #[must_use]
327    pub fn with_location(mut self, location: ErrorLocation) -> Self {
328        self.location = Some(location);
329        self
330    }
331}
332
333/// Process a raw load result into a fully processed ledger.
334///
335/// Pipeline (see numbered comments below for the rationale of each step):
336///
337/// ```text
338///   1. sort                         (canonical display order)
339///   2. synth plugins                (auto_accounts, document_discovery)
340///   3. Early validation             (account presence, structural, lifecycle)
341///   4. booking                      (cost spec resolution, interpolation)
342///   5. partition                    (set aside failed-booking txns)
343///   6. regular plugins              (file plugins + extras, on booked only)
344///   7. Late validation              (balance, currency, inventory, on booked only)
345///   8. finalize                     (unused-pad warnings)
346///   9. re-merge                     (booked + failed → final Ledger.directives)
347/// ```
348pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
349    let mut errors: Vec<LedgerError> = Vec::new();
350
351    // Convert load errors to ledger errors (parse phase). Iterate by
352    // reference so `raw` stays borrowable for the rest of the pipeline
353    // (the phase transitions and validator setup below borrow it).
354    for load_err in &raw.errors {
355        errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
356    }
357
358    // Phase-typed pipeline (issue #1166). The phantom-typed
359    // `Directives<P>` wrapper makes the sequence
360    //
361    //     Raw → Sorted → Synthed → EarlyValidated → Booked
362    //         → RegularPluginsApplied → LateValidated → Finalized
363    //
364    // a compile-time property of the type system. Each transition
365    // method consumes one phase and produces the next; the compiler
366    // rejects any call-site that drops a phase, swaps two, or invokes
367    // a later phase on raw input. See `crates/rustledger-loader/src/phase.rs`.
368    //
369    // The transitions themselves wrap the existing subsystem entry
370    // points (`run_booking`, `run_plugins`, validators) without
371    // changing their semantics — this PR is the structural refactor
372    // only; behavior is bit-identical to the pre-#1166 pipeline.
373
374    // Resolve the effective booking method once, before the pipeline
375    // starts, so both the validator (early/late phases — needs it to
376    // seed each opened account's per-account booking method, see
377    // issue #1182) and the booking engine see the same value. File-
378    // level `option "booking_method"` wins when explicitly set;
379    // otherwise the API-level `LoadOptions.booking_method` is used.
380    let effective_booking_method = resolve_effective_booking_method(&raw, options);
381
382    #[cfg(feature = "validation")]
383    let validation_session = if options.validate {
384        Some(rustledger_validate::ValidationSession::new(
385            build_validation_options(&raw.options, &raw.source_map, effective_booking_method),
386        ))
387    } else {
388        None
389    };
390
391    // Compute `today` once for both phases — avoids a midnight-crossing
392    // race where Early and Late could disagree on what day it is, and
393    // gives `FutureDate` warnings a single coherent reference point.
394    #[cfg(feature = "validation")]
395    let today = jiff::Zoned::now().date();
396
397    let synthed = crate::Directives::<crate::Raw>::from_parser(raw.directives)
398        .sort()
399        .apply_synth_plugins(
400            &raw.plugins,
401            &raw.options,
402            options,
403            &raw.source_map,
404            &mut errors,
405        )?;
406
407    // The validation feature changes `early_validate`'s shape: with
408    // it on we thread the `Option<ValidationSession<Pending>>` in and
409    // catch the returned `Option<ValidationSession<EarlyDone>>` for
410    // `late_validate` (typestate-moved per #1236); without it we just
411    // get the next-phase `Directives` back. Branching here keeps each
412    // cfg's signature small and prevents the call site from having to
413    // know the typestate phase parameters in the disabled case.
414    #[cfg(feature = "validation")]
415    let (directives, validation_session) =
416        synthed.early_validate(validation_session, today, &raw.source_map, &mut errors);
417    #[cfg(not(feature = "validation"))]
418    let directives = synthed.early_validate(&raw.source_map, &mut errors);
419
420    // Capture realized capital gains produced by the canonical booking pass, but
421    // only when the caller asked for them (the capgains report) — no consumer pays
422    // to retain them otherwise.
423    let mut capital_gains: Vec<rustledger_booking::CapitalGain> = Vec::new();
424    // Interpolation quantizes solved amounts against the transaction's balance
425    // tolerance, so the booking pass needs the ledger's own tolerance knobs —
426    // the same three the balance validator reads. Left on defaults, a ledger
427    // that customizes them would interpolate to one grid and be validated
428    // against another.
429    let tolerance_policy = rustledger_booking::TolerancePolicy {
430        multiplier: raw.options.inferred_tolerance_multiplier,
431        infer_from_cost: raw.options.infer_tolerance_from_cost,
432        defaults: raw.options.inferred_tolerance_default.clone(),
433    };
434    let (booked, failed) = directives.book(
435        effective_booking_method,
436        tolerance_policy,
437        &mut errors,
438        options.collect_capital_gains.then_some(&mut capital_gains),
439    );
440
441    let regular_applied = booked.apply_regular_plugins(
442        &raw.plugins,
443        &raw.options,
444        options,
445        &raw.source_map,
446        &mut errors,
447    )?;
448
449    #[cfg(feature = "validation")]
450    let late_validated =
451        regular_applied.late_validate(validation_session, today, &raw.source_map, &mut errors);
452    #[cfg(not(feature = "validation"))]
453    let late_validated = regular_applied.late_validate(&raw.source_map, &mut errors);
454
455    let finalized = late_validated.finalize(failed);
456
457    Ok(Ledger {
458        directives: finalized.into_inner(),
459        options: raw.options,
460        plugins: raw.plugins,
461        source_map: raw.source_map,
462        errors,
463        display_context: raw.display_context,
464        capital_gains,
465    })
466}
467
468/// Resolve the booking method from `LoadOptions` + file-level option.
469///
470/// Factored out of `process()` so both the validator session (which
471/// needs it to seed per-account booking) and the booking engine see
472/// the same value. File-level `option "booking_method"` wins when
473/// explicitly set; otherwise the API-level default is used.
474fn resolve_effective_booking_method(
475    raw: &LoadResult,
476    options: &LoadOptions,
477) -> rustledger_core::BookingMethod {
478    let file_set = raw.options.set_options.contains("booking_method");
479    if file_set {
480        raw.options
481            .booking_method
482            .parse()
483            .unwrap_or(options.booking_method)
484    } else {
485        options.booking_method
486    }
487}
488
489// ============================================================================
490// Phase transitions
491// ============================================================================
492//
493// Each transition consumes a `Directives<P>` of one phase and
494// produces a `Directives<NextP>` of the next phase. Bodies wrap the
495// existing subsystem calls (`run_booking`, `run_plugins`, validators)
496// without changing their semantics — only the type-level sequencing
497// is new. See `phase.rs` for the phase markers and overall rationale.
498
499/// Canonical display-order sort key: `(date, priority, file_id, span.start)`.
500/// What BQL / JSON / format output expects and what Python beancount
501/// produces. Used by `sort` (initial ordering) and `finalize` (re-sort
502/// after merging failed bookings back in).
503type CanonicalSortKey = (
504    rustledger_core::NaiveDate,
505    rustledger_core::DirectivePriority,
506    u16,
507    usize,
508);
509
510#[inline]
511const fn canonical_sort_key(d: &Spanned<Directive>) -> CanonicalSortKey {
512    (d.value.date(), d.value.priority(), d.file_id, d.span.start)
513}
514
515impl crate::Directives<crate::Raw> {
516    /// Sort directives into canonical display order — see
517    /// [`canonical_sort_key`].
518    ///
519    /// Booking needs a different iteration order (augmentations
520    /// BEFORE reductions on the same `(date, priority)`) but doesn't
521    /// need the underlying vec reordered — `run_booking` walks via
522    /// a transient `Vec<usize>` index. This sort goes once, here,
523    /// and the display order survives the rest of the pipeline.
524    #[must_use]
525    pub(crate) fn sort(mut self) -> crate::Directives<crate::Sorted> {
526        self.as_vec_mut().sort_by_key(canonical_sort_key);
527        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
528    }
529}
530
531impl crate::Directives<crate::Sorted> {
532    /// Run synth-only plugins (`auto_accounts`, `document_discovery`)
533    /// BEFORE early validation so the synthesizers inject Opens /
534    /// Documents that Early checks depend on (E1001 account
535    /// presence, E5001 missing-document file).
536    ///
537    /// Only this narrow synth subset runs here; everything else
538    /// waits until after booking (post-booking plugin pass) so
539    /// cost-spec-reading plugins see filled-in per-unit values on
540    /// `CostNumber::PerUnitFromTotal`. See `PluginPass` rustdoc for
541    /// the detailed split rationale.
542    pub(crate) fn apply_synth_plugins(
543        mut self,
544        plugins: &[crate::Plugin],
545        file_options: &crate::Options,
546        options: &LoadOptions,
547        source_map: &SourceMap,
548        errors: &mut Vec<LedgerError>,
549    ) -> Result<crate::Directives<crate::Synthed>, ProcessError> {
550        // `run_plugins` early-returns when no plugin entry matches the
551        // pass; no outer gate needed (and any outer gate risked
552        // missing one of the implicit-synth triggers — auto_accounts,
553        // document_discovery via `option "documents"`, file-declared
554        // synth plugins).
555        #[cfg(feature = "plugins")]
556        run_plugins(
557            self.as_vec_mut(),
558            plugins,
559            file_options,
560            options,
561            source_map,
562            errors,
563            PluginPass::PreBookingSynth,
564        )?;
565        // Suppress unused-arg warnings when `plugins` feature is off.
566        #[cfg(not(feature = "plugins"))]
567        {
568            let _ = (plugins, file_options, options, source_map, errors);
569        }
570        Ok(crate::Directives::new_unchecked(std::mem::take(
571            self.as_vec_mut(),
572        )))
573    }
574}
575
576impl crate::Directives<crate::Synthed> {
577    /// Run the early-phase validators. Account-presence /
578    /// lifecycle / structural errors are collected into `errors`
579    /// (via the `LedgerError` stream); the directive list itself is
580    /// unchanged by validation.
581    ///
582    /// Runs on pre-booking directives, AFTER synth plugins so
583    /// account-presence checks (E1001) see any Opens that plugins
584    /// like `auto_accounts` injected. This is what lets booking
585    /// match Python's "prune zero-interp postings" behavior without
586    /// losing E1001 on the elided-zero-to-unopened-account case
587    /// (rustledger#877).
588    #[cfg(feature = "validation")]
589    pub(crate) fn early_validate(
590        mut self,
591        validation_session: Option<
592            rustledger_validate::ValidationSession<rustledger_validate::Pending>,
593        >,
594        today: rustledger_core::NaiveDate,
595        source_map: &SourceMap,
596        errors: &mut Vec<LedgerError>,
597    ) -> (
598        crate::Directives<crate::EarlyValidated>,
599        Option<rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>>,
600    ) {
601        // Typestate move: consume `Pending`, return `EarlyDone`. The
602        // session must be threaded by value rather than `&mut`-borrowed
603        // because the phase parameter on `ValidationSession<P>` changes
604        // as a result of the call (#1236). The caller in `process()`
605        // captures the returned session and passes it to
606        // `late_validate`.
607        let session_out = validation_session.map(|session| {
608            let (session, phase_errors) = session.run_early_spanned(self.as_slice(), today);
609            ledger_errors_extend(errors, phase_errors, source_map);
610            session
611        });
612        (
613            crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut())),
614            session_out,
615        )
616    }
617
618    #[cfg(not(feature = "validation"))]
619    pub(crate) fn early_validate(
620        mut self,
621        source_map: &SourceMap,
622        errors: &mut Vec<LedgerError>,
623    ) -> crate::Directives<crate::EarlyValidated> {
624        let _ = (source_map, errors);
625        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
626    }
627}
628
629impl crate::Directives<crate::EarlyValidated> {
630    /// Run booking/interpolation. Returns the successfully-booked
631    /// directives plus a typed wrapper holding failed transactions.
632    ///
633    /// Failed transactions are in pre-booking shape (unresolved cost
634    /// specs, unfilled elided slots, possibly unbalanced); they
635    /// don't flow into regular plugins or Late validation — booking
636    /// already reported the root cause and the downstream checks
637    /// would cascade misleading errors. They get re-merged at
638    /// [`crate::Directives::<crate::LateValidated>::finalize`].
639    pub(crate) fn book(
640        mut self,
641        effective_method: rustledger_core::BookingMethod,
642        tolerance_policy: rustledger_booking::TolerancePolicy,
643        errors: &mut Vec<LedgerError>,
644        gains: Option<&mut Vec<rustledger_booking::CapitalGain>>,
645    ) -> (
646        crate::Directives<crate::Booked>,
647        crate::phase::FailedBookings,
648    ) {
649        let (booked, failed) = run_booking(
650            std::mem::take(self.as_vec_mut()),
651            effective_method,
652            tolerance_policy,
653            errors,
654            gains,
655        );
656        (
657            crate::Directives::new_unchecked(booked),
658            crate::phase::FailedBookings::new(failed),
659        )
660    }
661}
662
663impl crate::Directives<crate::Booked> {
664    /// Run post-booking plugins — file-declared + CLI extras.
665    /// Cost-spec-reading plugins (`implicit_prices`,
666    /// `capital_gains_classifier`, `check_average_cost`,
667    /// `sell_gains`, `unrealized`, `valuation`) see filled-in
668    /// per-unit values on `CostNumber::PerUnitFromTotal` because
669    /// booking has run.
670    ///
671    /// Matches Python beancount's plugins-after-booking ordering
672    /// and closes rustledger#1117. Failed transactions were
673    /// partitioned out by `book`; plugins only see
674    /// successfully-booked input.
675    pub(crate) fn apply_regular_plugins(
676        mut self,
677        plugins: &[crate::Plugin],
678        file_options: &crate::Options,
679        options: &LoadOptions,
680        source_map: &SourceMap,
681        errors: &mut Vec<LedgerError>,
682    ) -> Result<crate::Directives<crate::RegularPluginsApplied>, ProcessError> {
683        // `run_plugins` early-returns when no plugin entry matches
684        // the pass; no outer gate needed.
685        #[cfg(feature = "plugins")]
686        run_plugins(
687            self.as_vec_mut(),
688            plugins,
689            file_options,
690            options,
691            source_map,
692            errors,
693            PluginPass::PostBooking,
694        )?;
695        #[cfg(not(feature = "plugins"))]
696        {
697            let _ = (plugins, file_options, options, source_map, errors);
698        }
699        Ok(crate::Directives::new_unchecked(std::mem::take(
700            self.as_vec_mut(),
701        )))
702    }
703}
704
705impl crate::Directives<crate::RegularPluginsApplied> {
706    /// Run the late-phase validators on booked + plugin-processed
707    /// directives. Reuses the `ValidationSession` from
708    /// `early_validate` so account / commodity / pad bookkeeping
709    /// carries forward.
710    #[cfg(feature = "validation")]
711    pub(crate) fn late_validate(
712        mut self,
713        validation_session: Option<
714            rustledger_validate::ValidationSession<rustledger_validate::EarlyDone>,
715        >,
716        today: rustledger_core::NaiveDate,
717        source_map: &SourceMap,
718        errors: &mut Vec<LedgerError>,
719    ) -> crate::Directives<crate::LateValidated> {
720        // Typestate move: consume `EarlyDone`, drive through `LateDone`
721        // to `finalize()`. The compile-time enforcement here is that
722        // we cannot call `late_validate` with a fresh `Pending` session
723        // (no `From<Pending>` to `EarlyDone`), so the loader caller
724        // must have routed the session through `early_validate` first
725        // (#1236).
726        if let Some(session) = validation_session {
727            let (session, phase_errors) = session.run_late_spanned(self.as_slice(), today);
728            ledger_errors_extend(errors, phase_errors, source_map);
729            let finalize_errors = session.finalize();
730            ledger_errors_extend(errors, finalize_errors, source_map);
731        }
732        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
733    }
734
735    #[cfg(not(feature = "validation"))]
736    pub(crate) fn late_validate(
737        mut self,
738        source_map: &SourceMap,
739        errors: &mut Vec<LedgerError>,
740    ) -> crate::Directives<crate::LateValidated> {
741        let _ = (source_map, errors);
742        crate::Directives::new_unchecked(std::mem::take(self.as_vec_mut()))
743    }
744}
745
746impl crate::Directives<crate::LateValidated> {
747    /// Re-merge failed (un-booked) transactions back into the
748    /// directive list for output. The user wrote them and expects
749    /// to see them in `Ledger.directives`; we kept them isolated
750    /// from post-booking processing.
751    ///
752    /// Re-sorts to restore canonical display order — `booked`
753    /// retained order during plugin transformation; the sort
754    /// restores the failed entries' positions.
755    pub(crate) fn finalize(
756        mut self,
757        failed: crate::phase::FailedBookings,
758    ) -> crate::Directives<crate::Finalized> {
759        let mut v = std::mem::take(self.as_vec_mut());
760        v.extend(failed.into_inner());
761        v.sort_by_key(canonical_sort_key);
762
763        // Normalize `@@` total prices to per-unit (`@`) as the final pipeline
764        // step. This runs AFTER Late validation, so exact totals still survived
765        // for the precise balance-residual check (#1240) — which is the entire
766        // reason normalization is deferred rather than done during booking.
767        //
768        // Doing it HERE, in the one transition that produces `Finalized` (the
769        // only publicly-exposed phase), makes "prices are normalized" an
770        // invariant of every loaded `Ledger`: `rledger check`, the FFI/MCP
771        // component, and BQL all get it by construction, and none can drift by
772        // forgetting to normalize. It was previously bolted onto the CLI `check`
773        // path only, so the FFI surface silently regressed to exposing raw `@@`
774        // totals when it moved onto this shared pipeline (#1462).
775        for spanned in &mut v {
776            if let Directive::Transaction(txn) = &mut spanned.value {
777                rustledger_booking::normalize_prices(txn);
778            }
779        }
780
781        crate::Directives::new_unchecked(v)
782    }
783}
784
785/// Run booking and interpolation on transactions, returning the
786/// directives partitioned into `(booked, failed)`.
787///
788/// The caller has already sorted `directives` into canonical display
789/// order `(date, priority, file_id, span.start)`. Booking needs the
790/// same ordering. Rather than assume that, we walk the vec via a
791/// transient `Vec<usize>` of indices sorted by booking order, which
792/// keeps `booking_sort_key` the one place a booking-order tiebreak
793/// could ever be introduced. Since #2093 dropped the reduction
794/// tiebreak the permutation is the identity here, and the stable sort
795/// is what guarantees that.
796///
797/// Failed transactions are partitioned out into the second return
798/// value so they don't flow into regular plugins or Late validation
799/// (they're in pre-booking shape — postings have unresolved cost
800/// specs and unfilled elided slots, so downstream processing would
801/// cascade misleading errors). The caller is responsible for
802/// re-merging `failed` into the final `Ledger.directives` for output
803/// so the user still sees their original input.
804fn run_booking(
805    mut directives: Vec<Spanned<Directive>>,
806    booking_method: BookingMethod,
807    tolerance_policy: rustledger_booking::TolerancePolicy,
808    errors: &mut Vec<LedgerError>,
809    mut gains: Option<&mut Vec<rustledger_booking::CapitalGain>>,
810) -> (Vec<Spanned<Directive>>, Vec<Spanned<Directive>>) {
811    use rustledger_booking::BookingEngine;
812
813    let mut engine =
814        BookingEngine::with_method(booking_method).with_tolerance_policy(tolerance_policy);
815    engine.register_account_methods(directives.iter().map(|s| &s.value));
816
817    // Build an index ordered for booking. `directives` is already in
818    // display order — `(date, priority, file_id, span.start)` — and the
819    // booking key is its `(date, priority)` prefix, so a stable sort
820    // returns the identity permutation. It is kept rather than elided so
821    // that booking order has exactly one definition to change.
822    let mut order: Vec<usize> = (0..directives.len()).collect();
823    order.sort_by_key(|&i| rustledger_core::booking_sort_key(&directives[i].value));
824
825    let mut failed_indices: Vec<usize> = Vec::new();
826    for &i in &order {
827        let spanned = &mut directives[i];
828        if let Directive::Transaction(txn) = &mut spanned.value {
829            // Applying is part of booking this transaction: an overflow there
830            // must fail it, not merely warn. Otherwise the transaction counts
831            // as booked while the running balance it should have updated
832            // silently did not (#1863). `book_interpolate_apply` does all
833            // three and leaves `txn` as the author wrote it if any of them
834            // fails, which is what the `failed` partition below hands back to
835            // the ledger.
836            match engine.book_interpolate_apply(txn) {
837                Ok(txn_gains) => {
838                    if let Some(g) = gains.as_deref_mut() {
839                        g.extend(txn_gains);
840                    }
841                }
842                Err(e) => {
843                    errors.push(LedgerError::error(
844                        "BOOK",
845                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
846                    ));
847                    failed_indices.push(i);
848                }
849            }
850        }
851    }
852
853    // Partition into (booked, failed). Indices are valid in the current
854    // `directives` vec (no mutation has happened since they were
855    // collected); after this consuming iteration the vec is gone and
856    // partition is fait accompli — no window where a caller could
857    // accidentally mutate between collection and partition.
858    let failed_set: rustc_hash::FxHashSet<usize> = failed_indices.iter().copied().collect();
859    let mut booked = Vec::with_capacity(directives.len() - failed_indices.len());
860    let mut failed = Vec::with_capacity(failed_indices.len());
861    for (i, d) in directives.into_iter().enumerate() {
862        if failed_set.contains(&i) {
863            failed.push(d);
864        } else {
865            booked.push(d);
866        }
867    }
868    (booked, failed)
869}
870
871/// Which subset of plugins to run.
872///
873/// The loader pipeline calls `run_plugins` twice: once with
874/// [`PluginPass::PreBookingSynth`] before the Early validation phase
875/// (so synthesizers can inject Opens / Documents that early checks
876/// depend on), and once with [`PluginPass::PostBooking`] after booking
877/// (so cost-spec-reading plugins like `implicit_prices`,
878/// `capital_gains_classifier`, `check_average_cost`, `sell_gains`,
879/// `unrealized`, and `valuation` see filled-in per-unit values on the
880/// `CostNumber::PerUnitFromTotal` variant).
881///
882/// Standalone callers (LSP / FFI / tests on already-booked input) pass
883/// [`PluginPass::PostBooking`] — synth plugins are a loader-internal
884/// concern and would re-Open already-opened accounts if run a second
885/// time.
886#[cfg(feature = "plugins")]
887#[derive(Debug, Clone, Copy, PartialEq, Eq)]
888pub enum PluginPass {
889    /// Only plugins that synthesize directives the Early validator
890    /// depends on: `auto_accounts` (synthesizes Open directives) and
891    /// the built-in document discovery walker (synthesizes Document
892    /// directives the early phase checks for missing files).
893    PreBookingSynth,
894    /// All file-declared plugins and CLI `extra_plugins`, EXCLUDING
895    /// `auto_accounts` and `document_discovery` (those ran pre-booking).
896    /// Includes the 28 plugins that don't depend on synth state but
897    /// may depend on booked cost specs.
898    PostBooking,
899}
900
901/// Run plugins on directives.
902///
903/// Executes native plugins (and document discovery) on the given directives,
904/// modifying them in-place. Plugin errors are appended to `errors`.
905///
906/// A single plugin invocation in `run_plugins`'s unified dispatch
907/// list. `force_python` ("python:..." prefix) overrides native
908/// resolution; `config` is the plugin-specific string passed to
909/// `PluginInput.config`.
910#[cfg(feature = "plugins")]
911struct PluginInvocation {
912    name: String,
913    config: Option<String>,
914    force_python: bool,
915}
916
917/// `pass` selects which subset of plugins to run — see [`PluginPass`].
918/// The loader pipeline calls this twice (synth pass before Early,
919/// regular pass after booking).
920#[cfg(feature = "plugins")]
921pub fn run_plugins(
922    directives: &mut Vec<Spanned<Directive>>,
923    file_plugins: &[Plugin],
924    file_options: &Options,
925    options: &LoadOptions,
926    source_map: &SourceMap,
927    errors: &mut Vec<LedgerError>,
928    pass: PluginPass,
929) -> Result<(), ProcessError> {
930    use rustledger_plugin::{NativePluginRegistry, PluginOptions};
931
932    // Resolve document directories relative to the main file's directory.
933    // Used to build doc_discovery's per-call config in the synth pass.
934    let base_dir = source_map
935        .files()
936        .first()
937        .and_then(|f| f.path.parent())
938        .unwrap_or_else(|| std::path::Path::new("."));
939
940    // Access the process-wide registry singleton. The registry is
941    // immutable and stateless, so the same instance services every
942    // call.
943    let registry = NativePluginRegistry::global();
944
945    // Build the unified list of plugins to invoke for this pass:
946    //   1. Implicit synth plugins triggered by `LoadOptions` /
947    //      `file_options` (auto_accounts via `options.auto_accounts`;
948    //      document_discovery via non-empty `file_options.documents`).
949    //   2. File-declared plugins from `plugin "..."` directives.
950    //   3. CLI `--plugin` extras.
951    // Pass classification happens here — once — via `registry.find_synth`.
952    // A plugin enters the list iff its pass matches the requested `pass`.
953    let mut entries: Vec<PluginInvocation> = Vec::new();
954
955    if matches!(pass, PluginPass::PreBookingSynth) {
956        // Implicit synth: API-level auto_accounts flag.
957        if options.auto_accounts {
958            entries.push(PluginInvocation {
959                name: rustledger_plugin::AUTO_ACCOUNTS_NAME.to_string(),
960                config: None,
961                force_python: false,
962            });
963        }
964        // Implicit synth: document_discovery, driven by `option "documents"`.
965        // The plugin sits in the registry as a ZST; we hand it the
966        // resolved directories + base_dir via its config JSON.
967        if options.run_plugins && !file_options.documents.is_empty() {
968            let resolved: Vec<String> = file_options
969                .documents
970                .iter()
971                .map(|d| {
972                    let path = std::path::Path::new(d);
973                    if path.is_absolute() {
974                        d.clone()
975                    } else {
976                        base_dir.join(path).to_string_lossy().to_string()
977                    }
978                })
979                .collect();
980            entries.push(PluginInvocation {
981                name: rustledger_plugin::DOCUMENT_DISCOVERY_NAME.to_string(),
982                config: Some(rustledger_plugin::document_discovery_config(
983                    base_dir, &resolved,
984                )),
985                force_python: false,
986            });
987        }
988    }
989
990    // A plugin name belongs in the current pass iff its synth-marker
991    // membership matches `pass`. Non-native plugins (WASM/Python) are
992    // never in the synth registry and therefore always fall into the
993    // PostBooking pass.
994    let want_synth = matches!(pass, PluginPass::PreBookingSynth);
995
996    // File-declared plugins.
997    if options.run_plugins {
998        for plugin in file_plugins {
999            if registry.find_synth(&plugin.name).is_some() == want_synth {
1000                entries.push(PluginInvocation {
1001                    name: plugin.name.clone(),
1002                    config: plugin.config.clone(),
1003                    force_python: plugin.force_python,
1004                });
1005            }
1006        }
1007    }
1008
1009    // CLI extra plugins.
1010    for extra in &options.extra_plugins {
1011        if registry.find_synth(&extra.name).is_some() == want_synth {
1012            entries.push(PluginInvocation {
1013                name: extra.name.clone(),
1014                config: extra.config.clone(),
1015                force_python: false,
1016            });
1017        }
1018    }
1019
1020    if entries.is_empty() {
1021        return Ok(());
1022    }
1023
1024    let plugin_options = PluginOptions {
1025        operating_currencies: file_options.operating_currency.clone(),
1026        title: file_options.title.clone(),
1027        // Without these a plugin can only hardcode `Expenses:` etc., which
1028        // silently matches nothing on a renamed ledger (#1964).
1029        account_types: rustledger_plugin::PluginAccountTypes {
1030            assets: file_options.name_assets.clone(),
1031            liabilities: file_options.name_liabilities.clone(),
1032            equity: file_options.name_equity.clone(),
1033            income: file_options.name_income.clone(),
1034            expenses: file_options.name_expenses.clone(),
1035        },
1036    };
1037
1038    // Dispatch each entry: resolve it to a concrete runtime, then run + apply
1039    // uniformly. Resolution (classification, path-security, feature-gating, the
1040    // #1432 module-name rejection) lives in `resolve_plugin`; execution lives in
1041    // `ResolvedPlugin::run`. Building wrappers and applying ops here — once, not
1042    // once per runtime — is the point of the resolve/run split.
1043    let pass_kind = match pass {
1044        PluginPass::PreBookingSynth => rustledger_plugin::PluginPass::Synth,
1045        PluginPass::PostBooking => rustledger_plugin::PluginPass::Regular,
1046    };
1047    for invocation in &entries {
1048        // Resolution (classify + path-security + feature-gate + #1432 reject)
1049        // lives in `rustledger_plugin::resolve_plugin`; execution in
1050        // `ResolvedPlugin::run`. The loader keeps wrapper building, op
1051        // application, and its error-code convention.
1052        let resolved = match rustledger_plugin::resolve_plugin(
1053            &invocation.name,
1054            invocation.force_python,
1055            pass_kind,
1056            registry,
1057            base_dir,
1058            options.path_security,
1059        ) {
1060            Ok(resolved) => resolved,
1061            Err(e) => {
1062                errors.push(resolve_error_to_ledger(&e));
1063                continue;
1064            }
1065        };
1066
1067        // Rebuild wrappers per plugin so each sees the prior plugin's applied
1068        // ops, then convert + apply uniformly regardless of runtime. Every
1069        // runtime's diagnostics now flow through `record_plugin_errors`, so a
1070        // plugin-set source location is preserved (the old WASM/Python runner
1071        // conversions dropped it; native always kept it).
1072        let wrappers = build_wrappers(directives, source_map);
1073        match resolved.run(wrappers, &plugin_options, &invocation.config, base_dir) {
1074            Ok(output) => {
1075                record_plugin_errors(errors, output.errors, source_map);
1076                apply_plugin_ops(directives, output.ops, errors, source_map)?;
1077            }
1078            Err(e) => errors.push(run_error_to_ledger(&e)),
1079        }
1080    }
1081
1082    // No final wrapper→directive conversion needed: `apply_plugin_ops`
1083    // updates `directives` in place after each plugin call, preserving
1084    // original spans on Keep/Modify ops. Plugin-synthesized directives
1085    // (Insert ops) get `SYNTHESIZED_FILE_ID` and a zero span.
1086    Ok(())
1087}
1088
1089/// Build a fresh `Vec<DirectiveWrapper>` from the current directives,
1090/// carrying filename + line number for plugin-side error reporting.
1091/// Spans don't need to round-trip through the wrappers — the loader
1092/// preserves them via `apply_plugin_ops` matching on op index.
1093#[cfg(feature = "plugins")]
1094fn build_wrappers(
1095    directives: &[Spanned<Directive>],
1096    source_map: &SourceMap,
1097) -> Vec<rustledger_plugin::DirectiveWrapper> {
1098    use rustledger_plugin::directive_to_wrapper_with_location;
1099
1100    directives
1101        .iter()
1102        .map(|spanned| {
1103            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
1104                let (line, _col) = file.line_col(spanned.span.start);
1105                (Some(file.path.display().to_string()), Some(line as u32))
1106            } else {
1107                (None, None)
1108            };
1109            directive_to_wrapper_with_location(&spanned.value, filename, lineno)
1110        })
1111        .collect()
1112}
1113
1114/// Push plugin errors into the ledger's error stream, tagged with
1115/// `phase: "plugin"` and — when the plugin set `source_file` /
1116/// `line_number` on the error — an attached `ErrorLocation` so
1117/// downstream renderers (CLI, LSP, JSON output) can pinpoint where
1118/// the plugin objected.
1119///
1120/// Source-location resolution: if the wrapper's `source_file` resolves
1121/// to a real file in the source map, use that for `ErrorLocation.file`
1122/// and treat `line_number` as the line index. Plugin-synthesized
1123/// filenames (e.g. `"<auto_accounts>"`) that don't match any real
1124/// file are passed through as `PathBuf::from(name)` so the rendered
1125/// location still attributes the error to the originating plugin —
1126/// better than silently dropping the field.
1127#[cfg(feature = "plugins")]
1128fn record_plugin_errors(
1129    errors: &mut Vec<LedgerError>,
1130    plugin_errors: Vec<rustledger_plugin::PluginError>,
1131    source_map: &SourceMap,
1132) {
1133    for err in plugin_errors {
1134        let mut ledger_err = match err.severity {
1135            rustledger_plugin::PluginErrorSeverity::Error => {
1136                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
1137            }
1138            rustledger_plugin::PluginErrorSeverity::Warning => {
1139                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
1140            }
1141        };
1142        // Propagate plugin-set source location into `ErrorLocation`.
1143        // Column defaults to 1 — plugin errors don't carry column info
1144        // through the wrapper protocol.
1145        if let (Some(file), Some(line)) = (&err.source_file, err.line_number) {
1146            let resolved_path = source_map
1147                .get_by_path(std::path::Path::new(file))
1148                .map_or_else(|| std::path::PathBuf::from(file), |f| f.path.clone());
1149            ledger_err = ledger_err.with_location(ErrorLocation {
1150                file: resolved_path,
1151                line: line as usize,
1152                column: 1,
1153            });
1154        }
1155        errors.push(ledger_err);
1156    }
1157}
1158
1159/// Apply a plugin's `Vec<PluginOp>` to `directives` in place.
1160///
1161/// Validates that the op set forms a complete partition of the input
1162/// indices (each input index appears in exactly one `Keep` / `Modify` /
1163/// `Delete` op). Protocol violations produce a `PLUGIN` error in
1164/// `errors` and leave `directives` untouched.
1165///
1166/// For `Keep(i)` / `Modify(i, w)`, the resulting `Spanned<Directive>`
1167/// inherits `directives[i]`'s span and `file_id` — this is the core of
1168/// the ops protocol's correctness guarantee (plugin-transformed
1169/// directives keep their original source identity for error reporting).
1170/// `Insert(w)` directives get `(Span::ZERO, SYNTHESIZED_FILE_ID)`.
1171///
1172/// Inner posting spans returned by plugins are sanitized against the
1173/// host's `SourceMap` (see [`sanitize_inner_posting_spans`]) so a
1174/// misbehaving plugin cannot smuggle out-of-bounds spans into the LSP.
1175#[cfg(feature = "plugins")]
1176fn apply_plugin_ops(
1177    directives: &mut Vec<Spanned<Directive>>,
1178    ops: Vec<rustledger_plugin::PluginOp>,
1179    errors: &mut Vec<LedgerError>,
1180    source_map: &SourceMap,
1181) -> Result<(), ProcessError> {
1182    use rustledger_plugin::PluginOp;
1183    use rustledger_plugin::wrapper_to_directive;
1184
1185    // Validate the op set forms a complete cover of the input — the contract is
1186    // single-sourced in `rustledger-plugin` so the loader and FFI surfaces stay
1187    // in lock-step. On violation, surface the error and leave directives as-is.
1188    if let Err(msg) = rustledger_plugin::validate_op_coverage(directives.len(), &ops) {
1189        errors.push(LedgerError::error("PLUGIN", msg).with_phase("plugin"));
1190        return Ok(());
1191    }
1192
1193    // Materialize new directives, preserving spans for Keep/Modify.
1194    let mut new_directives = Vec::with_capacity(ops.len());
1195    for op in ops {
1196        match op {
1197            PluginOp::Keep(i) => {
1198                new_directives.push(directives[i].clone());
1199            }
1200            PluginOp::Modify(i, wrapper) => {
1201                let mut directive = wrapper_to_directive(&wrapper)
1202                    .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1203                // Plugins are not trusted to return well-formed inner
1204                // posting spans — a misbehaving plugin can synthesize a
1205                // file_id pointing at a nonexistent source or a span
1206                // that runs past EOF. The LSP later builds TextEdits
1207                // from these spans, so an out-of-bounds posting span
1208                // would produce a corrupt edit. Reset any inner posting
1209                // span that doesn't refer to a real loaded file or that
1210                // exceeds the file's length to `Spanned::synthesized`.
1211                sanitize_inner_posting_spans(&mut directive, source_map);
1212                new_directives.push(Spanned {
1213                    value: directive,
1214                    span: directives[i].span,
1215                    file_id: directives[i].file_id,
1216                });
1217            }
1218            PluginOp::Insert(wrapper) => {
1219                // Same trust caveat as Modify: don't let an Insert smuggle
1220                // bogus inner-posting spans through.
1221                // (Wrapper-derived outer span is validated below.)
1222                // Resolve the wrapper's filename + line number, if set,
1223                // into a real (file_id, span) when the filename
1224                // corresponds to a loaded source file. Falls back to
1225                // SYNTHESIZED_FILE_ID + zero span otherwise — including
1226                // for plugin-only attribution like `"<auto_accounts>"`
1227                // (which never matches a loaded file).
1228                let (span, file_id) = match (&wrapper.filename, wrapper.lineno) {
1229                    (Some(filename), Some(lineno)) => {
1230                        if let Some(file) = source_map.get_by_path(std::path::Path::new(filename)) {
1231                            let span_start = file.line_start(lineno as usize).unwrap_or(0);
1232                            (
1233                                rustledger_parser::Span::new(span_start, span_start),
1234                                file.id as u16,
1235                            )
1236                        } else {
1237                            (
1238                                rustledger_parser::Span::ZERO,
1239                                rustledger_parser::SYNTHESIZED_FILE_ID,
1240                            )
1241                        }
1242                    }
1243                    _ => (
1244                        rustledger_parser::Span::ZERO,
1245                        rustledger_parser::SYNTHESIZED_FILE_ID,
1246                    ),
1247                };
1248                let mut directive = wrapper_to_directive(&wrapper)
1249                    .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
1250                sanitize_inner_posting_spans(&mut directive, source_map);
1251                new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
1252            }
1253            PluginOp::Delete(_) => {}
1254        }
1255    }
1256
1257    *directives = new_directives;
1258    Ok(())
1259}
1260
1261/// Reset any inner `Spanned<Posting>` whose location does not refer to a
1262/// real loaded source range to [`Spanned::synthesized`]. Plugins are not
1263/// trusted to return well-formed `file_id` + byte ranges; without this,
1264/// a misbehaving plugin could induce out-of-bounds LSP text edits.
1265///
1266/// A span is considered valid when:
1267/// - `file_id == SYNTHESIZED_FILE_ID` (genuine synthesis), OR
1268/// - the `file_id` resolves in `SourceMap` AND `0 <= start <= end <= len`
1269///   for that file's source.
1270///
1271/// Everything else collapses to `Spanned::synthesized(posting)`. As a
1272/// final pass, synthesized postings that arrived with a non-zero span
1273/// are normalized to `Span::ZERO` so the in-memory state matches the
1274/// `Spanned::synthesized` constructor's contract (`file_id` +
1275/// `Span::ZERO`).
1276#[cfg(feature = "plugins")]
1277fn sanitize_inner_posting_spans(directive: &mut Directive, source_map: &SourceMap) {
1278    use rustledger_core::Span;
1279    use rustledger_parser::SYNTHESIZED_FILE_ID;
1280    if let Directive::Transaction(txn) = directive {
1281        for p in &mut txn.postings {
1282            let ok = if p.file_id == SYNTHESIZED_FILE_ID {
1283                true
1284            } else {
1285                source_map
1286                    .get(p.file_id as usize)
1287                    .is_some_and(|f| p.span.start <= p.span.end && p.span.end <= f.source.len())
1288            };
1289            if !ok {
1290                let inner = std::mem::replace(
1291                    &mut p.value,
1292                    rustledger_core::Posting::auto(rustledger_core::InternedStr::from("")),
1293                );
1294                *p = rustledger_core::Spanned::synthesized(inner);
1295            } else if p.file_id == SYNTHESIZED_FILE_ID && p.span != Span::ZERO {
1296                // Synthesized → span is meaningless; normalize so the
1297                // state is consistent with `Spanned::synthesized`.
1298                p.span = Span::ZERO;
1299            }
1300        }
1301    }
1302}
1303
1304/// Map loader [`Options`] to [`rustledger_validate::ValidationOptions`].
1305///
1306/// The single source of truth for the *option-derived* validation settings:
1307/// custom account-type names (`name_*`) and the tolerance options
1308/// (`inferred_tolerance_default`, `inferred_tolerance_multiplier`,
1309/// `infer_tolerance_from_cost`). Path-relative settings (document directories)
1310/// and the effective booking method are layered on by callers that hold the
1311/// necessary context — see `build_validation_options`.
1312///
1313/// Both `rledger check` (via `build_validation_options`) and the LSP/MCP
1314/// diagnostics path call this, so the two cannot drift. Issue #1648 was exactly
1315/// that drift: the LSP built its own `ValidationOptions` that dropped the
1316/// tolerance options, so it reported residual errors `check` did not.
1317#[cfg(feature = "validation")]
1318#[must_use]
1319pub fn validation_options_from_options(
1320    options: &Options,
1321) -> rustledger_validate::ValidationOptions {
1322    rustledger_validate::ValidationOptions::default()
1323        .with_account_types(
1324            options
1325                .account_types()
1326                .iter()
1327                .map(|s| (*s).to_string())
1328                .collect(),
1329        )
1330        .with_infer_tolerance_from_cost(options.infer_tolerance_from_cost)
1331        .with_tolerance_multiplier(options.inferred_tolerance_multiplier)
1332        .with_inferred_tolerance_default(options.inferred_tolerance_default.clone())
1333        // File-level `option "booking_method"`. `build_validation_options`
1334        // overrides this with the *effective* method (which also honors the
1335        // API-level `LoadOptions` default); callers without that override — the
1336        // LSP — get the file option, keeping editor diagnostics aligned with
1337        // `check` for booking-sensitive balance checks.
1338        .with_default_booking_method(
1339            options
1340                .booking_method
1341                .parse()
1342                .unwrap_or(BookingMethod::Strict),
1343        )
1344}
1345
1346/// Resolve `documents` option directories to filesystem paths.
1347///
1348/// Absolute entries pass through; relative entries join onto `base_dir` when it
1349/// is `Some`, otherwise are kept as-is (single-file buffers without an on-disk
1350/// path). Shared so `check` and the LSP resolve document directories identically.
1351///
1352/// NOT gated on the `validation` feature. It used to be, which is half of why
1353/// the E7006 existence check in `options.rs` grew its own `Path::new(value)`
1354/// instead of calling this — and that resolved against the process CWD (#1999).
1355/// A path helper with no validation dependency has no reason to be unavailable
1356/// to the loader.
1357#[must_use]
1358pub fn resolve_document_dirs(
1359    documents: &[String],
1360    base_dir: Option<&std::path::Path>,
1361) -> Vec<std::path::PathBuf> {
1362    documents
1363        .iter()
1364        .map(|d| {
1365            let path = std::path::Path::new(d);
1366            if path.is_absolute() {
1367                path.to_path_buf()
1368            } else if let Some(base) = base_dir {
1369                base.join(path)
1370            } else {
1371                path.to_path_buf()
1372            }
1373        })
1374        .collect()
1375}
1376
1377/// E7006 warnings for `option "documents"` roots that do not exist on disk.
1378///
1379/// The single source of truth for the check. `Loader::load` calls it with the
1380/// ledger's directory; the LSP's single-file fallback calls it with the open
1381/// buffer's directory. It deliberately does NOT live in `Options::set`, which
1382/// has no base dir and so could only ever ask about the process CWD — that was
1383/// #1999, where `rledger check sub/ledger.bean` reported a document root that
1384/// was sitting right next to the ledger.
1385///
1386/// A `None` `base_dir` (an unsaved buffer with no path) checks only absolute
1387/// roots. Relative ones are skipped rather than guessed at, because the only
1388/// thing left to resolve them against is the CWD, and that is the bug.
1389///
1390/// Probing goes through `fs` rather than [`std::path::Path::exists`] so the
1391/// check honors the loader's injected filesystem instead of reaching past it
1392/// to the host. That matters for in-memory loads: a [`VirtualFileSystem`] has
1393/// no directory entries, and a raw host probe would warn on every one.
1394///
1395/// [`VirtualFileSystem`]: crate::VirtualFileSystem
1396#[must_use]
1397pub fn document_root_warnings(
1398    documents: &[String],
1399    base_dir: Option<&std::path::Path>,
1400    fs: &dyn crate::vfs::FileSystem,
1401) -> Vec<crate::options::OptionWarning> {
1402    documents
1403        .iter()
1404        .zip(resolve_document_dirs(documents, base_dir))
1405        .filter(|(value, _)| base_dir.is_some() || std::path::Path::new(value).is_absolute())
1406        .filter(|(_, resolved)| !fs.dir_exists(resolved))
1407        .map(|(value, resolved)| crate::options::OptionWarning {
1408            code: "E7006",
1409            message: format!(
1410                "Document root '{value}' does not exist (resolved to '{}')",
1411                resolved.display()
1412            ),
1413            option: "documents".to_string(),
1414            value: value.clone(),
1415        })
1416        .collect()
1417}
1418
1419/// Per-`file_id` source-file directories, parallel to `source_map.files()`.
1420///
1421/// Lets the validator resolve a relative `document` path against its own
1422/// directive's file (matching Beancount and `include`) instead of the CWD.
1423#[cfg(feature = "validation")]
1424#[must_use]
1425pub fn document_source_dirs(source_map: &SourceMap) -> Vec<std::path::PathBuf> {
1426    source_map
1427        .files()
1428        .iter()
1429        .map(|f| {
1430            f.path.parent().map_or_else(
1431                || std::path::PathBuf::from("."),
1432                std::path::Path::to_path_buf,
1433            )
1434        })
1435        .collect()
1436}
1437
1438/// Build a [`ValidationOptions`] from loader-level file options.
1439///
1440/// Layers the path-relative document directories and the *effective* booking
1441/// method onto [`validation_options_from_options`] (the shared option-derived
1442/// core). Factored out of the old `run_validation` so both the early and late
1443/// phases in `process()` share the same `ValidationSession` configuration.
1444#[cfg(feature = "validation")]
1445fn build_validation_options(
1446    file_options: &Options,
1447    source_map: &SourceMap,
1448    default_booking_method: BookingMethod,
1449) -> rustledger_validate::ValidationOptions {
1450    // Document dirs resolve against the main file's parent directory (CWD as a
1451    // fallback when the source map is empty — matches the pre-refactor behavior).
1452    let base_dir = source_map
1453        .files()
1454        .first()
1455        .and_then(|f| f.path.parent())
1456        .unwrap_or_else(|| std::path::Path::new("."));
1457
1458    validation_options_from_options(file_options)
1459        .with_document_dirs(resolve_document_dirs(
1460            &file_options.documents,
1461            Some(base_dir),
1462        ))
1463        .with_document_source_dirs(document_source_dirs(source_map))
1464        .with_default_booking_method(default_booking_method)
1465}
1466
1467/// Convert a batch of [`rustledger_validate::ValidationError`]s into
1468/// loader-level [`LedgerError`]s (with resolved `file:line:column`
1469/// locations) and append to the existing list.
1470///
1471/// Factored out so both validation phases in `process()` share the
1472/// same conversion path.
1473#[cfg(feature = "validation")]
1474fn ledger_errors_extend(
1475    errors: &mut Vec<LedgerError>,
1476    validation_errors: Vec<rustledger_validate::ValidationError>,
1477    source_map: &SourceMap,
1478) {
1479    for err in validation_errors {
1480        let phase = if err.code.is_parse_phase() {
1481            "parse"
1482        } else {
1483            "validate"
1484        };
1485        let severity_level = if err.code.is_warning() {
1486            ErrorSeverity::Warning
1487        } else {
1488            ErrorSeverity::Error
1489        };
1490        // Fold the advisory note (if any) into the message so it propagates
1491        // through every downstream format (LedgerError, JSON diagnostic, CLI
1492        // report, LSP diagnostic) without each one needing a dedicated field.
1493        let message = match &err.note {
1494            Some(note) => format!("{err}\n  note: {note}"),
1495            None => err.to_string(),
1496        };
1497        // Resolve span + file_id into a file/line/column triple so CLI and
1498        // LSP consumers can render `file:line:col` headers without having
1499        // to do the lookup themselves (issue #901).
1500        let location = err.span.and_then(|span| {
1501            let fid = err.file_id? as usize;
1502            let file = source_map.get(fid)?;
1503            let (line, column) = file.line_col(span.start);
1504            Some(ErrorLocation {
1505                file: file.path.clone(),
1506                line,
1507                column,
1508            })
1509        });
1510        errors.push(LedgerError {
1511            severity: severity_level,
1512            code: err.code.code().to_string(),
1513            message,
1514            location,
1515            source_span: err.span.map(|s| (s.start, s.end)),
1516            file_id: err.file_id,
1517            phase: phase.to_string(),
1518        });
1519    }
1520}
1521
1522/// Load and fully process a beancount file.
1523///
1524/// This is the main entry point, equivalent to Python's `loader.load_file()`.
1525/// It performs: parse → sort → synth-plugins → Early → book → regular-plugins → Late → finalize.
1526///
1527/// # Example
1528///
1529/// ```ignore
1530/// use rustledger_loader::{load, LoadOptions};
1531/// use std::path::Path;
1532///
1533/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
1534/// for error in &ledger.errors {
1535///     eprintln!("{}: {}", error.code, error.message);
1536/// }
1537/// ```
1538pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
1539    let mut loader = crate::Loader::new();
1540
1541    if options.path_security {
1542        loader = loader.with_path_security(true);
1543    }
1544
1545    let raw = loader.load(path)?;
1546    process(raw, options)
1547}
1548
1549/// Like [`load`], but with a caller-provided [`FileSystem`](crate::FileSystem).
1550///
1551/// Lets the WASI component inject a filesystem whose
1552/// [`decrypt`](crate::FileSystem::decrypt) delegates to a host capability, so
1553/// GPG-encrypted ledgers load in the sandbox (a WASI guest can neither spawn
1554/// `gpg` nor reach the keyring) — #1667.
1555///
1556/// # Errors
1557///
1558/// Returns a [`ProcessError`] if loading or processing fails.
1559pub fn load_with_fs(
1560    path: &Path,
1561    options: &LoadOptions,
1562    fs: Box<dyn crate::FileSystem>,
1563) -> Result<Ledger, ProcessError> {
1564    let mut loader = crate::Loader::new().with_filesystem(fs);
1565
1566    if options.path_security {
1567        loader = loader.with_path_security(true);
1568    }
1569
1570    let raw = loader.load(path)?;
1571    process(raw, options)
1572}
1573
1574/// Load a beancount file without processing.
1575///
1576/// This returns raw directives without sorting, booking, or plugins.
1577/// Use this when you need the original parse output.
1578pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
1579    crate::Loader::new().load(path)
1580}
1581
1582/// Actionable error for a Python plugin referenced by module name. `file` is the
1583/// module's resolved source path when system Python could find it. The raw
1584/// "module not found" reads as a venv/PYTHONPATH problem, so name the
1585/// unsupported form and point at the file path instead. (#1432)
1586#[cfg(feature = "plugins")]
1587fn module_ref_message(raw_name: &str, file: Option<&str>) -> String {
1588    match file {
1589        Some(path) => format!(
1590            "Python plugin \"{raw_name}\" is not supported by module name; \
1591             reference the file directly: plugin \"{path}\""
1592        ),
1593        None => format!(
1594            "Python plugin \"{raw_name}\" is not supported by module name; \
1595             reference the file directly, e.g. plugin \"/path/to/plugin.py\". \
1596             The plugin sandbox cannot see the host venv, so the plugin must be \
1597             self-contained (stdlib plus the beancount compat shim)."
1598        ),
1599    }
1600}
1601
1602/// Map a typed [`rustledger_plugin::PluginResolveError`] to a host `LedgerError`,
1603/// preserving the loader's plugin error codes (`E8001`/`E8004`/`E8005`/`PLUGIN`)
1604/// and messages. The `rustledger-plugin` dispatcher is runtime-knowledge-pure
1605/// and does not own these codes; this is where the host convention is applied.
1606#[cfg(feature = "plugins")]
1607fn resolve_error_to_ledger(e: &rustledger_plugin::PluginResolveError) -> LedgerError {
1608    use rustledger_plugin::PluginResolveError as Re;
1609    match e {
1610        Re::PathOutsideBase { name } => LedgerError::error(
1611            "PLUGIN",
1612            format!("plugin path '{name}' is outside the ledger directory"),
1613        )
1614        .with_phase("plugin"),
1615        Re::WasmFeatureDisabled { name } => LedgerError::error(
1616            "PLUGIN",
1617            format!("WASM plugin '{name}' requires the wasm-plugins feature"),
1618        )
1619        .with_phase("plugin"),
1620        Re::PythonFeatureDisabled { name } => LedgerError::error(
1621            "E8005",
1622            format!("Python plugin \"{name}\" requires the python-plugins feature"),
1623        )
1624        .with_phase("plugin"),
1625        Re::PythonModuleName {
1626            name,
1627            suggested_file,
1628        } => LedgerError::error("E8004", module_ref_message(name, suggested_file.as_deref()))
1629            .with_phase("plugin"),
1630        Re::NotFound {
1631            name,
1632            suggested_file,
1633        } => match suggested_file {
1634            Some(path) => LedgerError::error("E8004", module_ref_message(name, Some(path)))
1635                .with_phase("plugin"),
1636            None => LedgerError::error("E8001", format!("Plugin not found: \"{name}\""))
1637                .with_phase("plugin"),
1638        },
1639    }
1640}
1641
1642/// Map a typed [`rustledger_plugin::PluginRunError`] to a host `LedgerError`.
1643#[cfg(feature = "plugins")]
1644fn run_error_to_ledger(e: &rustledger_plugin::PluginRunError) -> LedgerError {
1645    use rustledger_plugin::PluginRunError as Rn;
1646    match e {
1647        Rn::WasmFailed { path, message } => LedgerError::error(
1648            "PLUGIN",
1649            format!("WASM plugin {} failed: {message}", path.display()),
1650        )
1651        .with_phase("plugin"),
1652        Rn::PythonFailed { message } => {
1653            LedgerError::error("E8002", message.clone()).with_phase("plugin")
1654        }
1655    }
1656}
1657
1658#[cfg(all(test, feature = "validation"))]
1659mod finalize_price_tests {
1660    use rustledger_core::{Directive, PriceKind};
1661
1662    /// `finalize` normalizes `@@` (total) prices to per-unit (`@`), so every
1663    /// loaded `Ledger` carries normalized prices by construction — the FFI
1664    /// component and `rledger check` cannot disagree. Regression guard for #1462,
1665    /// where the FFI surface lost the normalization that lived only in the CLI
1666    /// `check` path and so exposed the raw `@@` total.
1667    #[test]
1668    fn finalize_normalizes_total_at_at_price_to_per_unit() {
1669        let dir = tempfile::tempdir().unwrap();
1670        let path = dir.path().join("main.bean");
1671        std::fs::write(
1672            &path,
1673            "2024-01-01 open Assets:Cash USD\n\
1674             2024-01-01 open Assets:Other EUR\n\
1675             2024-01-02 * \"total price\"\n  \
1676               Assets:Cash   7 USD @@ 10 EUR\n  \
1677               Assets:Other  -10 EUR\n",
1678        )
1679        .unwrap();
1680
1681        let ledger =
1682            super::load(&path, &super::LoadOptions::default()).expect("ledger should load");
1683        let price = ledger
1684            .directives
1685            .iter()
1686            .find_map(|s| match &s.value {
1687                Directive::Transaction(t) => t.postings.iter().find_map(|p| p.price.as_deref()),
1688                _ => None,
1689            })
1690            .expect("the `@@` posting should carry a price");
1691
1692        assert_eq!(
1693            price.kind,
1694            PriceKind::Unit,
1695            "`@@` must be normalized to a per-unit price, not left as a total"
1696        );
1697        let amount = price
1698            .amount
1699            .as_ref()
1700            .and_then(|a| a.as_amount())
1701            .expect("normalized per-unit amount present");
1702        // 10 EUR / 7 USD = 1.4285714… per unit — NOT the raw total `10`.
1703        assert!(
1704            amount.number.to_string().starts_with("1.42857"),
1705            "per-unit price should be 10/7, got {}",
1706            amount.number
1707        );
1708    }
1709}
1710
1711#[cfg(all(test, feature = "validation"))]
1712mod validation_options_tests {
1713    use super::validation_options_from_options;
1714    use crate::Options;
1715    use rust_decimal_macros::dec;
1716
1717    /// The shared converter must carry the per-currency tolerance override
1718    /// (`inferred_tolerance_default`) and `name_*` account types. This is the
1719    /// single source of truth both `check` and the LSP/MCP go through, so they
1720    /// cannot drift — issue #1648, where the LSP dropped the tolerance options
1721    /// and reported residual errors `check` did not.
1722    #[test]
1723    fn maps_inferred_tolerance_default_and_account_types() {
1724        let mut opts = Options::new();
1725        opts.set("inferred_tolerance_default", "CLP:0.5");
1726        opts.set("name_assets", "Activos");
1727
1728        let vo = validation_options_from_options(&opts);
1729
1730        assert_eq!(vo.inferred_tolerance_default.get("CLP"), Some(&dec!(0.5)));
1731        assert_eq!(vo.account_types[0], "Activos");
1732    }
1733}
1734
1735#[cfg(all(test, feature = "plugins"))]
1736mod sanitize_tests {
1737    use super::sanitize_inner_posting_spans;
1738    use crate::source_map::SourceMap;
1739    use rust_decimal_macros::dec;
1740    use rustledger_core::{
1741        Amount, Directive, IncompleteAmount, Posting, SYNTHESIZED_FILE_ID, Span, Spanned,
1742        Transaction,
1743    };
1744    use std::path::PathBuf;
1745    use std::sync::Arc;
1746
1747    fn txn_with_postings(postings: Vec<Spanned<Posting>>) -> Directive {
1748        let date = rustledger_core::naive_date(2024, 1, 15).unwrap();
1749        let mut txn = Transaction::new(date, "x");
1750        txn.postings = postings;
1751        Directive::Transaction(txn)
1752    }
1753
1754    fn posting_at(file_id: u16, span: Span) -> Spanned<Posting> {
1755        let p = Posting::with_incomplete(
1756            "Assets:Cash",
1757            IncompleteAmount::Complete(Amount::new(dec!(1), "USD")),
1758        );
1759        Spanned::new(p, span).with_file_id(file_id as usize)
1760    }
1761
1762    fn source_map_with_one_file(source: &str) -> (SourceMap, u16) {
1763        let mut sm = SourceMap::new();
1764        let id = sm.add_file(PathBuf::from("test.bean"), Arc::from(source));
1765        (sm, id as u16)
1766    }
1767
1768    #[test]
1769    fn span_within_real_file_is_preserved() {
1770        let (sm, fid) = source_map_with_one_file("0123456789");
1771        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(2, 6))]);
1772        sanitize_inner_posting_spans(&mut d, &sm);
1773        let Directive::Transaction(t) = &d else {
1774            unreachable!()
1775        };
1776        assert_eq!(t.postings[0].file_id, fid);
1777        assert_eq!(t.postings[0].span, Span::new(2, 6));
1778    }
1779
1780    #[test]
1781    fn span_past_eof_is_reset_to_synthesized() {
1782        // Bug case: a misbehaving plugin claims the posting extends past
1783        // the file's actual length. The sanitizer must reject it so the
1784        // LSP can't be tricked into producing an out-of-bounds TextEdit.
1785        let (sm, fid) = source_map_with_one_file("0123456789"); // 10 bytes
1786        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 9999))]);
1787        sanitize_inner_posting_spans(&mut d, &sm);
1788        let Directive::Transaction(t) = &d else {
1789            unreachable!()
1790        };
1791        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1792        assert_eq!(t.postings[0].span, Span::ZERO);
1793    }
1794
1795    #[test]
1796    fn unknown_file_id_is_reset_to_synthesized() {
1797        // Plugin claims a file_id that the host's SourceMap doesn't know.
1798        let (sm, _real) = source_map_with_one_file("hello");
1799        let mut d = txn_with_postings(vec![posting_at(123, Span::new(0, 5))]);
1800        sanitize_inner_posting_spans(&mut d, &sm);
1801        let Directive::Transaction(t) = &d else {
1802            unreachable!()
1803        };
1804        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1805        assert_eq!(t.postings[0].span, Span::ZERO);
1806    }
1807
1808    #[test]
1809    fn start_after_end_is_reset_to_synthesized() {
1810        let (sm, fid) = source_map_with_one_file("abcdef");
1811        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(5, 2))]);
1812        sanitize_inner_posting_spans(&mut d, &sm);
1813        let Directive::Transaction(t) = &d else {
1814            unreachable!()
1815        };
1816        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1817        assert_eq!(t.postings[0].span, Span::ZERO);
1818    }
1819
1820    #[test]
1821    fn synthesized_file_id_is_left_alone_but_span_normalized() {
1822        // file_id == SYNTHESIZED_FILE_ID with a non-zero span: the
1823        // sanitizer leaves it synthesized (span is meaningless for
1824        // synth postings) but normalizes to Span::ZERO for tidy state.
1825        let (sm, _fid) = source_map_with_one_file("x");
1826        let mut d = txn_with_postings(vec![posting_at(SYNTHESIZED_FILE_ID, Span::new(100, 200))]);
1827        sanitize_inner_posting_spans(&mut d, &sm);
1828        let Directive::Transaction(t) = &d else {
1829            unreachable!()
1830        };
1831        assert_eq!(t.postings[0].file_id, SYNTHESIZED_FILE_ID);
1832        assert_eq!(t.postings[0].span, Span::ZERO, "synth span normalized");
1833    }
1834
1835    #[test]
1836    fn boundary_span_eq_source_len_is_valid() {
1837        // end == source.len() is the canonical "to-end-of-file" span;
1838        // must not be rejected.
1839        let (sm, fid) = source_map_with_one_file("abcd");
1840        let mut d = txn_with_postings(vec![posting_at(fid, Span::new(0, 4))]);
1841        sanitize_inner_posting_spans(&mut d, &sm);
1842        let Directive::Transaction(t) = &d else {
1843            unreachable!()
1844        };
1845        assert_eq!(t.postings[0].file_id, fid);
1846        assert_eq!(t.postings[0].span, Span::new(0, 4));
1847    }
1848
1849    #[test]
1850    fn non_transaction_directive_is_left_alone() {
1851        // Sanitizer only walks transactions; other directive types have
1852        // no inner posting spans.
1853        let (sm, _fid) = source_map_with_one_file("x");
1854        let mut d = Directive::Open(rustledger_core::Open {
1855            date: rustledger_core::naive_date(2024, 1, 1).unwrap(),
1856            account: "Assets:Bank".into(),
1857            currencies: vec![],
1858            booking: None,
1859            meta: Default::default(),
1860        });
1861        sanitize_inner_posting_spans(&mut d, &sm); // no panic, no change
1862        assert!(matches!(d, Directive::Open(_)));
1863    }
1864}
1865
1866// The `is_python_module_name` classifier moved with dispatch into
1867// `rustledger-plugin`; its tests live there now. `module_ref_message` (the host's
1868// E8004 wording) stays here, so its tests do too.
1869#[cfg(all(test, feature = "plugins"))]
1870mod module_ref_message_tests {
1871    use super::module_ref_message;
1872
1873    #[test]
1874    fn message_uses_resolved_path_when_known() {
1875        let msg = module_ref_message("pkg.mod", Some("/abs/pkg/mod.py"));
1876        assert!(msg.contains("is not supported by module name"));
1877        assert!(msg.contains("plugin \"/abs/pkg/mod.py\""));
1878    }
1879
1880    #[test]
1881    fn message_falls_back_to_guidance_when_unresolved() {
1882        let msg = module_ref_message("pkg.mod", None);
1883        assert!(msg.contains("reference the file directly"));
1884        assert!(msg.contains("self-contained"));
1885    }
1886}