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)]
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, type priority, then cost-basis reductions last.
209    // Transactions without cost reductions (no negative-units + cost-spec
210    // postings) process before those that reduce lots, ensuring lots exist
211    // when matched regardless of file ordering.
212    directives.sort_by_cached_key(|d| {
213        (
214            d.value.date(),
215            d.value.priority(),
216            d.value.has_cost_reduction(),
217        )
218    });
219
220    // 2. Booking/interpolation
221    //
222    // The booking method comes from two sources: the API-level
223    // `LoadOptions.booking_method` and the file-level `option
224    // "booking_method"`. The file-level option takes precedence only
225    // when the file explicitly set it AND the caller hasn't overridden
226    // the API-level default. This matches Python beancount, where
227    // `option "booking_method" "FIFO"` sets the default for all accounts
228    // without an explicit method on their `open` directive.
229    //
230    // We check `set_options` (not `booking_method.is_empty()`) because
231    // `Options::new()` defaults `booking_method` to "STRICT", so the
232    // string is never empty.
233    #[cfg(feature = "booking")]
234    {
235        let file_set_booking = raw.options.set_options.contains("booking_method");
236        let effective_method = if file_set_booking {
237            raw.options
238                .booking_method
239                .parse()
240                .unwrap_or(options.booking_method)
241        } else {
242            options.booking_method
243        };
244        run_booking(&mut directives, effective_method, &mut errors);
245    }
246
247    // 3. Run plugins (including document discovery when run_plugins is enabled)
248    // Note: Document discovery only runs when run_plugins is true to respect raw mode semantics.
249    // LoadOptions::raw() sets run_plugins=false to prevent any directive mutations.
250    #[cfg(feature = "plugins")]
251    if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
252        run_plugins(
253            &mut directives,
254            &raw.plugins,
255            &raw.options,
256            options,
257            &raw.source_map,
258            &mut errors,
259        )?;
260    }
261
262    // 4. Validation
263    #[cfg(feature = "validation")]
264    if options.validate {
265        run_validation(&directives, &raw.options, &mut errors);
266    }
267
268    Ok(Ledger {
269        directives,
270        options: raw.options,
271        plugins: raw.plugins,
272        source_map: raw.source_map,
273        errors,
274        display_context: raw.display_context,
275    })
276}
277
278/// Run booking and interpolation on transactions.
279#[cfg(feature = "booking")]
280fn run_booking(
281    directives: &mut Vec<Spanned<Directive>>,
282    booking_method: BookingMethod,
283    errors: &mut Vec<LedgerError>,
284) {
285    use rustledger_booking::BookingEngine;
286
287    let mut engine = BookingEngine::with_method(booking_method);
288    engine.register_account_methods(directives.iter().map(|s| &s.value));
289
290    for spanned in directives.iter_mut() {
291        if let Directive::Transaction(txn) = &mut spanned.value {
292            match engine.book_and_interpolate(txn) {
293                Ok(result) => {
294                    engine.apply(&result.transaction);
295                    *txn = result.transaction;
296                }
297                Err(e) => {
298                    errors.push(LedgerError::error(
299                        "BOOK",
300                        format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
301                    ));
302                }
303            }
304        }
305    }
306}
307
308/// Run plugins on directives.
309///
310/// Executes native plugins (and document discovery) on the given directives,
311/// modifying them in-place. Plugin errors are appended to `errors`.
312///
313/// This is called by [`process()`] as part of the full pipeline, but can also
314/// be called standalone (e.g., by the LSP) when plugin execution is needed
315/// outside the normal load flow.
316#[cfg(feature = "plugins")]
317pub fn run_plugins(
318    directives: &mut Vec<Spanned<Directive>>,
319    file_plugins: &[Plugin],
320    file_options: &Options,
321    options: &LoadOptions,
322    source_map: &SourceMap,
323    errors: &mut Vec<LedgerError>,
324) -> Result<(), ProcessError> {
325    use rustledger_plugin::{
326        DocumentDiscoveryPlugin, NativePlugin, NativePluginRegistry, PluginInput, PluginOptions,
327        directive_to_wrapper_with_location, wrapper_to_directive,
328    };
329
330    // Resolve document directories relative to the main file's directory
331    // Document discovery only runs when run_plugins is true (respects raw mode)
332    let base_dir = source_map
333        .files()
334        .first()
335        .and_then(|f| f.path.parent())
336        .unwrap_or_else(|| std::path::Path::new("."));
337
338    let has_document_dirs = options.run_plugins && !file_options.documents.is_empty();
339    let resolved_documents: Vec<String> = if has_document_dirs {
340        file_options
341            .documents
342            .iter()
343            .map(|d| {
344                let path = std::path::Path::new(d);
345                if path.is_absolute() {
346                    d.clone()
347                } else {
348                    base_dir.join(path).to_string_lossy().to_string()
349                }
350            })
351            .collect()
352    } else {
353        Vec::new()
354    };
355
356    // Collect raw plugin names first (we'll resolve them with the registry later)
357    // Tuple: (name, config, force_python)
358    let mut raw_plugins: Vec<(String, Option<String>, bool)> = Vec::new();
359
360    // Add auto_accounts first if requested
361    if options.auto_accounts {
362        raw_plugins.push(("auto_accounts".to_string(), None, false));
363    }
364
365    // Add plugins from the file
366    if options.run_plugins {
367        for plugin in file_plugins {
368            raw_plugins.push((
369                plugin.name.clone(),
370                plugin.config.clone(),
371                plugin.force_python,
372            ));
373        }
374    }
375
376    // Add extra plugins from options
377    for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
378        let config = options.extra_plugin_configs.get(i).cloned().flatten();
379        raw_plugins.push((plugin_name.clone(), config, false));
380    }
381
382    // Check if we have any work to do - early return before creating registry
383    if raw_plugins.is_empty() && !has_document_dirs {
384        return Ok(());
385    }
386
387    // Convert directives to plugin format with source locations
388    let mut wrappers: Vec<_> = directives
389        .iter()
390        .map(|spanned| {
391            let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
392                let (line, _col) = file.line_col(spanned.span.start);
393                (Some(file.path.display().to_string()), Some(line as u32))
394            } else {
395                (None, None)
396            };
397            directive_to_wrapper_with_location(&spanned.value, filename, lineno)
398        })
399        .collect();
400
401    let plugin_options = PluginOptions {
402        operating_currencies: file_options.operating_currency.clone(),
403        title: file_options.title.clone(),
404    };
405
406    // Run document discovery plugin if documents directories are configured
407    if has_document_dirs {
408        let doc_plugin = DocumentDiscoveryPlugin::new(resolved_documents, base_dir.to_path_buf());
409        let input = PluginInput {
410            directives: std::mem::take(&mut wrappers),
411            options: plugin_options.clone(),
412            config: None,
413        };
414        let output = doc_plugin.process(input);
415
416        // Collect plugin errors
417        for err in output.errors {
418            let ledger_err = match err.severity {
419                rustledger_plugin::PluginErrorSeverity::Error => {
420                    LedgerError::error("PLUGIN", err.message)
421                }
422                rustledger_plugin::PluginErrorSeverity::Warning => {
423                    LedgerError::warning("PLUGIN", err.message)
424                }
425            };
426            errors.push(ledger_err);
427        }
428
429        wrappers = output.directives;
430    }
431
432    // Run each plugin (only create registry if we have plugins to run)
433    if !raw_plugins.is_empty() {
434        let registry = NativePluginRegistry::new();
435
436        for (raw_name, plugin_config, force_python) in &raw_plugins {
437            // Resolve the plugin name - try direct match first, then prefixed variants.
438            // Skip native resolution when force_python is set (plugin "python:..." prefix).
439            let resolved_name = if *force_python {
440                None
441            } else if registry.find(raw_name).is_some() {
442                Some(raw_name.as_str())
443            } else if let Some(short_name) = raw_name.strip_prefix("beancount.plugins.") {
444                registry.find(short_name).is_some().then_some(short_name)
445            } else if let Some(short_name) = raw_name.strip_prefix("beancount_reds_plugins.") {
446                registry.find(short_name).is_some().then_some(short_name)
447            } else if let Some(short_name) = raw_name.strip_prefix("beancount_lazy_plugins.") {
448                registry.find(short_name).is_some().then_some(short_name)
449            } else {
450                None
451            };
452
453            if let Some(name) = resolved_name
454                && let Some(plugin) = registry.find(name)
455            {
456                // Move wrappers into the plugin input instead of cloning.
457                // The plugin returns modified directives in its output,
458                // which we reassign to `wrappers` below.
459                let input = PluginInput {
460                    directives: std::mem::take(&mut wrappers),
461                    options: plugin_options.clone(),
462                    config: plugin_config.clone(),
463                };
464
465                let output = plugin.process(input);
466
467                // Collect plugin errors
468                for err in output.errors {
469                    let ledger_err = match err.severity {
470                        rustledger_plugin::PluginErrorSeverity::Error => {
471                            LedgerError::error("PLUGIN", err.message).with_phase("plugin")
472                        }
473                        rustledger_plugin::PluginErrorSeverity::Warning => {
474                            LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
475                        }
476                    };
477                    errors.push(ledger_err);
478                }
479
480                wrappers = output.directives;
481            } else {
482                // Not a native plugin — categorize and handle
483                let plugin_path = std::path::Path::new(raw_name);
484                let ext = plugin_path
485                    .extension()
486                    .and_then(|e| e.to_str())
487                    .unwrap_or("")
488                    .to_lowercase();
489
490                let resolve_path = |name: &str| -> Result<std::path::PathBuf, String> {
491                    let p = std::path::Path::new(name);
492                    let resolved = if p.is_absolute() {
493                        p.to_path_buf()
494                    } else {
495                        base_dir.join(name)
496                    };
497
498                    // Path security: prevent plugins from outside the ledger directory
499                    if options.path_security
500                        && let (Ok(canon_base), Ok(canon_plugin)) =
501                            (base_dir.canonicalize(), resolved.canonicalize())
502                        && !canon_plugin.starts_with(&canon_base)
503                    {
504                        return Err(format!(
505                            "plugin path '{name}' is outside the ledger directory"
506                        ));
507                    }
508
509                    Ok(resolved)
510                };
511
512                if ext == "wasm" {
513                    // WASM plugin
514                    #[cfg(feature = "wasm-plugins")]
515                    {
516                        let wasm_path = match resolve_path(raw_name) {
517                            Ok(p) => p,
518                            Err(e) => {
519                                errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
520                                continue;
521                            }
522                        };
523                        match run_wasm_plugin(&wasm_path, &wrappers, &plugin_options, plugin_config)
524                        {
525                            Ok((output_directives, plugin_errors)) => {
526                                for err in plugin_errors {
527                                    errors.push(err);
528                                }
529                                wrappers = output_directives;
530                            }
531                            Err(e) => {
532                                errors.push(
533                                    LedgerError::error(
534                                        "PLUGIN",
535                                        format!("WASM plugin {} failed: {e}", wasm_path.display()),
536                                    )
537                                    .with_phase("plugin"),
538                                );
539                            }
540                        }
541                    }
542                    #[cfg(not(feature = "wasm-plugins"))]
543                    {
544                        errors.push(
545                            LedgerError::error(
546                                "PLUGIN",
547                                format!(
548                                    "WASM plugin '{}' requires the wasm-plugins feature",
549                                    raw_name
550                                ),
551                            )
552                            .with_phase("plugin"),
553                        );
554                    }
555                } else if *force_python
556                    || ext == "py"
557                    || raw_name.contains(std::path::MAIN_SEPARATOR)
558                    || raw_name.contains('.')
559                {
560                    // Python module or file-based plugin (or force_python via "python:" prefix)
561                    #[cfg(feature = "python-plugins")]
562                    {
563                        let resolved = match resolve_path(raw_name) {
564                            Ok(p) => p,
565                            Err(e) => {
566                                errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
567                                continue;
568                            }
569                        };
570                        match run_python_plugin(
571                            raw_name,
572                            &resolved,
573                            base_dir,
574                            &wrappers,
575                            &plugin_options,
576                            plugin_config,
577                        ) {
578                            Ok((output_directives, plugin_errors)) => {
579                                for err in plugin_errors {
580                                    errors.push(err);
581                                }
582                                wrappers = output_directives;
583                            }
584                            Err(e) => {
585                                errors.push(LedgerError::error("E8002", e).with_phase("plugin"));
586                            }
587                        }
588                    }
589                    #[cfg(not(feature = "python-plugins"))]
590                    {
591                        errors.push(
592                            LedgerError::error(
593                                "E8005",
594                                format!(
595                                    "Python plugin \"{}\" requires python-plugin-wasm feature",
596                                    raw_name
597                                ),
598                            )
599                            .with_phase("plugin"),
600                        );
601                    }
602                } else {
603                    // Completely unknown plugin name — try to suggest a module path
604                    #[cfg(feature = "python-plugins")]
605                    {
606                        use rustledger_plugin::python::{is_python_available, suggest_module_path};
607                        let suggestion = if is_python_available() {
608                            suggest_module_path(raw_name)
609                        } else {
610                            None
611                        };
612                        if let Some(module_path) = suggestion {
613                            errors.push(
614                                LedgerError::error(
615                                    "E8004",
616                                    format!(
617                                        "Cannot resolve Python module '{raw_name}'. Replace with: plugin \"{module_path}\""
618                                    ),
619                                )
620                                .with_phase("plugin"),
621                            );
622                        } else {
623                            errors.push(
624                                LedgerError::error(
625                                    "E8001",
626                                    format!("Plugin not found: \"{raw_name}\""),
627                                )
628                                .with_phase("plugin"),
629                            );
630                        }
631                    }
632                    #[cfg(not(feature = "python-plugins"))]
633                    {
634                        errors.push(
635                            LedgerError::error(
636                                "E8001",
637                                format!("Plugin not found: \"{raw_name}\""),
638                            )
639                            .with_phase("plugin"),
640                        );
641                    }
642                }
643            }
644        }
645    }
646
647    // Build a filename -> file_id lookup for restoring locations
648    let filename_to_file_id: std::collections::HashMap<String, u16> = source_map
649        .files()
650        .iter()
651        .map(|f| (f.path.display().to_string(), f.id as u16))
652        .collect();
653
654    // Convert back to directives, preserving source locations from wrappers
655    let mut new_directives = Vec::with_capacity(wrappers.len());
656    for wrapper in &wrappers {
657        let directive = wrapper_to_directive(wrapper)
658            .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
659
660        // Reconstruct span from filename/lineno if available
661        let (span, file_id) =
662            if let (Some(filename), Some(lineno)) = (&wrapper.filename, wrapper.lineno) {
663                if let Some(&fid) = filename_to_file_id.get(filename) {
664                    // Found the file - reconstruct approximate span from line number
665                    if let Some(file) = source_map.get(fid as usize) {
666                        let span_start = file.line_start(lineno as usize).unwrap_or(0);
667                        (rustledger_parser::Span::new(span_start, span_start), fid)
668                    } else {
669                        (rustledger_parser::Span::new(0, 0), 0)
670                    }
671                } else {
672                    // Unknown file (plugin-generated) - use zero span
673                    (rustledger_parser::Span::new(0, 0), 0)
674                }
675            } else {
676                // No location info - use zero span
677                (rustledger_parser::Span::new(0, 0), 0)
678            };
679
680        new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
681    }
682
683    *directives = new_directives;
684    Ok(())
685}
686
687/// Run validation on directives.
688#[cfg(feature = "validation")]
689fn run_validation(
690    directives: &[Spanned<Directive>],
691    file_options: &Options,
692    errors: &mut Vec<LedgerError>,
693) {
694    use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
695
696    let account_types: Vec<String> = file_options
697        .account_types()
698        .iter()
699        .map(|s| (*s).to_string())
700        .collect();
701
702    let validation_options = ValidationOptions {
703        account_types,
704        infer_tolerance_from_cost: file_options.infer_tolerance_from_cost,
705        tolerance_multiplier: file_options.inferred_tolerance_multiplier,
706        inferred_tolerance_default: file_options.inferred_tolerance_default.clone(),
707        ..Default::default()
708    };
709
710    let validation_errors = validate_spanned_with_options(directives, validation_options);
711
712    for err in validation_errors {
713        let phase = if err.code.is_parse_phase() {
714            "parse"
715        } else {
716            "validate"
717        };
718        let severity_level = if err.code.is_warning() {
719            ErrorSeverity::Warning
720        } else {
721            ErrorSeverity::Error
722        };
723        errors.push(LedgerError {
724            severity: severity_level,
725            code: err.code.code().to_string(),
726            message: err.to_string(),
727            location: None,
728            phase: phase.to_string(),
729        });
730    }
731}
732
733/// Load and fully process a beancount file.
734///
735/// This is the main entry point, equivalent to Python's `loader.load_file()`.
736/// It performs: parse → sort → book → plugins → validate.
737///
738/// # Example
739///
740/// ```ignore
741/// use rustledger_loader::{load, LoadOptions};
742/// use std::path::Path;
743///
744/// let ledger = load(Path::new("ledger.beancount"), LoadOptions::default())?;
745/// for error in &ledger.errors {
746///     eprintln!("{}: {}", error.code, error.message);
747/// }
748/// ```
749pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
750    let mut loader = crate::Loader::new();
751
752    if options.path_security {
753        loader = loader.with_path_security(true);
754    }
755
756    let raw = loader.load(path)?;
757    process(raw, options)
758}
759
760/// Load a beancount file without processing.
761///
762/// This returns raw directives without sorting, booking, or plugins.
763/// Use this when you need the original parse output.
764pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
765    crate::Loader::new().load(path)
766}
767
768/// Run a WASM plugin and return its output directives and errors.
769#[cfg(feature = "wasm-plugins")]
770fn run_wasm_plugin(
771    wasm_path: &std::path::Path,
772    directives: &[rustledger_plugin_types::DirectiveWrapper],
773    options: &rustledger_plugin::PluginOptions,
774    config: &Option<String>,
775) -> Result<
776    (
777        Vec<rustledger_plugin_types::DirectiveWrapper>,
778        Vec<LedgerError>,
779    ),
780    String,
781> {
782    use rustledger_plugin::{PluginInput, PluginManager};
783
784    let mut mgr = PluginManager::new();
785    let plugin_idx = mgr
786        .load(wasm_path)
787        .map_err(|e| format!("failed to load: {e}"))?;
788
789    let input = PluginInput {
790        directives: directives.to_vec(),
791        options: options.clone(),
792        config: config.clone(),
793    };
794
795    let output = mgr
796        .execute(plugin_idx, &input)
797        .map_err(|e| format!("execution failed: {e}"))?;
798
799    let mut errors = Vec::new();
800    for err in output.errors {
801        let ledger_err = match err.severity {
802            rustledger_plugin::PluginErrorSeverity::Error => {
803                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
804            }
805            rustledger_plugin::PluginErrorSeverity::Warning => {
806                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
807            }
808        };
809        errors.push(ledger_err);
810    }
811
812    Ok((output.directives, errors))
813}
814
815/// Run a Python module plugin via the WASI-based Python runtime.
816#[cfg(feature = "python-plugins")]
817fn run_python_plugin(
818    module_name: &str,
819    resolved_path: &std::path::Path,
820    base_dir: &std::path::Path,
821    directives: &[rustledger_plugin_types::DirectiveWrapper],
822    options: &rustledger_plugin::PluginOptions,
823    config: &Option<String>,
824) -> Result<
825    (
826        Vec<rustledger_plugin_types::DirectiveWrapper>,
827        Vec<LedgerError>,
828    ),
829    String,
830> {
831    use rustledger_plugin::{PluginInput, python::PythonRuntime};
832
833    let runtime = PythonRuntime::new().map_err(|e| format!("Python runtime unavailable: {e}"))?;
834
835    let input = PluginInput {
836        directives: directives.to_vec(),
837        options: options.clone(),
838        config: config.clone(),
839    };
840
841    // Try file-based execution first, then module-based
842    let is_file = resolved_path.exists()
843        || std::path::Path::new(module_name)
844            .extension()
845            .is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
846        || module_name.contains(std::path::MAIN_SEPARATOR);
847
848    let output = if is_file {
849        runtime
850            .execute_module(module_name, &input, Some(base_dir))
851            .map_err(|e| format!("Python plugin execution failed: {e}"))?
852    } else {
853        runtime
854            .execute_module(module_name, &input, Some(base_dir))
855            .map_err(|e| format!("Python plugin '{module_name}' execution failed: {e}"))?
856    };
857
858    let mut errors = Vec::new();
859    for err in output.errors {
860        let ledger_err = match err.severity {
861            rustledger_plugin::PluginErrorSeverity::Error => {
862                LedgerError::error("PLUGIN", err.message).with_phase("plugin")
863            }
864            rustledger_plugin::PluginErrorSeverity::Warning => {
865                LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
866            }
867        };
868        errors.push(ledger_err);
869    }
870
871    Ok((output.directives, errors))
872}