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: chrono::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}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ErrorSeverity {
135 Error,
137 Warning,
139}
140
141#[derive(Debug, Clone)]
143pub struct ErrorLocation {
144 pub file: std::path::PathBuf,
146 pub line: usize,
148 pub column: usize,
150}
151
152impl LedgerError {
153 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 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 #[must_use]
175 pub fn with_location(mut self, location: ErrorLocation) -> Self {
176 self.location = Some(location);
177 self
178 }
179}
180
181pub 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 for load_err in raw.errors {
194 errors.push(LedgerError::error("LOAD", load_err.to_string()));
195 }
196
197 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 #[cfg(feature = "booking")]
207 {
208 run_booking(&mut directives, options, &mut errors);
209 }
210
211 #[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 #[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#[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#[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 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 let mut raw_plugins: Vec<(String, Option<String>)> = Vec::new();
314
315 if options.auto_accounts {
317 raw_plugins.push(("auto_accounts".to_string(), None));
318 }
319
320 if options.run_plugins {
322 for plugin in file_plugins {
323 raw_plugins.push((plugin.name.clone(), plugin.config.clone()));
324 }
325 }
326
327 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 if raw_plugins.is_empty() && !has_document_dirs {
335 return Ok(());
336 }
337
338 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 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 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 if !raw_plugins.is_empty() {
385 let registry = NativePluginRegistry::new();
386
387 for (raw_name, plugin_config) in &raw_plugins {
388 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 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 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 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 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 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 (rustledger_parser::Span::new(0, 0), 0)
457 }
458 } else {
459 (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#[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
500pub 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
527pub fn load_raw(path: &Path) -> Result<LoadResult, LoadError> {
532 crate::Loader::new().load(path)
533}