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
212    #[cfg(feature = "plugins")]
213    if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
214        run_plugins(
215            &mut directives,
216            &raw.plugins,
217            &raw.options,
218            options,
219            &mut errors,
220        )?;
221    }
222
223    // 4. Validation
224    #[cfg(feature = "validation")]
225    if options.validate {
226        run_validation(&directives, &raw.options, &mut errors);
227    }
228
229    Ok(Ledger {
230        directives,
231        options: raw.options,
232        plugins: raw.plugins,
233        source_map: raw.source_map,
234        errors,
235        display_context: raw.display_context,
236    })
237}
238
239/// Run booking and interpolation on transactions.
240#[cfg(feature = "booking")]
241fn run_booking(
242    directives: &mut Vec<Spanned<Directive>>,
243    options: &LoadOptions,
244    errors: &mut Vec<LedgerError>,
245) {
246    use rustledger_booking::BookingEngine;
247
248    let mut engine = BookingEngine::with_method(options.booking_method);
249
250    for spanned in directives.iter_mut() {
251        if let Directive::Transaction(txn) = &mut spanned.value {
252            match engine.book_and_interpolate(txn) {
253                Ok(result) => {
254                    engine.apply(&result.transaction);
255                    *txn = result.transaction;
256                }
257                Err(e) => {
258                    errors.push(LedgerError::error(
259                        "BOOK",
260                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
261                    ));
262                }
263            }
264        }
265    }
266}
267
268/// Run plugins on directives.
269#[cfg(feature = "plugins")]
270fn run_plugins(
271    directives: &mut Vec<Spanned<Directive>>,
272    file_plugins: &[Plugin],
273    file_options: &Options,
274    options: &LoadOptions,
275    errors: &mut Vec<LedgerError>,
276) -> Result<(), ProcessError> {
277    use rustledger_plugin::{
278        NativePluginRegistry, PluginInput, PluginOptions, directives_to_wrappers,
279        wrappers_to_directives,
280    };
281
282    let registry = NativePluginRegistry::new();
283
284    // Build list of plugins to run
285    let mut plugins_to_run: Vec<(String, Option<String>)> = Vec::new();
286
287    // Add auto_accounts first if requested
288    if options.auto_accounts {
289        plugins_to_run.push(("auto_accounts".to_string(), None));
290    }
291
292    // Add plugins from the file
293    if options.run_plugins {
294        for plugin in file_plugins {
295            // Check if we have a native implementation
296            let plugin_name = if registry.find(&plugin.name).is_some() {
297                plugin.name.clone()
298            } else if let Some(short_name) = plugin.name.strip_prefix("beancount.plugins.") {
299                if registry.find(short_name).is_some() {
300                    short_name.to_string()
301                } else {
302                    // No native implementation - skip for now (TODO: Python execution)
303                    continue;
304                }
305            } else if let Some(short_name) = plugin.name.strip_prefix("beancount_reds_plugins.") {
306                if registry.find(short_name).is_some() {
307                    short_name.to_string()
308                } else {
309                    continue;
310                }
311            } else if let Some(short_name) = plugin.name.strip_prefix("beancount_lazy_plugins.") {
312                if registry.find(short_name).is_some() {
313                    short_name.to_string()
314                } else {
315                    continue;
316                }
317            } else {
318                continue;
319            };
320
321            plugins_to_run.push((plugin_name, plugin.config.clone()));
322        }
323    }
324
325    // Add extra plugins from options
326    for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
327        let config = options.extra_plugin_configs.get(i).cloned().flatten();
328        plugins_to_run.push((plugin_name.clone(), config));
329    }
330
331    if plugins_to_run.is_empty() {
332        return Ok(());
333    }
334
335    // Convert directives to plugin format (without spans for now)
336    let plain_directives: Vec<Directive> = directives.iter().map(|s| s.value.clone()).collect();
337    let mut wrappers = directives_to_wrappers(&plain_directives);
338
339    let plugin_options = PluginOptions {
340        operating_currencies: file_options.operating_currency.clone(),
341        title: file_options.title.clone(),
342    };
343
344    // Run each plugin
345    for (plugin_name, plugin_config) in &plugins_to_run {
346        if let Some(plugin) = registry.find(plugin_name) {
347            let input = PluginInput {
348                directives: wrappers.clone(),
349                options: plugin_options.clone(),
350                config: plugin_config.clone(),
351            };
352
353            let output = plugin.process(input);
354
355            // Collect plugin errors
356            for err in output.errors {
357                let ledger_err = match err.severity {
358                    rustledger_plugin::PluginErrorSeverity::Error => {
359                        LedgerError::error("PLUGIN", err.message)
360                    }
361                    rustledger_plugin::PluginErrorSeverity::Warning => {
362                        LedgerError::warning("PLUGIN", err.message)
363                    }
364                };
365                errors.push(ledger_err);
366            }
367
368            wrappers = output.directives;
369        }
370    }
371
372    // Convert back to directives
373    let processed = wrappers_to_directives(&wrappers)
374        .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
375
376    // Replace directives, preserving spans where possible
377    if processed.len() == directives.len() {
378        // Same count - update in place
379        for (i, new_directive) in processed.into_iter().enumerate() {
380            directives[i].value = new_directive;
381        }
382    } else {
383        // Count changed - plugins added/removed directives.
384        // Use synthetic zero spans for plugin-generated directives since we cannot
385        // reliably map them back to source locations. Error reporting for these
386        // directives will show the plugin name instead of a file location.
387        *directives = processed
388            .into_iter()
389            .map(|d| Spanned::new(d, rustledger_parser::Span::new(0, 0)))
390            .collect();
391    }
392
393    Ok(())
394}
395
396/// Run validation on directives.
397#[cfg(feature = "validation")]
398fn run_validation(
399    directives: &[Spanned<Directive>],
400    file_options: &Options,
401    errors: &mut Vec<LedgerError>,
402) {
403    use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
404
405    let account_types: Vec<String> = file_options
406        .account_types()
407        .iter()
408        .map(|s| (*s).to_string())
409        .collect();
410
411    let validation_options = ValidationOptions {
412        account_types,
413        infer_tolerance_from_cost: file_options.infer_tolerance_from_cost,
414        tolerance_multiplier: file_options.inferred_tolerance_multiplier,
415        inferred_tolerance_default: file_options.inferred_tolerance_default.clone(),
416        ..Default::default()
417    };
418
419    let validation_errors = validate_spanned_with_options(directives, validation_options);
420
421    for err in validation_errors {
422        errors.push(LedgerError::error(err.code.code(), err.to_string()));
423    }
424}
425
426/// Load and fully process a beancount file.
427///
428/// This is the main entry point, equivalent to Python's `loader.load_file()`.
429/// It performs: parse → sort → book → plugins → validate.
430///
431/// # Example
432///
433/// ```ignore
434/// use rustledger_loader::{load, LoadOptions};
435/// use std::path::Path;
436///
437/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
438/// for error in &ledger.errors {
439///     eprintln!("{}: {}", error.code, error.message);
440/// }
441/// ```
442pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
443    let mut loader = crate::Loader::new();
444
445    if options.path_security {
446        loader = loader.with_path_security(true);
447    }
448
449    let raw = loader.load(path)?;
450    process(raw, options)
451}
452
453/// Load a beancount file without processing.
454///
455/// This returns raw directives without sorting, booking, or plugins.
456/// Use this when you need the original parse output.
457pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
458    crate::Loader::new().load(path)
459}