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}