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)]
121#[non_exhaustive]
122pub struct LedgerError {
123 pub severity: ErrorSeverity,
125 pub code: String,
127 pub message: String,
129 pub location: Option<ErrorLocation>,
131 pub source_span: Option<(usize, usize)>,
137 pub file_id: Option<u16>,
140 pub phase: String,
142}
143
144#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ErrorSeverity {
147 Error,
149 Warning,
151}
152
153#[derive(Debug, Clone)]
155pub struct ErrorLocation {
156 pub file: std::path::PathBuf,
158 pub line: usize,
160 pub column: usize,
162}
163
164impl LedgerError {
165 pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
167 Self {
168 severity: ErrorSeverity::Error,
169 code: code.into(),
170 message: message.into(),
171 location: None,
172 source_span: None,
173 file_id: None,
174 phase: "validate".to_string(),
175 }
176 }
177
178 pub fn warning(code: impl Into<String>, message: impl Into<String>) -> Self {
180 Self {
181 severity: ErrorSeverity::Warning,
182 code: code.into(),
183 message: message.into(),
184 location: None,
185 source_span: None,
186 file_id: None,
187 phase: "validate".to_string(),
188 }
189 }
190
191 #[must_use]
193 pub const fn with_source_span(mut self, span: (usize, usize), file_id: u16) -> Self {
194 self.source_span = Some(span);
195 self.file_id = Some(file_id);
196 self
197 }
198
199 #[must_use]
201 pub fn with_phase(mut self, phase: impl Into<String>) -> Self {
202 self.phase = phase.into();
203 self
204 }
205
206 #[must_use]
208 pub fn with_location(mut self, location: ErrorLocation) -> Self {
209 self.location = Some(location);
210 self
211 }
212}
213
214pub fn process(raw: LoadResult, options: &LoadOptions) -> Result<Ledger, ProcessError> {
222 let mut directives = raw.directives;
223 let mut errors: Vec<LedgerError> = Vec::new();
224
225 for load_err in raw.errors {
227 errors.push(LedgerError::error("LOAD", load_err.to_string()).with_phase("parse"));
228 }
229
230 directives.sort_by_cached_key(|d| {
235 (
236 d.value.date(),
237 d.value.priority(),
238 d.value.has_cost_reduction(),
239 )
240 });
241
242 #[cfg(feature = "booking")]
256 {
257 let file_set_booking = raw.options.set_options.contains("booking_method");
258 let effective_method = if file_set_booking {
259 raw.options
260 .booking_method
261 .parse()
262 .unwrap_or(options.booking_method)
263 } else {
264 options.booking_method
265 };
266 run_booking(&mut directives, effective_method, &mut errors);
267 }
268
269 #[cfg(feature = "plugins")]
273 if options.run_plugins || !options.extra_plugins.is_empty() || options.auto_accounts {
274 run_plugins(
275 &mut directives,
276 &raw.plugins,
277 &raw.options,
278 options,
279 &raw.source_map,
280 &mut errors,
281 )?;
282 }
283
284 #[cfg(feature = "validation")]
286 if options.validate {
287 run_validation(&directives, &raw.options, &raw.source_map, &mut errors);
288 }
289
290 Ok(Ledger {
291 directives,
292 options: raw.options,
293 plugins: raw.plugins,
294 source_map: raw.source_map,
295 errors,
296 display_context: raw.display_context,
297 })
298}
299
300#[cfg(feature = "booking")]
302fn run_booking(
303 directives: &mut Vec<Spanned<Directive>>,
304 booking_method: BookingMethod,
305 errors: &mut Vec<LedgerError>,
306) {
307 use rustledger_booking::BookingEngine;
308
309 let mut engine = BookingEngine::with_method(booking_method);
310 engine.register_account_methods(directives.iter().map(|s| &s.value));
311
312 for spanned in directives.iter_mut() {
313 if let Directive::Transaction(txn) = &mut spanned.value {
314 match engine.book_and_interpolate(txn) {
315 Ok(result) => {
316 engine.apply(&result.transaction);
317 *txn = result.transaction;
318 }
319 Err(e) => {
320 errors.push(LedgerError::error(
321 "BOOK",
322 format!("{} ({}, \"{}\")", e, txn.date, txn.narration),
323 ));
324 }
325 }
326 }
327 }
328}
329
330#[cfg(feature = "plugins")]
339pub fn run_plugins(
340 directives: &mut Vec<Spanned<Directive>>,
341 file_plugins: &[Plugin],
342 file_options: &Options,
343 options: &LoadOptions,
344 source_map: &SourceMap,
345 errors: &mut Vec<LedgerError>,
346) -> Result<(), ProcessError> {
347 use rustledger_plugin::{
348 DocumentDiscoveryPlugin, NativePlugin, NativePluginRegistry, PluginInput, PluginOptions,
349 directive_to_wrapper_with_location, wrapper_to_directive,
350 };
351
352 let base_dir = source_map
355 .files()
356 .first()
357 .and_then(|f| f.path.parent())
358 .unwrap_or_else(|| std::path::Path::new("."));
359
360 let has_document_dirs = options.run_plugins && !file_options.documents.is_empty();
361 let resolved_documents: Vec<String> = if has_document_dirs {
362 file_options
363 .documents
364 .iter()
365 .map(|d| {
366 let path = std::path::Path::new(d);
367 if path.is_absolute() {
368 d.clone()
369 } else {
370 base_dir.join(path).to_string_lossy().to_string()
371 }
372 })
373 .collect()
374 } else {
375 Vec::new()
376 };
377
378 let mut raw_plugins: Vec<(String, Option<String>, bool)> = Vec::new();
381
382 if options.auto_accounts {
384 raw_plugins.push(("auto_accounts".to_string(), None, false));
385 }
386
387 if options.run_plugins {
389 for plugin in file_plugins {
390 raw_plugins.push((
391 plugin.name.clone(),
392 plugin.config.clone(),
393 plugin.force_python,
394 ));
395 }
396 }
397
398 for (i, plugin_name) in options.extra_plugins.iter().enumerate() {
400 let config = options.extra_plugin_configs.get(i).cloned().flatten();
401 raw_plugins.push((plugin_name.clone(), config, false));
402 }
403
404 if raw_plugins.is_empty() && !has_document_dirs {
406 return Ok(());
407 }
408
409 let mut wrappers: Vec<_> = directives
411 .iter()
412 .map(|spanned| {
413 let (filename, lineno) = if let Some(file) = source_map.get(spanned.file_id as usize) {
414 let (line, _col) = file.line_col(spanned.span.start);
415 (Some(file.path.display().to_string()), Some(line as u32))
416 } else {
417 (None, None)
418 };
419 directive_to_wrapper_with_location(&spanned.value, filename, lineno)
420 })
421 .collect();
422
423 let plugin_options = PluginOptions {
424 operating_currencies: file_options.operating_currency.clone(),
425 title: file_options.title.clone(),
426 };
427
428 if has_document_dirs {
430 let doc_plugin = DocumentDiscoveryPlugin::new(resolved_documents, base_dir.to_path_buf());
431 let input = PluginInput {
432 directives: std::mem::take(&mut wrappers),
433 options: plugin_options.clone(),
434 config: None,
435 };
436 let output = doc_plugin.process(input);
437
438 for err in output.errors {
440 let ledger_err = match err.severity {
441 rustledger_plugin::PluginErrorSeverity::Error => {
442 LedgerError::error("PLUGIN", err.message)
443 }
444 rustledger_plugin::PluginErrorSeverity::Warning => {
445 LedgerError::warning("PLUGIN", err.message)
446 }
447 };
448 errors.push(ledger_err);
449 }
450
451 wrappers = output.directives;
452 }
453
454 if !raw_plugins.is_empty() {
456 let registry = NativePluginRegistry::new();
457
458 for (raw_name, plugin_config, force_python) in &raw_plugins {
459 let resolved_name = if *force_python {
462 None
463 } else if registry.find(raw_name).is_some() {
464 Some(raw_name.as_str())
465 } else if let Some(short_name) = raw_name.strip_prefix("beancount.plugins.") {
466 registry.find(short_name).is_some().then_some(short_name)
467 } else if let Some(short_name) = raw_name.strip_prefix("beancount_reds_plugins.") {
468 registry.find(short_name).is_some().then_some(short_name)
469 } else if let Some(short_name) = raw_name.strip_prefix("beancount_lazy_plugins.") {
470 registry.find(short_name).is_some().then_some(short_name)
471 } else {
472 None
473 };
474
475 if let Some(name) = resolved_name
476 && let Some(plugin) = registry.find(name)
477 {
478 let input = PluginInput {
482 directives: std::mem::take(&mut wrappers),
483 options: plugin_options.clone(),
484 config: plugin_config.clone(),
485 };
486
487 let output = plugin.process(input);
488
489 for err in output.errors {
491 let ledger_err = match err.severity {
492 rustledger_plugin::PluginErrorSeverity::Error => {
493 LedgerError::error("PLUGIN", err.message).with_phase("plugin")
494 }
495 rustledger_plugin::PluginErrorSeverity::Warning => {
496 LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
497 }
498 };
499 errors.push(ledger_err);
500 }
501
502 wrappers = output.directives;
503 } else {
504 let plugin_path = std::path::Path::new(raw_name);
506 let ext = plugin_path
507 .extension()
508 .and_then(|e| e.to_str())
509 .unwrap_or("")
510 .to_lowercase();
511
512 let resolve_path = |name: &str| -> Result<std::path::PathBuf, String> {
513 let p = std::path::Path::new(name);
514 let resolved = if p.is_absolute() {
515 p.to_path_buf()
516 } else {
517 base_dir.join(name)
518 };
519
520 if options.path_security
522 && let (Ok(canon_base), Ok(canon_plugin)) =
523 (base_dir.canonicalize(), resolved.canonicalize())
524 && !canon_plugin.starts_with(&canon_base)
525 {
526 return Err(format!(
527 "plugin path '{name}' is outside the ledger directory"
528 ));
529 }
530
531 Ok(resolved)
532 };
533
534 if ext == "wasm" {
535 #[cfg(feature = "wasm-plugins")]
537 {
538 let wasm_path = match resolve_path(raw_name) {
539 Ok(p) => p,
540 Err(e) => {
541 errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
542 continue;
543 }
544 };
545 match run_wasm_plugin(&wasm_path, &wrappers, &plugin_options, plugin_config)
546 {
547 Ok((output_directives, plugin_errors)) => {
548 for err in plugin_errors {
549 errors.push(err);
550 }
551 wrappers = output_directives;
552 }
553 Err(e) => {
554 errors.push(
555 LedgerError::error(
556 "PLUGIN",
557 format!("WASM plugin {} failed: {e}", wasm_path.display()),
558 )
559 .with_phase("plugin"),
560 );
561 }
562 }
563 }
564 #[cfg(not(feature = "wasm-plugins"))]
565 {
566 errors.push(
567 LedgerError::error(
568 "PLUGIN",
569 format!(
570 "WASM plugin '{}' requires the wasm-plugins feature",
571 raw_name
572 ),
573 )
574 .with_phase("plugin"),
575 );
576 }
577 } else if *force_python
578 || ext == "py"
579 || raw_name.contains(std::path::MAIN_SEPARATOR)
580 || raw_name.contains('.')
581 {
582 #[cfg(feature = "python-plugins")]
584 {
585 let resolved = match resolve_path(raw_name) {
586 Ok(p) => p,
587 Err(e) => {
588 errors.push(LedgerError::error("PLUGIN", e).with_phase("plugin"));
589 continue;
590 }
591 };
592 match run_python_plugin(
593 raw_name,
594 &resolved,
595 base_dir,
596 &wrappers,
597 &plugin_options,
598 plugin_config,
599 ) {
600 Ok((output_directives, plugin_errors)) => {
601 for err in plugin_errors {
602 errors.push(err);
603 }
604 wrappers = output_directives;
605 }
606 Err(e) => {
607 errors.push(LedgerError::error("E8002", e).with_phase("plugin"));
608 }
609 }
610 }
611 #[cfg(not(feature = "python-plugins"))]
612 {
613 errors.push(
614 LedgerError::error(
615 "E8005",
616 format!(
617 "Python plugin \"{}\" requires python-plugin-wasm feature",
618 raw_name
619 ),
620 )
621 .with_phase("plugin"),
622 );
623 }
624 } else {
625 #[cfg(feature = "python-plugins")]
627 {
628 use rustledger_plugin::python::{is_python_available, suggest_module_path};
629 let suggestion = if is_python_available() {
630 suggest_module_path(raw_name)
631 } else {
632 None
633 };
634 if let Some(module_path) = suggestion {
635 errors.push(
636 LedgerError::error(
637 "E8004",
638 format!(
639 "Cannot resolve Python module '{raw_name}'. Replace with: plugin \"{module_path}\""
640 ),
641 )
642 .with_phase("plugin"),
643 );
644 } else {
645 errors.push(
646 LedgerError::error(
647 "E8001",
648 format!("Plugin not found: \"{raw_name}\""),
649 )
650 .with_phase("plugin"),
651 );
652 }
653 }
654 #[cfg(not(feature = "python-plugins"))]
655 {
656 errors.push(
657 LedgerError::error(
658 "E8001",
659 format!("Plugin not found: \"{raw_name}\""),
660 )
661 .with_phase("plugin"),
662 );
663 }
664 }
665 }
666 }
667 }
668
669 let filename_to_file_id: std::collections::HashMap<String, u16> = source_map
671 .files()
672 .iter()
673 .map(|f| (f.path.display().to_string(), f.id as u16))
674 .collect();
675
676 let mut new_directives = Vec::with_capacity(wrappers.len());
678 for wrapper in &wrappers {
679 let directive = wrapper_to_directive(wrapper)
680 .map_err(|e| ProcessError::PluginConversion(e.to_string()))?;
681
682 let (span, file_id) =
686 if let (Some(filename), Some(lineno)) = (&wrapper.filename, wrapper.lineno) {
687 if let Some(&fid) = filename_to_file_id.get(filename) {
688 if let Some(file) = source_map.get(fid as usize) {
690 let span_start = file.line_start(lineno as usize).unwrap_or(0);
691 (rustledger_parser::Span::new(span_start, span_start), fid)
692 } else {
693 (
694 rustledger_parser::Span::new(0, 0),
695 rustledger_parser::SYNTHESIZED_FILE_ID,
696 )
697 }
698 } else {
699 (
701 rustledger_parser::Span::new(0, 0),
702 rustledger_parser::SYNTHESIZED_FILE_ID,
703 )
704 }
705 } else {
706 (
708 rustledger_parser::Span::new(0, 0),
709 rustledger_parser::SYNTHESIZED_FILE_ID,
710 )
711 };
712
713 new_directives.push(Spanned::new(directive, span).with_file_id(file_id as usize));
714 }
715
716 *directives = new_directives;
717 Ok(())
718}
719
720#[cfg(feature = "validation")]
722fn run_validation(
723 directives: &[Spanned<Directive>],
724 file_options: &Options,
725 source_map: &SourceMap,
726 errors: &mut Vec<LedgerError>,
727) {
728 use rustledger_validate::{ValidationOptions, validate_spanned_with_options};
729
730 let base_dir = source_map
732 .files()
733 .first()
734 .and_then(|f| f.path.parent())
735 .unwrap_or_else(|| std::path::Path::new("."));
736
737 let resolved_document_dirs: Vec<std::path::PathBuf> = file_options
738 .documents
739 .iter()
740 .map(|d| {
741 let path = std::path::Path::new(d);
742 if path.is_absolute() {
743 path.to_path_buf()
744 } else {
745 base_dir.join(path)
746 }
747 })
748 .collect();
749
750 let account_types: Vec<String> = file_options
751 .account_types()
752 .iter()
753 .map(|s| (*s).to_string())
754 .collect();
755
756 let validation_options = ValidationOptions::default()
757 .with_account_types(account_types)
758 .with_document_dirs(resolved_document_dirs)
759 .with_infer_tolerance_from_cost(file_options.infer_tolerance_from_cost)
760 .with_tolerance_multiplier(file_options.inferred_tolerance_multiplier)
761 .with_inferred_tolerance_default(file_options.inferred_tolerance_default.clone());
762
763 let validation_errors = validate_spanned_with_options(directives, validation_options);
764
765 for err in validation_errors {
766 let phase = if err.code.is_parse_phase() {
767 "parse"
768 } else {
769 "validate"
770 };
771 let severity_level = if err.code.is_warning() {
772 ErrorSeverity::Warning
773 } else {
774 ErrorSeverity::Error
775 };
776 let message = match &err.note {
780 Some(note) => format!("{err}\n note: {note}"),
781 None => err.to_string(),
782 };
783 let location = err.span.and_then(|span| {
787 let fid = err.file_id? as usize;
788 let file = source_map.get(fid)?;
789 let (line, column) = file.line_col(span.start);
790 Some(ErrorLocation {
791 file: file.path.clone(),
792 line,
793 column,
794 })
795 });
796 errors.push(LedgerError {
797 severity: severity_level,
798 code: err.code.code().to_string(),
799 message,
800 location,
801 source_span: err.span.map(|s| (s.start, s.end)),
802 file_id: err.file_id,
803 phase: phase.to_string(),
804 });
805 }
806}
807
808pub fn load(path: &Path, options: &LoadOptions) -> Result<Ledger, ProcessError> {
825 let mut loader = crate::Loader::new();
826
827 if options.path_security {
828 loader = loader.with_path_security(true);
829 }
830
831 let raw = loader.load(path)?;
832 process(raw, options)
833}
834
835pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
840 crate::Loader::new().load(path)
841}
842
843#[cfg(feature = "wasm-plugins")]
845fn run_wasm_plugin(
846 wasm_path: &std::path::Path,
847 directives: &[rustledger_plugin_types::DirectiveWrapper],
848 options: &rustledger_plugin::PluginOptions,
849 config: &Option<String>,
850) -> Result<
851 (
852 Vec<rustledger_plugin_types::DirectiveWrapper>,
853 Vec<LedgerError>,
854 ),
855 String,
856> {
857 use rustledger_plugin::{PluginInput, PluginManager};
858
859 let mut mgr = PluginManager::new();
860 let plugin_idx = mgr
861 .load(wasm_path)
862 .map_err(|e| format!("failed to load: {e}"))?;
863
864 let input = PluginInput {
865 directives: directives.to_vec(),
866 options: options.clone(),
867 config: config.clone(),
868 };
869
870 let output = mgr
871 .execute(plugin_idx, &input)
872 .map_err(|e| format!("execution failed: {e}"))?;
873
874 let mut errors = Vec::new();
875 for err in output.errors {
876 let ledger_err = match err.severity {
877 rustledger_plugin::PluginErrorSeverity::Error => {
878 LedgerError::error("PLUGIN", err.message).with_phase("plugin")
879 }
880 rustledger_plugin::PluginErrorSeverity::Warning => {
881 LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
882 }
883 };
884 errors.push(ledger_err);
885 }
886
887 Ok((output.directives, errors))
888}
889
890#[cfg(feature = "python-plugins")]
892fn run_python_plugin(
893 module_name: &str,
894 resolved_path: &std::path::Path,
895 base_dir: &std::path::Path,
896 directives: &[rustledger_plugin_types::DirectiveWrapper],
897 options: &rustledger_plugin::PluginOptions,
898 config: &Option<String>,
899) -> Result<
900 (
901 Vec<rustledger_plugin_types::DirectiveWrapper>,
902 Vec<LedgerError>,
903 ),
904 String,
905> {
906 use rustledger_plugin::{PluginInput, python::PythonRuntime};
907
908 let runtime = PythonRuntime::new().map_err(|e| format!("Python runtime unavailable: {e}"))?;
909
910 let input = PluginInput {
911 directives: directives.to_vec(),
912 options: options.clone(),
913 config: config.clone(),
914 };
915
916 let is_file = resolved_path.exists()
918 || std::path::Path::new(module_name)
919 .extension()
920 .is_some_and(|ext| ext.eq_ignore_ascii_case("py"))
921 || module_name.contains(std::path::MAIN_SEPARATOR);
922
923 let output = if is_file {
924 runtime
925 .execute_module(module_name, &input, Some(base_dir))
926 .map_err(|e| format!("Python plugin execution failed: {e}"))?
927 } else {
928 runtime
929 .execute_module(module_name, &input, Some(base_dir))
930 .map_err(|e| format!("Python plugin '{module_name}' execution failed: {e}"))?
931 };
932
933 let mut errors = Vec::new();
934 for err in output.errors {
935 let ledger_err = match err.severity {
936 rustledger_plugin::PluginErrorSeverity::Error => {
937 LedgerError::error("PLUGIN", err.message).with_phase("plugin")
938 }
939 rustledger_plugin::PluginErrorSeverity::Warning => {
940 LedgerError::warning("PLUGIN", err.message).with_phase("plugin")
941 }
942 };
943 errors.push(ledger_err);
944 }
945
946 Ok((output.directives, errors))
947}