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: chrono::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)]
121pub struct LedgerError {
122    /// Error severity.
123    pub severity: ErrorSeverity,
124    /// Error code (e.g., "E0001", "W8002").
125    pub code: String,
126    /// Human-readable error message.
127    pub message: String,
128    /// Source location, if available.
129    pub location: Option<ErrorLocation>,
130    /// Processing phase that produced this error: "parse", "validate", or "plugin".
131    pub phase: String,
132}
133
134/// Error severity level.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ErrorSeverity {
137    /// Error - indicates a problem that should be fixed.
138    Error,
139    /// Warning - indicates a potential issue.
140    Warning,
141}
142
143/// Source location for an error.
144#[derive(Debug, Clone)]
145pub struct ErrorLocation {
146    /// File path.
147    pub file: std::path::PathBuf,
148    /// Line number (1-indexed).
149    pub line: usize,
150    /// Column number (1-indexed).
151    pub column: usize,
152}
153
154impl LedgerError {
155    /// Create a new error with the given phase.
156    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
157        Self {
158            severity: ErrorSeverity::Error,
159            code: code.into(),
160            message: message.into(),
161            location: None,
162            phase: "validate".to_string(),
163        }
164    }
165
166    /// Create a new warning.
167    pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
168        Self {
169            severity: ErrorSeverity::Warning,
170            code: code.into(),
171            message: message.into(),
172            location: None,
173            phase: "validate".to_string(),
174        }
175    }
176
177    /// Set the processing phase for this error.
178    #[must_use]
179    pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
180        self.phase = phase.into();
181        self
182    }
183
184    /// Add a location to this error.
185    #[must_use]
186    pub fn with_location(mut self, location: ErrorLocation) -> Self {
187        self.location = Some(location);
188        self
189    }
190}
191
192/// Process a raw load result into a fully processed ledger.
193///
194/// This applies the processing pipeline:
195/// 1. Sort directives by date
196/// 2. Run booking/interpolation
197/// 3. Run plugins
198/// 4. Run validation (optional)
199pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
200    let mut directives = raw.directives;
201    let mut errors: Vec<LedgerError> = Vec::new();
202
203    // Convert load errors to ledger errors (parse phase)
204    for load_err in raw.errors {
205        errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
206    }
207
208    // 1. Sort by date (and priority for same-date directives)
209    directives.sort_by(|a, b| {
210        a.value
211            .date()
212            .cmp(&b.value.date())
213            .then_with(|| a.value.priority().cmp(&b.value.priority()))
214    });
215
216    // 2. Booking/interpolation
217    //
218    // The booking method comes from two sources: the API-level
219    // `LoadOptions.booking_method` and the file-level `option
220    // "booking_method"`. The file-level option takes precedence only
221    // when the file explicitly set it AND the caller hasn't overridden
222    // the API-level default. This matches Python beancount, where
223    // `option "booking_method" "FIFO"` sets the default for all accounts
224    // without an explicit method on their `open` directive.
225    //
226    // We check `set_options` (not `booking_method.is_empty()`) because
227    // `Options::new()` defaults `booking_method` to "STRICT", so the
228    // string is never empty.
229    #[cfg(feature = "booking")]
230    {
231        let file_set_booking = raw.options.set_options.contains("booking_method");
232        let effective_method = if file_set_booking {
233            raw.options
234                .booking_method
235                .parse()
236                .unwrap_or(options.booking_method)
237        } else {
238            options.booking_method
239        };
240        run_booking(&mut directives, effective_method, &mut errors);
241    }
242
243    // 3. Run plugins (including document discovery when run_plugins is enabled)
244    // Note: Document discovery only runs when run_plugins is true to respect raw mode semantics.
245    // LoadOptions::raw() sets run_plugins=false to prevent any directive mutations.
246    #[cfg(feature = "plugins")]
247    if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
248        run_plugins(
249            &mut directives,
250            &raw.plugins,
251            &raw.options,
252            options,
253            &raw.source_map,
254            &mut errors,
255        )?;
256    }
257
258    // 4. Validation
259    #[cfg(feature = "validation")]
260    if options.validate {
261        run_validation(&directives, &raw.options, &mut errors);
262    }
263
264    Ok(Ledger {
265        directives,
266        options: raw.options,
267        plugins: raw.plugins,
268        source_map: raw.source_map,
269        errors,
270        display_context: raw.display_context,
271    })
272}
273
274/// Run booking and interpolation on transactions.
275#[cfg(feature = "booking")]
276fn run_booking(
277    directives: &mut Vec<Spanned<Directive>>,
278    booking_method: BookingMethod,
279    errors: &mut Vec<LedgerError>,
280) {
281    use rustledger_booking::BookingEngine;
282
283    let mut engine = BookingEngine::with_method(booking_method);
284    engine.register_account_methods(directives.iter().map(|s| &s.value));
285
286    for spanned in directives.iter_mut() {
287        if let Directive::Transaction(txn) = &mut spanned.value {
288            match engine.book_and_interpolate(txn) {
289                Ok(result) => {
290                    engine.apply(&result.transaction);
291                    *txn = result.transaction;
292                }
293                Err(e) => {
294                    errors.push(LedgerError::error(
295                        "BOOK",
296                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
297                    ));
298                }
299            }
300        }
301    }
302}
303
304/// Run plugins on directives.
305///
306/// Executes native plugins (and document discovery) on the given directives,
307/// modifying them in-place. Plugin errors are appended to `errors`.
308///
309/// This is called by [`process()`] as part of the full pipeline, but can also
310/// be called standalone (e.g., by the LSP) when plugin execution is needed
311/// outside the normal load flow.
312#[cfg(feature = "plugins")]
313pub fn run_plugins(
314    directives: &mut Vec<Spanned<Directive>>,
315    file_plugins: &[Plugin],
316    file_options: &Options,
317    options: &LoadOptions,
318    source_map: &SourceMap,
319    errors: &mut Vec<LedgerError>,
320) -> Result<(), ProcessError> {
321    use rustledger_plugin::{
322        DocumentDiscoveryPlugin, NativePlugin, NativePluginRegistry, PluginInput, PluginOptions,
323        directive_to_wrapper_with_location, wrapper_to_directive,
324    };
325
326    // Resolve document directories relative to the main file's directory
327    // Document discovery only runs when run_plugins is true (respects raw mode)
328    let base_dir = source_map
329        .files()
330        .first()
331        .and_then(|f| f.path.parent())
332        .unwrap_or_else(|| std::path::Path::new("."));
333
334    let has_document_dirs = options.run_plugins && !file_options.documents.is_empty();
335    let resolved_documents: Vec<String> = if has_document_dirs {
336        file_options
337            .documents
338            .iter()
339            .map(|d| {
340                let path = std::path::Path::new(d);
341                if path.is_absolute() {
342                    d.clone()
343                } else {
344                    base_dir.join(path).to_string_lossy().to_string()
345                }
346            })
347            .collect()
348    } else {
349        Vec::new()
350    };
351
352    // Collect raw plugin names first (we'll resolve them with the registry later)
353    let mut raw_plugins: Vec<(String, Option<String>)> = Vec::new();
354
355    // Add auto_accounts first if requested
356    if options.auto_accounts {
357        raw_plugins.push(("auto_accounts".to_string(), None));
358    }
359
360    // Add plugins from the file
361    if options.run_plugins {
362        for plugin in file_plugins {
363            raw_plugins.push((plugin.name.clone(), plugin.config.clone()));
364        }
365    }
366
367    // Add extra plugins from options
368    for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
369        let config = options.extra_plugin_configs.get(i).cloned().flatten();
370        raw_plugins.push((plugin_name.clone(), config));
371    }
372
373    // Check if we have any work to do - early return before creating registry
374    if raw_plugins.is_empty() && !has_document_dirs {
375        return Ok(());
376    }
377
378    // Convert directives to plugin format with source locations
379    let mut wrappers: Vec<_> = directives
380        .iter()
381        .map(|spanned| {
382            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
383                let (line, _col) = file.line_col(spanned.span.start);
384                (Some(file.path.display().to_string()), Some(line as u32))
385            } else {
386                (None, None)
387            };
388            directive_to_wrapper_with_location(&spanned.value, filename, lineno)
389        })
390        .collect();
391
392    let plugin_options = PluginOptions {
393        operating_currencies: file_options.operating_currency.clone(),
394        title: file_options.title.clone(),
395    };
396
397    // Run document discovery plugin if documents directories are configured
398    if has_document_dirs {
399        let doc_plugin = DocumentDiscoveryPlugin::new(resolved_documents, base_dir.to_path_buf());
400        let input = PluginInput {
401            directives: wrappers.clone(),
402            options: plugin_options.clone(),
403            config: None,
404        };
405        let output = doc_plugin.process(input);
406
407        // Collect plugin errors
408        for err in output.errors {
409            let ledger_err = match err.severity {
410                rustledger_plugin::PluginErrorSeverity::Error => {
411                    LedgerError::error("PLUGIN", err.message)
412                }
413                rustledger_plugin::PluginErrorSeverity::Warning => {
414                    LedgerError::warning("PLUGIN", err.message)
415                }
416            };
417            errors.push(ledger_err);
418        }
419
420        wrappers = output.directives;
421    }
422
423    // Run each plugin (only create registry if we have plugins to run)
424    if !raw_plugins.is_empty() {
425        let registry = NativePluginRegistry::new();
426
427        for (raw_name, plugin_config) in &raw_plugins {
428            // Resolve the plugin name - try direct match first, then prefixed variants
429            let resolved_name = if registry.find(raw_name).is_some() {
430                Some(raw_name.as_str())
431            } else if let Some(short_name) = raw_name.strip_prefix("beancount.plugins.") {
432                registry.find(short_name).is_some().then_some(short_name)
433            } else if let Some(short_name) = raw_name.strip_prefix("beancount_reds_plugins.") {
434                registry.find(short_name).is_some().then_some(short_name)
435            } else if let Some(short_name) = raw_name.strip_prefix("beancount_lazy_plugins.") {
436                registry.find(short_name).is_some().then_some(short_name)
437            } else {
438                None
439            };
440
441            if let Some(name) = resolved_name
442                && let Some(plugin) = registry.find(name)
443            {
444                let input = PluginInput {
445                    directives: wrappers.clone(),
446                    options: plugin_options.clone(),
447                    config: plugin_config.clone(),
448                };
449
450                let output = plugin.process(input);
451
452                // Collect plugin errors
453                for err in output.errors {
454                    let ledger_err = match err.severity {
455                        rustledger_plugin::PluginErrorSeverity::Error => {
456                            LedgerError::error("PLUGIN", err.message).with_phase("plugin")
457                        }
458                        rustledger_plugin::PluginErrorSeverity::Warning => {
459                            LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
460                        }
461                    };
462                    errors.push(ledger_err);
463                }
464
465                wrappers = output.directives;
466            }
467        }
468    }
469
470    // Build a filename -> file_id lookup for restoring locations
471    let filename_to_file_id: std::collections::HashMap<String, u16> = source_map
472        .files()
473        .iter()
474        .map(|f| (f.path.display().to_string(), f.id as u16))
475        .collect();
476
477    // Convert back to directives, preserving source locations from wrappers
478    let mut new_directives = Vec::with_capacity(wrappers.len());
479    for wrapper in &wrappers {
480        let directive = wrapper_to_directive(wrapper)
481            .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
482
483        // Reconstruct span from filename/lineno if available
484        let (span, file_id) =
485            if let (Some(filename), Some(lineno)) = (&wrapper.filename, wrapper.lineno) {
486                if let Some(&fid) = filename_to_file_id.get(filename) {
487                    // Found the file - reconstruct approximate span from line number
488                    if let Some(file) = source_map.get(fid as usize) {
489                        let span_start = file.line_start(lineno as usize).unwrap_or(0);
490                        (rustledger_parser::Span::new(span_start, span_start), fid)
491                    } else {
492                        (rustledger_parser::Span::new(0, 0), 0)
493                    }
494                } else {
495                    // Unknown file (plugin-generated) - use zero span
496                    (rustledger_parser::Span::new(0, 0), 0)
497                }
498            } else {
499                // No location info - use zero span
500                (rustledger_parser::Span::new(0, 0), 0)
501            };
502
503        new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
504    }
505
506    *directives = new_directives;
507    Ok(())
508}
509
510/// Run validation on directives.
511#[cfg(feature = "validation")]
512fn run_validation(
513    directives: &[Spanned<Directive>],
514    file_options: &Options,
515    errors: &mut Vec<LedgerError>,
516) {
517    use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
518
519    let account_types: Vec<String> = file_options
520        .account_types()
521        .iter()
522        .map(|s| (*s).to_string())
523        .collect();
524
525    let validation_options = ValidationOptions {
526        account_types,
527        infer_tolerance_from_cost: file_options.infer_tolerance_from_cost,
528        tolerance_multiplier: file_options.inferred_tolerance_multiplier,
529        inferred_tolerance_default: file_options.inferred_tolerance_default.clone(),
530        ..Default::default()
531    };
532
533    let validation_errors = validate_spanned_with_options(directives, validation_options);
534
535    for err in validation_errors {
536        let phase = if err.code.is_parse_phase() {
537            "parse"
538        } else {
539            "validate"
540        };
541        let severity_level = if err.code.is_warning() {
542            ErrorSeverity::Warning
543        } else {
544            ErrorSeverity::Error
545        };
546        errors.push(LedgerError {
547            severity: severity_level,
548            code: err.code.code().to_string(),
549            message: err.to_string(),
550            location: None,
551            phase: phase.to_string(),
552        });
553    }
554}
555
556/// Load and fully process a beancount file.
557///
558/// This is the main entry point, equivalent to Python's `loader.load_file()`.
559/// It performs: parse → sort → book → plugins → validate.
560///
561/// # Example
562///
563/// ```ignore
564/// use rustledger_loader::{load, LoadOptions};
565/// use std::path::Path;
566///
567/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
568/// for error in &ledger.errors {
569///     eprintln!("{}: {}", error.code, error.message);
570/// }
571/// ```
572pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
573    let mut loader = crate::Loader::new();
574
575    if options.path_security {
576        loader = loader.with_path_security(true);
577    }
578
579    let raw = loader.load(path)?;
580    process(raw, options)
581}
582
583/// Load a beancount file without processing.
584///
585/// This returns raw directives without sorting, booking, or plugins.
586/// Use this when you need the original parse output.
587pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
588    crate::Loader::new().load(path)
589}