1use 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#[derive(Debug, Clone)]
14pub struct LoadOptions {
15 pub booking_method: BookingMethod,
17 pub run_plugins: bool,
19 pub auto_accounts: bool,
21 pub extra_plugins: Vec<String>,
23 pub extra_plugin_configs: Vec<Option<String>>,
25 pub validate: bool,
27 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 #[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#[derive(Debug, Error)]
63pub enum ProcessError {
64 #[error("loading failed: {0}")]
66 Load(#[from] LoadError),
67
68 #[cfg(feature = "booking")]
70 #[error("booking error: {message}")]
71 Booking {
72 message: String,
74 date: rustledger_core::NaiveDate,
76 narration: String,
78 },
79
80 #[cfg(feature = "plugins")]
82 #[error("plugin error: {0}")]
83 Plugin(String),
84
85 #[cfg(feature = "validation")]
87 #[error("validation error: {0}")]
88 Validation(String),
89
90 #[cfg(feature = "plugins")]
92 #[error("failed to convert plugin output: {0}")]
93 PluginConversion(String),
94}
95
96#[derive(Debug)]
101pub struct Ledger {
102 pub directives: Vec<Spanned<Directive>>,
104 pub options: Options,
106 pub plugins: Vec<Plugin>,
108 pub source_map: SourceMap,
110 pub errors: Vec<LedgerError>,
112 pub display_context: DisplayContext,
114}
115
116#[derive(Debug)]
121pub struct LedgerError {
122 pub severity: ErrorSeverity,
124 pub code: String,
126 pub message: String,
128 pub location: Option<ErrorLocation>,
130 pub phase: String,
132}
133
134#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136pub enum ErrorSeverity {
137 Error,
139 Warning,
141}
142
143#[derive(Debug, Clone)]
145pub struct ErrorLocation {
146 pub file: std::path::PathBuf,
148 pub line: usize,
150 pub column: usize,
152}
153
154impl LedgerError {
155 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 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 #[must_use]
179 pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
180 self.phase = phase.into();
181 self
182 }
183
184 #[must_use]
186 pub fn with_location(mut self, location: ErrorLocation) -> Self {
187 self.location = Some(location);
188 self
189 }
190}
191
192pub 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 for load_err in raw.errors {
205 errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
206 }
207
208 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 #[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 #[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 #[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#[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#[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 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 let mut raw_plugins: Vec<(String, Option<String>, bool)> = Vec::new();
359
360 if options.auto_accounts {
362 raw_plugins.push(("auto_accounts".to_string(), None, false));
363 }
364
365 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 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 if raw_plugins.is_empty() && !has_document_dirs {
384 return Ok(());
385 }
386
387 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 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 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 if !raw_plugins.is_empty() {
434 let registry = NativePluginRegistry::new();
435
436 for (raw_name, plugin_config, force_python) in &raw_plugins {
437 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 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 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 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 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 #[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 #[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 #[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 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 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 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 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 (rustledger_parser::Span::new(0, 0), 0)
674 }
675 } else {
676 (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#[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
733pub 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
760pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
765 crate::Loader::new().load(path)
766}
767
768#[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#[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 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}