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