1#![forbid(unsafe_code)]
29#![warn(missing_docs)]
30
31#[cfg(feature = "cache")]
32pub mod cache;
33mod options;
34#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
35mod process;
36mod source_map;
37mod vfs;
38
39#[cfg(feature = "cache")]
40pub use cache::{
41 CacheEntry, CachedOptions, CachedPlugin, invalidate_cache, load_cache_entry,
42 reintern_directives, save_cache_entry,
43};
44pub use options::Options;
45pub use source_map::{SourceFile, SourceMap};
46pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
47
48#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
50pub use process::{
51 ErrorLocation, ErrorSeverity, Ledger, LedgerError, LoadOptions, ProcessError, load, load_raw,
52 process,
53};
54
55use rustledger_core::{Directive, DisplayContext};
56use rustledger_parser::{ParseError, Span, Spanned};
57use std::collections::HashSet;
58use std::path::{Path, PathBuf};
59use std::process::Command;
60use thiserror::Error;
61
62fn normalize_path(path: &Path) -> PathBuf {
70 if let Ok(canonical) = path.canonicalize() {
72 return canonical;
73 }
74
75 if path.is_absolute() {
77 path.to_path_buf()
78 } else if let Ok(cwd) = std::env::current_dir() {
79 let mut result = cwd;
81 for component in path.components() {
82 match component {
83 std::path::Component::ParentDir => {
84 result.pop();
85 }
86 std::path::Component::Normal(s) => {
87 result.push(s);
88 }
89 std::path::Component::CurDir => {}
90 std::path::Component::RootDir => {
91 result = PathBuf::from("/");
92 }
93 std::path::Component::Prefix(p) => {
94 result = PathBuf::from(p.as_os_str());
95 }
96 }
97 }
98 result
99 } else {
100 path.to_path_buf()
102 }
103}
104
105#[derive(Debug, Error)]
107pub enum LoadError {
108 #[error("failed to read file {path}: {source}")]
110 Io {
111 path: PathBuf,
113 #[source]
115 source: std::io::Error,
116 },
117
118 #[error("include cycle detected: {}", .cycle.join(" -> "))]
120 IncludeCycle {
121 cycle: Vec<String>,
123 },
124
125 #[error("parse errors in {path}")]
127 ParseErrors {
128 path: PathBuf,
130 errors: Vec<ParseError>,
132 },
133
134 #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
136 PathTraversal {
137 include_path: String,
139 base_dir: PathBuf,
141 },
142
143 #[error("failed to decrypt {path}: {message}")]
145 Decryption {
146 path: PathBuf,
148 message: String,
150 },
151
152 #[error("include pattern \"{pattern}\" does not match any files")]
154 GlobNoMatch {
155 pattern: String,
157 },
158
159 #[error("failed to expand include pattern \"{pattern}\": {message}")]
161 GlobError {
162 pattern: String,
164 message: String,
166 },
167}
168
169#[derive(Debug)]
171pub struct LoadResult {
172 pub directives: Vec<Spanned<Directive>>,
174 pub options: Options,
176 pub plugins: Vec<Plugin>,
178 pub source_map: SourceMap,
180 pub errors: Vec<LoadError>,
182 pub display_context: DisplayContext,
184}
185
186#[derive(Debug, Clone)]
188pub struct Plugin {
189 pub name: String,
191 pub config: Option<String>,
193 pub span: Span,
195 pub file_id: usize,
197 pub force_python: bool,
199}
200
201fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
206 let output = Command::new("gpg")
207 .args(["--batch", "--decrypt"])
208 .arg(path)
209 .output()
210 .map_err(|e| LoadError::Decryption {
211 path: path.to_path_buf(),
212 message: format!("failed to run gpg: {e}"),
213 })?;
214
215 if !output.status.success() {
216 return Err(LoadError::Decryption {
217 path: path.to_path_buf(),
218 message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
219 });
220 }
221
222 String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
223 path: path.to_path_buf(),
224 message: format!("decrypted content is not valid UTF-8: {e}"),
225 })
226}
227
228#[derive(Debug)]
230pub struct Loader {
231 loaded_files: HashSet<PathBuf>,
233 include_stack: Vec<PathBuf>,
235 include_stack_set: HashSet<PathBuf>,
237 root_dir: Option<PathBuf>,
240 enforce_path_security: bool,
242 fs: Box<dyn FileSystem>,
244}
245
246impl Default for Loader {
247 fn default() -> Self {
248 Self {
249 loaded_files: HashSet::new(),
250 include_stack: Vec::new(),
251 include_stack_set: HashSet::new(),
252 root_dir: None,
253 enforce_path_security: false,
254 fs: Box::new(DiskFileSystem),
255 }
256 }
257}
258
259impl Loader {
260 #[must_use]
262 pub fn new() -> Self {
263 Self::default()
264 }
265
266 #[must_use]
280 pub const fn with_path_security(mut self, enabled: bool) -> Self {
281 self.enforce_path_security = enabled;
282 self
283 }
284
285 #[must_use]
290 pub fn with_root_dir(mut self, root: PathBuf) -> Self {
291 self.root_dir = Some(root);
292 self.enforce_path_security = true;
293 self
294 }
295
296 #[must_use]
312 pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
313 self.fs = fs;
314 self
315 }
316
317 pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
333 let mut directives = Vec::new();
334 let mut options = Options::default();
335 let mut plugins = Vec::new();
336 let mut source_map = SourceMap::new();
337 let mut errors = Vec::new();
338
339 let canonical = self.fs.normalize(path);
341
342 if self.enforce_path_security && self.root_dir.is_none() {
344 self.root_dir = canonical.parent().map(Path::to_path_buf);
345 }
346
347 self.load_recursive(
348 &canonical,
349 &mut directives,
350 &mut options,
351 &mut plugins,
352 &mut source_map,
353 &mut errors,
354 )?;
355
356 let display_context = build_display_context(&directives, &options);
358
359 Ok(LoadResult {
360 directives,
361 options,
362 plugins,
363 source_map,
364 errors,
365 display_context,
366 })
367 }
368
369 fn load_recursive(
370 &mut self,
371 path: &Path,
372 directives: &mut Vec<Spanned<Directive>>,
373 options: &mut Options,
374 plugins: &mut Vec<Plugin>,
375 source_map: &mut SourceMap,
376 errors: &mut Vec<LoadError>,
377 ) -> Result<(), LoadError> {
378 let path_buf = path.to_path_buf();
380
381 if self.include_stack_set.contains(&path_buf) {
383 let mut cycle: Vec<String> = self
384 .include_stack
385 .iter()
386 .map(|p| p.display().to_string())
387 .collect();
388 cycle.push(path.display().to_string());
389 return Err(LoadError::IncludeCycle { cycle });
390 }
391
392 if self.loaded_files.contains(&path_buf) {
394 return Ok(());
395 }
396
397 let source: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
400 decrypt_gpg_file(path)?.into()
401 } else {
402 self.fs.read(path)?
403 };
404
405 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
407
408 self.include_stack_set.insert(path_buf.clone());
410 self.include_stack.push(path_buf.clone());
411 self.loaded_files.insert(path_buf);
412
413 let result = rustledger_parser::parse(&source);
415
416 if !result.errors.is_empty() {
418 errors.push(LoadError::ParseErrors {
419 path: path.to_path_buf(),
420 errors: result.errors,
421 });
422 }
423
424 for (key, value, _span) in result.options {
426 options.set(&key, &value);
427 }
428
429 for (name, config, span) in result.plugins {
431 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
433 (stripped.to_string(), true)
434 } else {
435 (name, false)
436 };
437 plugins.push(Plugin {
438 name: actual_name,
439 config,
440 span,
441 file_id,
442 force_python,
443 });
444 }
445
446 let base_dir = path.parent().unwrap_or(Path::new("."));
448 for (include_path, _span) in &result.includes {
449 let has_glob = include_path.contains('*')
452 || include_path.contains('?')
453 || include_path.contains('[');
454
455 let full_path = base_dir.join(include_path);
456
457 if self.enforce_path_security
460 && let Some(ref root) = self.root_dir
461 {
462 let path_to_check = if has_glob {
464 let glob_start = include_path
466 .find(['*', '?', '['])
467 .unwrap_or(include_path.len());
468 let prefix = &include_path[..glob_start];
470 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
471 base_dir.join(&include_path[..=last_sep])
472 } else {
473 base_dir.to_path_buf()
474 };
475 normalize_path(&prefix_path)
476 } else {
477 normalize_path(&full_path)
478 };
479
480 if !path_to_check.starts_with(root) {
481 errors.push(LoadError::PathTraversal {
482 include_path: include_path.clone(),
483 base_dir: root.clone(),
484 });
485 continue;
486 }
487 }
488
489 let full_path_str = full_path.to_string_lossy();
490
491 let paths_to_load: Vec<PathBuf> = if has_glob {
493 match self.fs.glob(&full_path_str) {
494 Ok(matched) => matched,
495 Err(e) => {
496 errors.push(LoadError::GlobError {
497 pattern: include_path.clone(),
498 message: e,
499 });
500 continue;
501 }
502 }
503 } else {
504 vec![full_path.clone()]
505 };
506
507 if has_glob && paths_to_load.is_empty() {
509 errors.push(LoadError::GlobNoMatch {
510 pattern: include_path.clone(),
511 });
512 continue;
513 }
514
515 for matched_path in paths_to_load {
517 let canonical = self.fs.normalize(&matched_path);
519
520 if self.enforce_path_security
523 && let Some(ref root) = self.root_dir
524 && !canonical.starts_with(root)
525 {
526 errors.push(LoadError::PathTraversal {
527 include_path: matched_path.to_string_lossy().into_owned(),
528 base_dir: root.clone(),
529 });
530 continue;
531 }
532
533 if let Err(e) = self
534 .load_recursive(&canonical, directives, options, plugins, source_map, errors)
535 {
536 errors.push(e);
537 }
538 }
539 }
540
541 directives.extend(
543 result
544 .directives
545 .into_iter()
546 .map(|d| d.with_file_id(file_id)),
547 );
548
549 if let Some(popped) = self.include_stack.pop() {
551 self.include_stack_set.remove(&popped);
552 }
553
554 Ok(())
555 }
556}
557
558fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
564 let mut ctx = DisplayContext::new();
565
566 ctx.set_render_commas(options.render_commas);
568
569 for spanned in directives {
571 match &spanned.value {
572 Directive::Transaction(txn) => {
573 for posting in &txn.postings {
574 if let Some(ref units) = posting.units
576 && let (Some(number), Some(currency)) = (units.number(), units.currency())
577 {
578 ctx.update(number, currency);
579 }
580 if let Some(ref cost) = posting.cost
582 && let (Some(number), Some(currency)) =
583 (cost.number_per.or(cost.number_total), &cost.currency)
584 {
585 ctx.update(number, currency.as_str());
586 }
587 if let Some(ref price) = posting.price
589 && let Some(amount) = price.amount()
590 {
591 ctx.update(amount.number, amount.currency.as_str());
592 }
593 }
594 }
595 Directive::Balance(bal) => {
596 ctx.update(bal.amount.number, bal.amount.currency.as_str());
597 if let Some(tol) = bal.tolerance {
598 ctx.update(tol, bal.amount.currency.as_str());
599 }
600 }
601 Directive::Price(price) => {
602 ctx.update(price.amount.number, price.amount.currency.as_str());
603 }
604 Directive::Pad(_)
605 | Directive::Open(_)
606 | Directive::Close(_)
607 | Directive::Commodity(_)
608 | Directive::Event(_)
609 | Directive::Query(_)
610 | Directive::Note(_)
611 | Directive::Document(_)
612 | Directive::Custom(_) => {}
613 }
614 }
615
616 for (currency, precision) in &options.display_precision {
618 ctx.set_fixed_precision(currency, *precision);
619 }
620
621 ctx
622}
623
624#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
630pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
631 Loader::new().load(path)
632}
633
634#[cfg(test)]
635mod tests {
636 use super::*;
637 use std::io::Write;
638 use tempfile::NamedTempFile;
639
640 #[test]
641 fn test_is_encrypted_file_gpg_extension() {
642 let fs = DiskFileSystem;
643 let path = Path::new("test.beancount.gpg");
644 assert!(fs.is_encrypted(path));
645 }
646
647 #[test]
648 fn test_is_encrypted_file_plain_beancount() {
649 let fs = DiskFileSystem;
650 let path = Path::new("test.beancount");
651 assert!(!fs.is_encrypted(path));
652 }
653
654 #[test]
655 fn test_is_encrypted_file_asc_with_pgp_header() {
656 let fs = DiskFileSystem;
657 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
658 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
659 writeln!(file, "some encrypted content").unwrap();
660 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
661 file.flush().unwrap();
662
663 assert!(fs.is_encrypted(file.path()));
664 }
665
666 #[test]
667 fn test_is_encrypted_file_asc_without_pgp_header() {
668 let fs = DiskFileSystem;
669 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
670 writeln!(file, "This is just a plain text file").unwrap();
671 writeln!(file, "with .asc extension but no PGP content").unwrap();
672 file.flush().unwrap();
673
674 assert!(!fs.is_encrypted(file.path()));
675 }
676
677 #[test]
678 fn test_decrypt_gpg_file_missing_gpg() {
679 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
681 writeln!(file, "fake encrypted content").unwrap();
682 file.flush().unwrap();
683
684 let result = decrypt_gpg_file(file.path());
687 assert!(result.is_err());
688
689 if let Err(LoadError::Decryption { path, message }) = result {
690 assert_eq!(path, file.path().to_path_buf());
691 assert!(!message.is_empty());
692 } else {
693 panic!("Expected Decryption error");
694 }
695 }
696
697 #[test]
698 fn test_plugin_force_python_prefix() {
699 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
700 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
701 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
702 file.flush().unwrap();
703
704 let result = Loader::new().load(file.path()).unwrap();
705
706 assert_eq!(result.plugins.len(), 2);
707
708 assert_eq!(result.plugins[0].name, "my_plugin");
710 assert!(result.plugins[0].force_python);
711
712 assert_eq!(result.plugins[1].name, "regular_plugin");
714 assert!(!result.plugins[1].force_python);
715 }
716
717 #[test]
718 fn test_plugin_force_python_with_config() {
719 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
720 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
721 file.flush().unwrap();
722
723 let result = Loader::new().load(file.path()).unwrap();
724
725 assert_eq!(result.plugins.len(), 1);
726 assert_eq!(result.plugins[0].name, "my_plugin");
727 assert!(result.plugins[0].force_python);
728 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
729 }
730
731 #[test]
732 fn test_virtual_filesystem_include_resolution() {
733 let mut vfs = VirtualFileSystem::new();
735 vfs.add_file(
736 "main.beancount",
737 r#"
738include "accounts.beancount"
739
7402024-01-15 * "Coffee"
741 Expenses:Food 5.00 USD
742 Assets:Bank -5.00 USD
743"#,
744 );
745 vfs.add_file(
746 "accounts.beancount",
747 r"
7482024-01-01 open Assets:Bank USD
7492024-01-01 open Expenses:Food USD
750",
751 );
752
753 let result = Loader::new()
755 .with_filesystem(Box::new(vfs))
756 .load(Path::new("main.beancount"))
757 .unwrap();
758
759 assert_eq!(result.directives.len(), 3);
761 assert!(result.errors.is_empty());
762
763 let directive_types: Vec<_> = result
765 .directives
766 .iter()
767 .map(|d| match &d.value {
768 rustledger_core::Directive::Open(_) => "open",
769 rustledger_core::Directive::Transaction(_) => "txn",
770 _ => "other",
771 })
772 .collect();
773 assert_eq!(directive_types, vec!["open", "open", "txn"]);
774 }
775
776 #[test]
777 fn test_virtual_filesystem_nested_includes() {
778 let mut vfs = VirtualFileSystem::new();
780 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
781 vfs.add_file(
782 "level1.beancount",
783 r#"
784include "level2.beancount"
7852024-01-01 open Assets:Level1 USD
786"#,
787 );
788 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
789
790 let result = Loader::new()
791 .with_filesystem(Box::new(vfs))
792 .load(Path::new("main.beancount"))
793 .unwrap();
794
795 assert_eq!(result.directives.len(), 2);
797 assert!(result.errors.is_empty());
798 }
799
800 #[test]
801 fn test_virtual_filesystem_missing_include() {
802 let mut vfs = VirtualFileSystem::new();
803 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
804
805 let result = Loader::new()
806 .with_filesystem(Box::new(vfs))
807 .load(Path::new("main.beancount"))
808 .unwrap();
809
810 assert!(!result.errors.is_empty());
812 let error_msg = result.errors[0].to_string();
813 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
814 }
815
816 #[test]
817 fn test_virtual_filesystem_glob_include() {
818 let mut vfs = VirtualFileSystem::new();
819 vfs.add_file(
820 "main.beancount",
821 r#"
822include "transactions/*.beancount"
823
8242024-01-01 open Assets:Bank USD
825"#,
826 );
827 vfs.add_file(
828 "transactions/2024.beancount",
829 r#"
8302024-01-01 open Expenses:Food USD
831
8322024-06-15 * "Groceries"
833 Expenses:Food 50.00 USD
834 Assets:Bank -50.00 USD
835"#,
836 );
837 vfs.add_file(
838 "transactions/2025.beancount",
839 r#"
8402025-01-01 open Expenses:Rent USD
841
8422025-02-01 * "Rent"
843 Expenses:Rent 1000.00 USD
844 Assets:Bank -1000.00 USD
845"#,
846 );
847 vfs.add_file(
849 "other/ignored.beancount",
850 "2024-01-01 open Expenses:Other USD",
851 );
852
853 let result = Loader::new()
854 .with_filesystem(Box::new(vfs))
855 .load(Path::new("main.beancount"))
856 .unwrap();
857
858 let opens = result
860 .directives
861 .iter()
862 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
863 .count();
864 assert_eq!(
865 opens, 3,
866 "expected 3 open directives (1 main + 2 transactions)"
867 );
868
869 let txns = result
870 .directives
871 .iter()
872 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
873 .count();
874 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
875
876 assert!(
877 result.errors.is_empty(),
878 "expected no errors, got: {:?}",
879 result.errors
880 );
881 }
882
883 #[test]
884 fn test_virtual_filesystem_glob_dot_slash_prefix() {
885 let mut vfs = VirtualFileSystem::new();
886 vfs.add_file(
887 "main.beancount",
888 r#"
889include "./transactions/*.beancount"
890
8912024-01-01 open Assets:Bank USD
892"#,
893 );
894 vfs.add_file(
895 "transactions/2024.beancount",
896 r#"
8972024-01-01 open Expenses:Food USD
898
8992024-06-15 * "Groceries"
900 Expenses:Food 50.00 USD
901 Assets:Bank -50.00 USD
902"#,
903 );
904 vfs.add_file(
905 "transactions/2025.beancount",
906 r#"
9072025-01-01 open Expenses:Rent USD
908
9092025-02-01 * "Rent"
910 Expenses:Rent 1000.00 USD
911 Assets:Bank -1000.00 USD
912"#,
913 );
914
915 let result = Loader::new()
916 .with_filesystem(Box::new(vfs))
917 .load(Path::new("main.beancount"))
918 .unwrap();
919
920 let opens = result
922 .directives
923 .iter()
924 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
925 .count();
926 assert_eq!(
927 opens, 3,
928 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
929 );
930
931 let txns = result
932 .directives
933 .iter()
934 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
935 .count();
936 assert_eq!(
937 txns, 2,
938 "expected 2 transactions from glob-matched files despite ./ prefix"
939 );
940
941 assert!(
942 result.errors.is_empty(),
943 "expected no errors, got: {:?}",
944 result.errors
945 );
946 }
947
948 #[test]
949 fn test_virtual_filesystem_glob_no_match() {
950 let mut vfs = VirtualFileSystem::new();
951 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
952
953 let result = Loader::new()
954 .with_filesystem(Box::new(vfs))
955 .load(Path::new("main.beancount"))
956 .unwrap();
957
958 let has_glob_error = result
960 .errors
961 .iter()
962 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
963 assert!(
964 has_glob_error,
965 "expected GlobNoMatch error, got: {:?}",
966 result.errors
967 );
968 }
969}