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