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