Skip to main content

rustledger_loader/
process.rs

1//! Processing pipeline: sort → book → plugins → validate.
2//!
3//! This module orchestrates the full processing pipeline for a beancount ledger,
4//! equivalent to Python's `loader.load_file()` function.
5
6use crate::{LoadError, LoadResult, Options, Plugin, SourceMap};
7use rustledger_core::{BookingMethod, Directive, DisplayContext};
8use rustledger_parser::Spanned;
9use std::path::Path;
10use thiserror::Error;
11
12/// Options for loading and processing a ledger.
13#[derive(Debug, Clone)]
14pub struct LoadOptions {
15    /// Booking method for lot matching (default: Strict).
16    pub booking_method: BookingMethod,
17    /// Run plugins declared in the file (default: true).
18    pub run_plugins: bool,
19    /// Run `auto_accounts` plugin (default: false).
20    pub auto_accounts: bool,
21    /// Additional native plugins to run (by name).
22    pub extra_plugins: Vec<String>,
23    /// Plugin configurations for extra plugins.
24    pub extra_plugin_configs: Vec<Option<String>>,
25    /// Run validation after processing (default: true).
26    pub validate: bool,
27    /// Enable path security (prevent include traversal).
28    pub path_security: bool,
29}
30
31impl Default for LoadOptions {
32    fn default() -> Self {
33        Self {
34            booking_method: BookingMethod::Strict,
35            run_plugins: true,
36            auto_accounts: false,
37            extra_plugins: Vec::new(),
38            extra_plugin_configs: Vec::new(),
39            validate: true,
40            path_security: false,
41        }
42    }
43}
44
45impl LoadOptions {
46    /// Create options for raw loading (no booking, no plugins, no validation).
47    #[must_use]
48    pub const fn raw() -> Self {
49        Self {
50            booking_method: BookingMethod::Strict,
51            run_plugins: false,
52            auto_accounts: false,
53            extra_plugins: Vec::new(),
54            extra_plugin_configs: Vec::new(),
55            validate: false,
56            path_security: false,
57        }
58    }
59}
60
61/// Errors that can occur during ledger processing.
62#[derive(Debug, Error)]
63pub enum ProcessError {
64    /// Loading failed.
65    #[error("loading failed: {0}")]
66    Load(#[from] LoadError),
67
68    /// Booking/interpolation error.
69    #[cfg(feature = "booking")]
70    #[error("booking error: {message}")]
71    Booking {
72        /// Error message.
73        message: String,
74        /// Date of the transaction.
75        date: rustledger_core::NaiveDate,
76        /// Narration of the transaction.
77        narration: String,
78    },
79
80    /// Plugin execution error.
81    #[cfg(feature = "plugins")]
82    #[error("plugin error: {0}")]
83    Plugin(String),
84
85    /// Validation error.
86    #[cfg(feature = "validation")]
87    #[error("validation error: {0}")]
88    Validation(String),
89
90    /// Plugin output conversion error.
91    #[cfg(feature = "plugins")]
92    #[error("failed to convert plugin output: {0}")]
93    PluginConversion(String),
94}
95
96/// A fully processed ledger.
97///
98/// This is the result of loading and processing a beancount file,
99/// equivalent to the tuple returned by Python's `loader.load_file()`.
100#[derive(Debug)]
101pub struct Ledger {
102    /// Processed directives (sorted, booked, plugins applied).
103    pub directives: Vec<Spanned<Directive>>,
104    /// Options parsed from the file.
105    pub options: Options,
106    /// Plugins declared in the file.
107    pub plugins: Vec<Plugin>,
108    /// Source map for error reporting.
109    pub source_map: SourceMap,
110    /// Errors encountered during processing.
111    pub errors: Vec<LedgerError>,
112    /// Display context for formatting numbers.
113    pub display_context: DisplayContext,
114}
115
116/// Unified error type for ledger processing.
117///
118/// This encompasses all error types that can occur during loading,
119/// booking, plugin execution, and validation.
120#[derive(Debug)]
121#[non_exhaustive]
122pub struct LedgerError {
123    /// Error severity.
124    pub severity: ErrorSeverity,
125    /// Error code (e.g., "E0001", "W8002").
126    pub code: String,
127    /// Human-readable error message.
128    pub message: String,
129    /// Source location, if available.
130    pub location: Option<ErrorLocation>,
131    /// Byte span (inclusive start, exclusive end) in the source file,
132    /// used by rich renderers (e.g. miette) to draw a snippet around
133    /// the offending directive. Consumers that only need `file:line:col`
134    /// should use `location`; those that want to show the surrounding
135    /// source text want this.
136    pub source_span: Option<(usize, usize)>,
137    /// Source file ID — index into the ledger's [`SourceMap`]. Used
138    /// alongside `source_span` for snippet rendering.
139    pub file_id: Option<u16>,
140    /// Processing phase that produced this error: "parse", "validate", or "plugin".
141    pub phase: String,
142}
143
144/// Error severity level.
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ErrorSeverity {
147    /// Error - indicates a problem that should be fixed.
148    Error,
149    /// Warning - indicates a potential issue.
150    Warning,
151}
152
153/// Source location for an error.
154#[derive(Debug, Clone)]
155pub struct ErrorLocation {
156    /// File path.
157    pub file: std::path::PathBuf,
158    /// Line number (1-indexed).
159    pub line: usize,
160    /// Column number (1-indexed).
161    pub column: usize,
162}
163
164impl LedgerError {
165    /// Create a new error with the given phase.
166    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
167        Self {
168            severity: ErrorSeverity::Error,
169            code: code.into(),
170            message: message.into(),
171            location: None,
172            source_span: None,
173            file_id: None,
174            phase: "validate".to_string(),
175        }
176    }
177
178    /// Create a new warning.
179    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
180        Self {
181            severity: ErrorSeverity::Warning,
182            code: code.into(),
183            message: message.into(),
184            location: None,
185            source_span: None,
186            file_id: None,
187            phase: "validate".to_string(),
188        }
189    }
190
191    /// Attach a source span and file ID so rich renderers can draw a snippet.
192    #[must_use]
193    pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
194        self.source_span = Some(span);
195        self.file_id = Some(file_id);
196        self
197    }
198
199    /// Set the processing phase for this error.
200    #[must_use]
201    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
202        self.phase = phase.into();
203        self
204    }
205
206    /// Add a location to this error.
207    #[must_use]
208    pub fn with_location(mut self, location: ErrorLocation) -> Self {
209        self.location = Some(location);
210        self
211    }
212}
213
214/// Process a raw load result into a fully processed ledger.
215///
216/// This applies the processing pipeline:
217/// 1. Sort directives by date
218/// 2. Run booking/interpolation
219/// 3. Run plugins
220/// 4. Run validation (optional)
221pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
222    let mut directives = raw.directives;
223    let mut errors: Vec<LedgerError> = Vec::new();
224
225    // Convert load errors to ledger errors (parse phase)
226    for load_err in raw.errors {
227        errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
228    }
229
230    // 1. Sort by date, type priority, then cost-basis reductions last.
231    // Transactions without cost reductions (no negative-units + cost-spec
232    // postings) process before those that reduce lots, ensuring lots exist
233    // when matched regardless of file ordering.
234    directives.sort_by_cached_key(|d| {
235        (
236            d.value.date(),
237            d.value.priority(),
238            d.value.has_cost_reduction(),
239        )
240    });
241
242    // 2. Booking/interpolation
243    //
244    // The booking method comes from two sources: the API-level
245    // `LoadOptions.booking_method` and the file-level `option
246    // "booking_method"`. The file-level option takes precedence only
247    // when the file explicitly set it AND the caller hasn't overridden
248    // the API-level default. This matches Python beancount, where
249    // `option "booking_method" "FIFO"` sets the default for all accounts
250    // without an explicit method on their `open` directive.
251    //
252    // We check `set_options` (not `booking_method.is_empty()`) because
253    // `Options::new()` defaults `booking_method` to "STRICT", so the
254    // string is never empty.
255    #[cfg(feature = "booking")]
256    {
257        let file_set_booking = raw.options.set_options.contains("booking_method");
258        let effective_method = if file_set_booking {
259            raw.options
260                .booking_method
261                .parse()
262                .unwrap_or(options.booking_method)
263        } else {
264            options.booking_method
265        };
266        run_booking(&mut directives, effective_method, &mut errors);
267    }
268
269    // 3. Run plugins (including document discovery when run_plugins is enabled)
270    // Note: Document discovery only runs when run_plugins is true to respect raw mode semantics.
271    // LoadOptions::raw() sets run_plugins=false to prevent any directive mutations.
272    #[cfg(feature = "plugins")]
273    if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
274        run_plugins(
275            &mut directives,
276            &raw.plugins,
277            &raw.options,
278            options,
279            &raw.source_map,
280            &mut errors,
281        )?;
282    }
283
284    // 4. Validation
285    #[cfg(feature = "validation")]
286    if options.validate {
287        run_validation(&directives, &raw.options, &raw.source_map, &mut errors);
288    }
289
290    Ok(Ledger {
291        directives,
292        options: raw.options,
293        plugins: raw.plugins,
294        source_map: raw.source_map,
295        errors,
296        display_context: raw.display_context,
297    })
298}
299
300/// Run booking and interpolation on transactions.
301#[cfg(feature = "booking")]
302fn run_booking(
303    directives: &mut Vec<Spanned<Directive>>,
304    booking_method: BookingMethod,
305    errors: &mut Vec<LedgerError>,
306) {
307    use rustledger_booking::BookingEngine;
308
309    let mut engine = BookingEngine::with_method(booking_method);
310    engine.register_account_methods(directives.iter().map(|s| &s.value));
311
312    for spanned in directives.iter_mut() {
313        if let Directive::Transaction(txn) = &mut spanned.value {
314            match engine.book_and_interpolate(txn) {
315                Ok(result) => {
316                    engine.apply(&result.transaction);
317                    *txn = result.transaction;
318                }
319                Err(e) => {
320                    errors.push(LedgerError::error(
321                        "BOOK",
322                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
323                    ));
324                }
325            }
326        }
327    }
328}
329
330/// Run plugins on directives.
331///
332/// Executes native plugins (and document discovery) on the given directives,
333/// modifying them in-place. Plugin errors are appended to `errors`.
334///
335/// This is called by [`process()`] as part of the full pipeline, but can also
336/// be called standalone (e.g., by the LSP) when plugin execution is needed
337/// outside the normal load flow.
338#[cfg(feature = "plugins")]
339pub fn run_plugins(
340    directives: &mut Vec<Spanned<Directive>>,
341    file_plugins: &[Plugin],
342    file_options: &Options,
343    options: &LoadOptions,
344    source_map: &SourceMap,
345    errors: &mut Vec<LedgerError>,
346) -> Result<(), ProcessError> {
347    use rustledger_plugin::{
348        DocumentDiscoveryPlugin, NativePlugin, NativePluginRegistry, PluginInput, PluginOptions,
349        directive_to_wrapper_with_location, wrapper_to_directive,
350    };
351
352    // Resolve document directories relative to the main file's directory
353    // Document discovery only runs when run_plugins is true (respects raw mode)
354    let base_dir = source_map
355        .files()
356        .first()
357        .and_then(|f| f.path.parent())
358        .unwrap_or_else(|| std::path::Path::new("."));
359
360    let has_document_dirs = options.run_plugins && !file_options.documents.is_empty();
361    let resolved_documents: Vec<String> = if has_document_dirs {
362        file_options
363            .documents
364            .iter()
365            .map(|d| {
366                let path = std::path::Path::new(d);
367                if path.is_absolute() {
368                    d.clone()
369                } else {
370                    base_dir.join(path).to_string_lossy().to_string()
371                }
372            })
373            .collect()
374    } else {
375        Vec::new()
376    };
377
378    // Collect raw plugin names first (we'll resolve them with the registry later)
379    // Tuple: (name, config, force_python)
380    let mut raw_plugins: Vec<(String, Option<String>, bool)> = Vec::new();
381
382    // Add auto_accounts first if requested
383    if options.auto_accounts {
384        raw_plugins.push(("auto_accounts".to_string(), None, false));
385    }
386
387    // Add plugins from the file
388    if options.run_plugins {
389        for plugin in file_plugins {
390            raw_plugins.push((
391                plugin.name.clone(),
392                plugin.config.clone(),
393                plugin.force_python,
394            ));
395        }
396    }
397
398    // Add extra plugins from options
399    for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
400        let config = options.extra_plugin_configs.get(i).cloned().flatten();
401        raw_plugins.push((plugin_name.clone(), config, false));
402    }
403
404    // Check if we have any work to do - early return before creating registry
405    if raw_plugins.is_empty() && !has_document_dirs {
406        return Ok(());
407    }
408
409    // Convert directives to plugin format with source locations
410    let mut wrappers: Vec<_> = directives
411        .iter()
412        .map(|spanned| {
413            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
414                let (line, _col) = file.line_col(spanned.span.start);
415                (Some(file.path.display().to_string()), Some(line as u32))
416            } else {
417                (None, None)
418            };
419            directive_to_wrapper_with_location(&spanned.value, filename, lineno)
420        })
421        .collect();
422
423    let plugin_options = PluginOptions {
424        operating_currencies: file_options.operating_currency.clone(),
425        title: file_options.title.clone(),
426    };
427
428    // Run document discovery plugin if documents directories are configured
429    if has_document_dirs {
430        let doc_plugin = DocumentDiscoveryPlugin::new(resolved_documents, base_dir.to_path_buf());
431        let input = PluginInput {
432            directives: std::mem::take(&mut wrappers),
433            options: plugin_options.clone(),
434            config: None,
435        };
436        let output = doc_plugin.process(input);
437
438        // Collect plugin errors
439        for err in output.errors {
440            let ledger_err = match err.severity {
441                rustledger_plugin::PluginErrorSeverity::Error => {
442                    LedgerError::error("PLUGIN", err.message)
443                }
444                rustledger_plugin::PluginErrorSeverity::Warning => {
445                    LedgerError::warning("PLUGIN", err.message)
446                }
447            };
448            errors.push(ledger_err);
449        }
450
451        wrappers = output.directives;
452    }
453
454    // Run each plugin (only create registry if we have plugins to run)
455    if !raw_plugins.is_empty() {
456        let registry = NativePluginRegistry::new();
457
458        for (raw_name, plugin_config, force_python) in &raw_plugins {
459            // Resolve the plugin name - try direct match first, then prefixed variants.
460            // Skip native resolution when force_python is set (plugin "python:..." prefix).
461            let resolved_name = if *force_python {
462                None
463            } else if registry.find(raw_name).is_some() {
464                Some(raw_name.as_str())
465            } else if let Some(short_name) = raw_name.strip_prefix("beancount.plugins.") {
466                registry.find(short_name).is_some().then_some(short_name)
467            } else if let Some(short_name) = raw_name.strip_prefix("beancount_reds_plugins.") {
468                registry.find(short_name).is_some().then_some(short_name)
469            } else if let Some(short_name) = raw_name.strip_prefix("beancount_lazy_plugins.") {
470                registry.find(short_name).is_some().then_some(short_name)
471            } else {
472                None
473            };
474
475            if let Some(name) = resolved_name
476                && let Some(plugin) = registry.find(name)
477            {
478                // Move wrappers into the plugin input instead of cloning.
479                // The plugin returns modified directives in its output,
480                // which we reassign to `wrappers` below.
481                let input = PluginInput {
482                    directives: std::mem::take(&mut wrappers),
483                    options: plugin_options.clone(),
484                    config: plugin_config.clone(),
485                };
486
487                let output = plugin.process(input);
488
489                // Collect plugin errors
490                for err in output.errors {
491                    let ledger_err = match err.severity {
492                        rustledger_plugin::PluginErrorSeverity::Error => {
493                            LedgerError::error("PLUGIN", err.message).with_phase("plugin")
494                        }
495                        rustledger_plugin::PluginErrorSeverity::Warning => {
496                            LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
497                        }
498                    };
499                    errors.push(ledger_err);
500                }
501
502                wrappers = output.directives;
503            } else {
504                // Not a native plugin — categorize and handle
505                let plugin_path = std::path::Path::new(raw_name);
506                let ext = plugin_path
507                    .extension()
508                    .and_then(|e| e.to_str())
509                    .unwrap_or("")
510                    .to_lowercase();
511
512                let resolve_path = |name: &str| -> Result<std::path::PathBuf, String> {
513                    let p = std::path::Path::new(name);
514                    let resolved = if p.is_absolute() {
515                        p.to_path_buf()
516                    } else {
517                        base_dir.join(name)
518                    };
519
520                    // Path security: prevent plugins from outside the ledger directory
521                    if options.path_security
522                        && let (Ok(canon_base), Ok(canon_plugin)) =
523                            (base_dir.canonicalize(), resolved.canonicalize())
524                        && !canon_plugin.starts_with(&canon_base)
525                    {
526                        return Err(format!(
527                            "plugin path '{name}' is outside the ledger directory"
528                        ));
529                    }
530
531                    Ok(resolved)
532                };
533
534                if ext == "wasm" {
535                    // WASM plugin
536                    #[cfg(feature = "wasm-plugins")]
537                    {
538                        let wasm_path = match resolve_path(raw_name) {
539                            Ok(p) => p,
540                            Err(e) => {
541                                errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
542                                continue;
543                            }
544                        };
545                        match run_wasm_plugin(&wasm_path, &wrappers, &plugin_options, plugin_config)
546                        {
547                            Ok((output_directives, plugin_errors)) => {
548                                for err in plugin_errors {
549                                    errors.push(err);
550                                }
551                                wrappers = output_directives;
552                            }
553                            Err(e) => {
554                                errors.push(
555                                    LedgerError::error(
556                                        "PLUGIN",
557                                        format!("WASM plugin {} failed: {e}", wasm_path.display()),
558                                    )
559                                    .with_phase("plugin"),
560                                );
561                            }
562                        }
563                    }
564                    #[cfg(not(feature = "wasm-plugins"))]
565                    {
566                        errors.push(
567                            LedgerError::error(
568                                "PLUGIN",
569                                format!(
570                                    "WASM plugin '{}' requires the wasm-plugins feature",
571                                    raw_name
572                                ),
573                            )
574                            .with_phase("plugin"),
575                        );
576                    }
577                } else if *force_python
578                    || ext == "py"
579                    || raw_name.contains(std::path::MAIN_SEPARATOR)
580                    || raw_name.contains('.')
581                {
582                    // Python module or file-based plugin (or force_python via "python:" prefix)
583                    #[cfg(feature = "python-plugins")]
584                    {
585                        let resolved = match resolve_path(raw_name) {
586                            Ok(p) => p,
587                            Err(e) => {
588                                errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
589                                continue;
590                            }
591                        };
592                        match run_python_plugin(
593                            raw_name,
594                            &resolved,
595                            base_dir,
596                            &wrappers,
597                            &plugin_options,
598                            plugin_config,
599                        ) {
600                            Ok((output_directives, plugin_errors)) => {
601                                for err in plugin_errors {
602                                    errors.push(err);
603                                }
604                                wrappers = output_directives;
605                            }
606                            Err(e) => {
607                                errors.push(LedgerError::error("E8002", e).with_phase("plugin"));
608                            }
609                        }
610                    }
611                    #[cfg(not(feature = "python-plugins"))]
612                    {
613                        errors.push(
614                            LedgerError::error(
615                                "E8005",
616                                format!(
617                                    "Python plugin \"{}\" requires python-plugin-wasm feature",
618                                    raw_name
619                                ),
620                            )
621                            .with_phase("plugin"),
622                        );
623                    }
624                } else {
625                    // Completely unknown plugin name — try to suggest a module path
626                    #[cfg(feature = "python-plugins")]
627                    {
628                        use rustledger_plugin::python::{is_python_available, suggest_module_path};
629                        let suggestion = if is_python_available() {
630                            suggest_module_path(raw_name)
631                        } else {
632                            None
633                        };
634                        if let Some(module_path) = suggestion {
635                            errors.push(
636                                LedgerError::error(
637                                    "E8004",
638                                    format!(
639                                        "Cannot resolve Python module '{raw_name}'. Replace with: plugin \"{module_path}\""
640                                    ),
641                                )
642                                .with_phase("plugin"),
643                            );
644                        } else {
645                            errors.push(
646                                LedgerError::error(
647                                    "E8001",
648                                    format!("Plugin not found: \"{raw_name}\""),
649                                )
650                                .with_phase("plugin"),
651                            );
652                        }
653                    }
654                    #[cfg(not(feature = "python-plugins"))]
655                    {
656                        errors.push(
657                            LedgerError::error(
658                                "E8001",
659                                format!("Plugin not found: \"{raw_name}\""),
660                            )
661                            .with_phase("plugin"),
662                        );
663                    }
664                }
665            }
666        }
667    }
668
669    // Build a filename -> file_id lookup for restoring locations
670    let filename_to_file_id: std::collections::HashMap<String, u16> = source_map
671        .files()
672        .iter()
673        .map(|f| (f.path.display().to_string(), f.id as u16))
674        .collect();
675
676    // Convert back to directives, preserving source locations from wrappers
677    let mut new_directives = Vec::with_capacity(wrappers.len());
678    for wrapper in &wrappers {
679        let directive = wrapper_to_directive(wrapper)
680            .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
681
682        // Reconstruct span from filename/lineno if available, falling back to
683        // the plugin-synthesized sentinel when no source location is recoverable.
684        // See `rustledger_parser::SYNTHESIZED_FILE_ID` and issue #896.
685        let (span, file_id) =
686            if let (Some(filename), Some(lineno)) = (&wrapper.filename, wrapper.lineno) {
687                if let Some(&fid) = filename_to_file_id.get(filename) {
688                    // Found the file - reconstruct approximate span from line number
689                    if let Some(file) = source_map.get(fid as usize) {
690                        let span_start = file.line_start(lineno as usize).unwrap_or(0);
691                        (rustledger_parser::Span::new(span_start, span_start), fid)
692                    } else {
693                        (
694                            rustledger_parser::Span::new(0, 0),
695                            rustledger_parser::SYNTHESIZED_FILE_ID,
696                        )
697                    }
698                } else {
699                    // Plugin-generated directive with an unknown/synthetic filename.
700                    (
701                        rustledger_parser::Span::new(0, 0),
702                        rustledger_parser::SYNTHESIZED_FILE_ID,
703                    )
704                }
705            } else {
706                // Plugin-generated directive with no source location at all.
707                (
708                    rustledger_parser::Span::new(0, 0),
709                    rustledger_parser::SYNTHESIZED_FILE_ID,
710                )
711            };
712
713        new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
714    }
715
716    *directives = new_directives;
717    Ok(())
718}
719
720/// Run validation on directives.
721#[cfg(feature = "validation")]
722fn run_validation(
723    directives: &[Spanned<Directive>],
724    file_options: &Options,
725    source_map: &SourceMap,
726    errors: &mut Vec<LedgerError>,
727) {
728    use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
729
730    // Resolve document directories relative to the main file's directory
731    let base_dir = source_map
732        .files()
733        .first()
734        .and_then(|f| f.path.parent())
735        .unwrap_or_else(|| std::path::Path::new("."));
736
737    let resolved_document_dirs: Vec<std::path::PathBuf> = file_options
738        .documents
739        .iter()
740        .map(|d| {
741            let path = std::path::Path::new(d);
742            if path.is_absolute() {
743                path.to_path_buf()
744            } else {
745                base_dir.join(path)
746            }
747        })
748        .collect();
749
750    let account_types: Vec<String> = file_options
751        .account_types()
752        .iter()
753        .map(|s| (*s).to_string())
754        .collect();
755
756    let validation_options = ValidationOptions::default()
757        .with_account_types(account_types)
758        .with_document_dirs(resolved_document_dirs)
759        .with_infer_tolerance_from_cost(file_options.infer_tolerance_from_cost)
760        .with_tolerance_multiplier(file_options.inferred_tolerance_multiplier)
761        .with_inferred_tolerance_default(file_options.inferred_tolerance_default.clone());
762
763    let validation_errors = validate_spanned_with_options(directives, validation_options);
764
765    for err in validation_errors {
766        let phase = if err.code.is_parse_phase() {
767            "parse"
768        } else {
769            "validate"
770        };
771        let severity_level = if err.code.is_warning() {
772            ErrorSeverity::Warning
773        } else {
774            ErrorSeverity::Error
775        };
776        // Fold the advisory note (if any) into the message so it propagates
777        // through every downstream format (LedgerError, JSON diagnostic, CLI
778        // report, LSP diagnostic) without each one needing a dedicated field.
779        let message = match &err.note {
780            Some(note) => format!("{err}\n  note: {note}"),
781            None => err.to_string(),
782        };
783        // Resolve span + file_id into a file/line/column triple so CLI and
784        // LSP consumers can render `file:line:col` headers without having
785        // to do the lookup themselves (issue #901).
786        let location = err.span.and_then(|span| {
787            let fid = err.file_id? as usize;
788            let file = source_map.get(fid)?;
789            let (line, column) = file.line_col(span.start);
790            Some(ErrorLocation {
791                file: file.path.clone(),
792                line,
793                column,
794            })
795        });
796        errors.push(LedgerError {
797            severity: severity_level,
798            code: err.code.code().to_string(),
799            message,
800            location,
801            source_span: err.span.map(|s| (s.start, s.end)),
802            file_id: err.file_id,
803            phase: phase.to_string(),
804        });
805    }
806}
807
808/// Load and fully process a beancount file.
809///
810/// This is the main entry point, equivalent to Python's `loader.load_file()`.
811/// It performs: parse → sort → book → plugins → validate.
812///
813/// # Example
814///
815/// ```ignore
816/// use rustledger_loader::{load, LoadOptions};
817/// use std::path::Path;
818///
819/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
820/// for error in &ledger.errors {
821///     eprintln!("{}: {}", error.code, error.message);
822/// }
823/// ```
824pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
825    let mut loader = crate::Loader::new();
826
827    if options.path_security {
828        loader = loader.with_path_security(true);
829    }
830
831    let raw = loader.load(path)?;
832    process(raw, options)
833}
834
835/// Load a beancount file without processing.
836///
837/// This returns raw directives without sorting, booking, or plugins.
838/// Use this when you need the original parse output.
839pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
840    crate::Loader::new().load(path)
841}
842
843/// Run a WASM plugin and return its output directives and errors.
844#[cfg(feature = "wasm-plugins")]
845fn run_wasm_plugin(
846    wasm_path: &std::path::Path,
847    directives: &[rustledger_plugin_types::DirectiveWrapper],
848    options: &rustledger_plugin::PluginOptions,
849    config: &Option<String>,
850) -> Result<
851    (
852        Vec<rustledger_plugin_types::DirectiveWrapper>,
853        Vec<LedgerError>,
854    ),
855    String,
856> {
857    use rustledger_plugin::{PluginInput, PluginManager};
858
859    let mut mgr = PluginManager::new();
860    let plugin_idx = mgr
861        .load(wasm_path)
862        .map_err(|e| format!("failed to load: {e}"))?;
863
864    let input = PluginInput {
865        directives: directives.to_vec(),
866        options: options.clone(),
867        config: config.clone(),
868    };
869
870    let output = mgr
871        .execute(plugin_idx, &input)
872        .map_err(|e| format!("execution failed: {e}"))?;
873
874    let mut errors = Vec::new();
875    for err in output.errors {
876        let ledger_err = match err.severity {
877            rustledger_plugin::PluginErrorSeverity::Error => {
878                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
879            }
880            rustledger_plugin::PluginErrorSeverity::Warning => {
881                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
882            }
883        };
884        errors.push(ledger_err);
885    }
886
887    Ok((output.directives, errors))
888}
889
890/// Run a Python module plugin via the WASI-based Python runtime.
891#[cfg(feature = "python-plugins")]
892fn run_python_plugin(
893    module_name: &str,
894    resolved_path: &std::path::Path,
895    base_dir: &std::path::Path,
896    directives: &[rustledger_plugin_types::DirectiveWrapper],
897    options: &rustledger_plugin::PluginOptions,
898    config: &Option<String>,
899) -> Result<
900    (
901        Vec<rustledger_plugin_types::DirectiveWrapper>,
902        Vec<LedgerError>,
903    ),
904    String,
905> {
906    use rustledger_plugin::{PluginInput, python::PythonRuntime};
907
908    let runtime = PythonRuntime::new().map_err(|e| format!("Python runtime unavailable: {e}"))?;
909
910    let input = PluginInput {
911        directives: directives.to_vec(),
912        options: options.clone(),
913        config: config.clone(),
914    };
915
916    // Try file-based execution first, then module-based
917    let is_file = resolved_path.exists()
918        || std::path::Path::new(module_name)
919            .extension()
920            .is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
921        || module_name.contains(std::path::MAIN_SEPARATOR);
922
923    let output = if is_file {
924        runtime
925            .execute_module(module_name, &input, Some(base_dir))
926            .map_err(|e| format!("Python plugin execution failed: {e}"))?
927    } else {
928        runtime
929            .execute_module(module_name, &input, Some(base_dir))
930            .map_err(|e| format!("Python plugin '{module_name}' execution failed: {e}"))?
931    };
932
933    let mut errors = Vec::new();
934    for err in output.errors {
935        let ledger_err = match err.severity {
936            rustledger_plugin::PluginErrorSeverity::Error => {
937                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
938            }
939            rustledger_plugin::PluginErrorSeverity::Warning => {
940                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
941            }
942        };
943        errors.push(ledger_err);
944    }
945
946    Ok((output.directives, errors))
947}