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, reintern_plain_directives, save_cache_entry,
43};
44pub use options::Options;
45pub use source_map::{SourceFile, SourceMap};
46pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
47
48#[cfg(feature = "plugins")]
50pub use process::run_plugins;
51#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
52pub use process::{
53 ErrorLocation, ErrorSeverity, Ledger, LedgerError, LoadOptions, ProcessError, load, load_raw,
54 process,
55};
56
57use rustledger_core::{Directive, DisplayContext};
58use rustledger_parser::{ParseError, Span, Spanned};
59use std::collections::HashSet;
60use std::path::{Path, PathBuf};
61use std::process::Command;
62use thiserror::Error;
63
64fn normalize_path(path: &Path) -> PathBuf {
72 if let Ok(canonical) = path.canonicalize() {
74 return canonical;
75 }
76
77 if path.is_absolute() {
79 path.to_path_buf()
80 } else if let Ok(cwd) = std::env::current_dir() {
81 let mut result = cwd;
83 for component in path.components() {
84 match component {
85 std::path::Component::ParentDir => {
86 result.pop();
87 }
88 std::path::Component::Normal(s) => {
89 result.push(s);
90 }
91 std::path::Component::CurDir => {}
92 std::path::Component::RootDir => {
93 result = PathBuf::from("/");
94 }
95 std::path::Component::Prefix(p) => {
96 result = PathBuf::from(p.as_os_str());
97 }
98 }
99 }
100 result
101 } else {
102 path.to_path_buf()
104 }
105}
106
107#[derive(Debug, Error)]
109pub enum LoadError {
110 #[error("failed to read file {path}: {source}")]
112 Io {
113 path: PathBuf,
115 #[source]
117 source: std::io::Error,
118 },
119
120 #[error(
129 "Duplicate filename parsed: \"{}\" (include cycle: {})",
130 .cycle.last().map_or("", String::as_str),
131 .cycle.join(" -> ")
132 )]
133 IncludeCycle {
134 cycle: Vec<String>,
139 },
140
141 #[error("parse errors in {path}")]
143 ParseErrors {
144 path: PathBuf,
146 errors: Vec<ParseError>,
148 },
149
150 #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
152 PathTraversal {
153 include_path: String,
155 base_dir: PathBuf,
157 },
158
159 #[error("failed to decrypt {path}: {message}")]
161 Decryption {
162 path: PathBuf,
164 message: String,
166 },
167
168 #[error("include pattern \"{pattern}\" does not match any files")]
170 GlobNoMatch {
171 pattern: String,
173 },
174
175 #[error("failed to expand include pattern \"{pattern}\": {message}")]
177 GlobError {
178 pattern: String,
180 message: String,
182 },
183}
184
185#[derive(Debug)]
187pub struct LoadResult {
188 pub directives: Vec<Spanned<Directive>>,
190 pub options: Options,
192 pub plugins: Vec<Plugin>,
194 pub source_map: SourceMap,
196 pub errors: Vec<LoadError>,
198 pub display_context: DisplayContext,
200}
201
202#[derive(Debug, Clone)]
204pub struct Plugin {
205 pub name: String,
207 pub config: Option<String>,
209 pub span: Span,
211 pub file_id: usize,
213 pub force_python: bool,
215}
216
217fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
222 let output = Command::new("gpg")
223 .args(["--batch", "--decrypt"])
224 .arg(path)
225 .output()
226 .map_err(|e| LoadError::Decryption {
227 path: path.to_path_buf(),
228 message: format!("failed to run gpg: {e}"),
229 })?;
230
231 if !output.status.success() {
232 return Err(LoadError::Decryption {
233 path: path.to_path_buf(),
234 message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
235 });
236 }
237
238 String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
239 path: path.to_path_buf(),
240 message: format!("decrypted content is not valid UTF-8: {e}"),
241 })
242}
243
244#[derive(Debug)]
246pub struct Loader {
247 loaded_files: HashSet<PathBuf>,
249 include_stack: Vec<PathBuf>,
251 include_stack_set: HashSet<PathBuf>,
253 root_dir: Option<PathBuf>,
256 enforce_path_security: bool,
258 fs: Box<dyn FileSystem>,
260}
261
262impl Default for Loader {
263 fn default() -> Self {
264 Self {
265 loaded_files: HashSet::new(),
266 include_stack: Vec::new(),
267 include_stack_set: HashSet::new(),
268 root_dir: None,
269 enforce_path_security: false,
270 fs: Box::new(DiskFileSystem),
271 }
272 }
273}
274
275impl Loader {
276 #[must_use]
278 pub fn new() -> Self {
279 Self::default()
280 }
281
282 #[must_use]
296 pub const fn with_path_security(mut self, enabled: bool) -> Self {
297 self.enforce_path_security = enabled;
298 self
299 }
300
301 #[must_use]
306 pub fn with_root_dir(mut self, root: PathBuf) -> Self {
307 self.root_dir = Some(root);
308 self.enforce_path_security = true;
309 self
310 }
311
312 #[must_use]
328 pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
329 self.fs = fs;
330 self
331 }
332
333 pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
351 let mut directives = Vec::new();
352 let mut options = Options::default();
353 let mut plugins = Vec::new();
354 let mut source_map = SourceMap::new();
355 let mut errors = Vec::new();
356
357 let canonical = self.fs.normalize(path);
359
360 if self.enforce_path_security && self.root_dir.is_none() {
362 self.root_dir = canonical.parent().map(Path::to_path_buf);
363 }
364
365 self.load_recursive(
368 &canonical,
369 None,
370 &mut directives,
371 &mut options,
372 &mut plugins,
373 &mut source_map,
374 &mut errors,
375 )?;
376
377 let display_context = build_display_context(&directives, &options);
379
380 Ok(LoadResult {
381 directives,
382 options,
383 plugins,
384 source_map,
385 errors,
386 display_context,
387 })
388 }
389
390 #[allow(clippy::too_many_arguments)]
391 fn load_recursive(
392 &mut self,
393 path: &Path,
394 pre_parsed: Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>,
395 directives: &mut Vec<Spanned<Directive>>,
396 options: &mut Options,
397 plugins: &mut Vec<Plugin>,
398 source_map: &mut SourceMap,
399 errors: &mut Vec<LoadError>,
400 ) -> Result<(), LoadError> {
401 let path_buf = path.to_path_buf();
403
404 if self.include_stack_set.contains(&path_buf) {
406 let cycle: Vec<String> = self
412 .include_stack
413 .iter()
414 .map(|p| p.display().to_string())
415 .chain(std::iter::once(path.display().to_string()))
416 .collect();
417 return Err(LoadError::IncludeCycle { cycle });
418 }
419
420 if self.loaded_files.contains(&path_buf) {
422 return Ok(());
423 }
424
425 let (source, result) = if let Some(pre) = pre_parsed {
428 pre
429 } else {
430 let src: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
431 decrypt_gpg_file(path)?.into()
432 } else {
433 self.fs.read(path)?
434 };
435 let parsed = rustledger_parser::parse(&src);
436 (src, parsed)
437 };
438
439 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
441
442 self.include_stack_set.insert(path_buf.clone());
444 self.include_stack.push(path_buf.clone());
445 self.loaded_files.insert(path_buf);
446
447 if !result.errors.is_empty() {
449 errors.push(LoadError::ParseErrors {
450 path: path.to_path_buf(),
451 errors: result.errors,
452 });
453 }
454
455 for (key, value, _span) in result.options {
457 options.set(&key, &value);
458 }
459
460 for (name, config, span) in result.plugins {
462 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
464 (stripped.to_string(), true)
465 } else {
466 (name, false)
467 };
468 plugins.push(Plugin {
469 name: actual_name,
470 config,
471 span,
472 file_id,
473 force_python,
474 });
475 }
476
477 let base_dir = path.parent().unwrap_or(Path::new("."));
479 for (include_path, _span) in &result.includes {
480 let has_glob = include_path.contains('*')
483 || include_path.contains('?')
484 || include_path.contains('[');
485
486 let full_path = base_dir.join(include_path);
487
488 if self.enforce_path_security
491 && let Some(ref root) = self.root_dir
492 {
493 let path_to_check = if has_glob {
495 let glob_start = include_path
497 .find(['*', '?', '['])
498 .unwrap_or(include_path.len());
499 let prefix = &include_path[..glob_start];
501 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
502 base_dir.join(&include_path[..=last_sep])
503 } else {
504 base_dir.to_path_buf()
505 };
506 normalize_path(&prefix_path)
507 } else {
508 normalize_path(&full_path)
509 };
510
511 if !path_to_check.starts_with(root) {
512 errors.push(LoadError::PathTraversal {
513 include_path: include_path.clone(),
514 base_dir: root.clone(),
515 });
516 continue;
517 }
518 }
519
520 let full_path_str = full_path.to_string_lossy();
521
522 let paths_to_load: Vec<PathBuf> = if has_glob {
524 match self.fs.glob(&full_path_str) {
525 Ok(matched) => matched,
526 Err(e) => {
527 errors.push(LoadError::GlobError {
528 pattern: include_path.clone(),
529 message: e,
530 });
531 continue;
532 }
533 }
534 } else {
535 vec![full_path.clone()]
536 };
537
538 if has_glob && paths_to_load.is_empty() {
540 errors.push(LoadError::GlobNoMatch {
541 pattern: include_path.clone(),
542 });
543 continue;
544 }
545
546 let mut valid_paths = Vec::with_capacity(paths_to_load.len());
548 for matched_path in paths_to_load {
549 let canonical = self.fs.normalize(&matched_path);
550
551 if self.enforce_path_security
553 && let Some(ref root) = self.root_dir
554 && !canonical.starts_with(root)
555 {
556 errors.push(LoadError::PathTraversal {
557 include_path: matched_path.to_string_lossy().into_owned(),
558 base_dir: root.clone(),
559 });
560 continue;
561 }
562
563 valid_paths.push(canonical);
564 }
565
566 if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
575 use rayon::prelude::*;
576
577 let fs = &*self.fs;
586 let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
587 valid_paths
588 .par_iter()
589 .map(|p| {
590 if fs.is_encrypted(p) {
592 return None;
593 }
594 let source = fs.read(p).ok()?;
597 let parsed = rustledger_parser::parse(&source);
598 Some((source, parsed))
599 })
600 .collect();
601
602 for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
607 if let Err(e) = self.load_recursive(
608 canonical, pre, directives, options, plugins, source_map, errors,
609 ) {
610 errors.push(e);
611 }
612 }
613 } else {
614 for canonical in valid_paths {
616 if let Err(e) = self.load_recursive(
617 &canonical, None, directives, options, plugins, source_map, errors,
618 ) {
619 errors.push(e);
620 }
621 }
622 }
623 }
624
625 directives.extend(
627 result
628 .directives
629 .into_iter()
630 .map(|d| d.with_file_id(file_id)),
631 );
632
633 if let Some(popped) = self.include_stack.pop() {
635 self.include_stack_set.remove(&popped);
636 }
637
638 Ok(())
639 }
640}
641
642fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
648 let mut ctx = DisplayContext::new();
649
650 ctx.set_render_commas(options.render_commas);
652
653 for spanned in directives {
655 match &spanned.value {
656 Directive::Transaction(txn) => {
657 for posting in &txn.postings {
658 if let Some(ref units) = posting.units
660 && let (Some(number), Some(currency)) = (units.number(), units.currency())
661 {
662 ctx.update(number, currency);
663 }
664 if let Some(ref cost) = posting.cost
666 && let (Some(number), Some(currency)) =
667 (cost.number_per.or(cost.number_total), &cost.currency)
668 {
669 ctx.update(number, currency.as_str());
670 }
671 }
675 }
676 Directive::Balance(bal) => {
677 ctx.update(bal.amount.number, bal.amount.currency.as_str());
678 if let Some(tol) = bal.tolerance {
679 ctx.update(tol, bal.amount.currency.as_str());
680 }
681 }
682 Directive::Price(_) => {
683 }
688 Directive::Pad(_)
689 | Directive::Open(_)
690 | Directive::Close(_)
691 | Directive::Commodity(_)
692 | Directive::Event(_)
693 | Directive::Query(_)
694 | Directive::Note(_)
695 | Directive::Document(_)
696 | Directive::Custom(_) => {}
697 }
698 }
699
700 for (currency, precision) in &options.display_precision {
702 ctx.set_fixed_precision(currency, *precision);
703 }
704
705 ctx
706}
707
708#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
714pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
715 Loader::new().load(path)
716}
717
718#[cfg(test)]
719mod tests {
720 use super::*;
721 use std::io::Write;
722 use tempfile::NamedTempFile;
723
724 #[test]
725 fn test_is_encrypted_file_gpg_extension() {
726 let fs = DiskFileSystem;
727 let path = Path::new("test.beancount.gpg");
728 assert!(fs.is_encrypted(path));
729 }
730
731 #[test]
732 fn test_is_encrypted_file_plain_beancount() {
733 let fs = DiskFileSystem;
734 let path = Path::new("test.beancount");
735 assert!(!fs.is_encrypted(path));
736 }
737
738 #[test]
739 fn test_is_encrypted_file_asc_with_pgp_header() {
740 let fs = DiskFileSystem;
741 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
742 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
743 writeln!(file, "some encrypted content").unwrap();
744 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
745 file.flush().unwrap();
746
747 assert!(fs.is_encrypted(file.path()));
748 }
749
750 #[test]
751 fn test_is_encrypted_file_asc_without_pgp_header() {
752 let fs = DiskFileSystem;
753 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
754 writeln!(file, "This is just a plain text file").unwrap();
755 writeln!(file, "with .asc extension but no PGP content").unwrap();
756 file.flush().unwrap();
757
758 assert!(!fs.is_encrypted(file.path()));
759 }
760
761 #[test]
762 fn test_decrypt_gpg_file_missing_gpg() {
763 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
765 writeln!(file, "fake encrypted content").unwrap();
766 file.flush().unwrap();
767
768 let result = decrypt_gpg_file(file.path());
771 assert!(result.is_err());
772
773 if let Err(LoadError::Decryption { path, message }) = result {
774 assert_eq!(path, file.path().to_path_buf());
775 assert!(!message.is_empty());
776 } else {
777 panic!("Expected Decryption error");
778 }
779 }
780
781 #[test]
782 fn test_plugin_force_python_prefix() {
783 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
784 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
785 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
786 file.flush().unwrap();
787
788 let result = Loader::new().load(file.path()).unwrap();
789
790 assert_eq!(result.plugins.len(), 2);
791
792 assert_eq!(result.plugins[0].name, "my_plugin");
794 assert!(result.plugins[0].force_python);
795
796 assert_eq!(result.plugins[1].name, "regular_plugin");
798 assert!(!result.plugins[1].force_python);
799 }
800
801 #[test]
802 fn test_plugin_force_python_with_config() {
803 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
804 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
805 file.flush().unwrap();
806
807 let result = Loader::new().load(file.path()).unwrap();
808
809 assert_eq!(result.plugins.len(), 1);
810 assert_eq!(result.plugins[0].name, "my_plugin");
811 assert!(result.plugins[0].force_python);
812 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
813 }
814
815 #[test]
816 fn test_virtual_filesystem_include_resolution() {
817 let mut vfs = VirtualFileSystem::new();
819 vfs.add_file(
820 "main.beancount",
821 r#"
822include "accounts.beancount"
823
8242024-01-15 * "Coffee"
825 Expenses:Food 5.00 USD
826 Assets:Bank -5.00 USD
827"#,
828 );
829 vfs.add_file(
830 "accounts.beancount",
831 r"
8322024-01-01 open Assets:Bank USD
8332024-01-01 open Expenses:Food USD
834",
835 );
836
837 let result = Loader::new()
839 .with_filesystem(Box::new(vfs))
840 .load(Path::new("main.beancount"))
841 .unwrap();
842
843 assert_eq!(result.directives.len(), 3);
845 assert!(result.errors.is_empty());
846
847 let directive_types: Vec<_> = result
849 .directives
850 .iter()
851 .map(|d| match &d.value {
852 rustledger_core::Directive::Open(_) => "open",
853 rustledger_core::Directive::Transaction(_) => "txn",
854 _ => "other",
855 })
856 .collect();
857 assert_eq!(directive_types, vec!["open", "open", "txn"]);
858 }
859
860 #[test]
861 fn test_virtual_filesystem_nested_includes() {
862 let mut vfs = VirtualFileSystem::new();
864 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
865 vfs.add_file(
866 "level1.beancount",
867 r#"
868include "level2.beancount"
8692024-01-01 open Assets:Level1 USD
870"#,
871 );
872 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
873
874 let result = Loader::new()
875 .with_filesystem(Box::new(vfs))
876 .load(Path::new("main.beancount"))
877 .unwrap();
878
879 assert_eq!(result.directives.len(), 2);
881 assert!(result.errors.is_empty());
882 }
883
884 #[test]
885 fn test_virtual_filesystem_missing_include() {
886 let mut vfs = VirtualFileSystem::new();
887 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
888
889 let result = Loader::new()
890 .with_filesystem(Box::new(vfs))
891 .load(Path::new("main.beancount"))
892 .unwrap();
893
894 assert!(!result.errors.is_empty());
896 let error_msg = result.errors[0].to_string();
897 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
898 }
899
900 #[test]
901 fn test_virtual_filesystem_glob_include() {
902 let mut vfs = VirtualFileSystem::new();
903 vfs.add_file(
904 "main.beancount",
905 r#"
906include "transactions/*.beancount"
907
9082024-01-01 open Assets:Bank USD
909"#,
910 );
911 vfs.add_file(
912 "transactions/2024.beancount",
913 r#"
9142024-01-01 open Expenses:Food USD
915
9162024-06-15 * "Groceries"
917 Expenses:Food 50.00 USD
918 Assets:Bank -50.00 USD
919"#,
920 );
921 vfs.add_file(
922 "transactions/2025.beancount",
923 r#"
9242025-01-01 open Expenses:Rent USD
925
9262025-02-01 * "Rent"
927 Expenses:Rent 1000.00 USD
928 Assets:Bank -1000.00 USD
929"#,
930 );
931 vfs.add_file(
933 "other/ignored.beancount",
934 "2024-01-01 open Expenses:Other USD",
935 );
936
937 let result = Loader::new()
938 .with_filesystem(Box::new(vfs))
939 .load(Path::new("main.beancount"))
940 .unwrap();
941
942 let opens = result
944 .directives
945 .iter()
946 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
947 .count();
948 assert_eq!(
949 opens, 3,
950 "expected 3 open directives (1 main + 2 transactions)"
951 );
952
953 let txns = result
954 .directives
955 .iter()
956 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
957 .count();
958 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
959
960 assert!(
961 result.errors.is_empty(),
962 "expected no errors, got: {:?}",
963 result.errors
964 );
965 }
966
967 #[test]
968 fn test_virtual_filesystem_glob_dot_slash_prefix() {
969 let mut vfs = VirtualFileSystem::new();
970 vfs.add_file(
971 "main.beancount",
972 r#"
973include "./transactions/*.beancount"
974
9752024-01-01 open Assets:Bank USD
976"#,
977 );
978 vfs.add_file(
979 "transactions/2024.beancount",
980 r#"
9812024-01-01 open Expenses:Food USD
982
9832024-06-15 * "Groceries"
984 Expenses:Food 50.00 USD
985 Assets:Bank -50.00 USD
986"#,
987 );
988 vfs.add_file(
989 "transactions/2025.beancount",
990 r#"
9912025-01-01 open Expenses:Rent USD
992
9932025-02-01 * "Rent"
994 Expenses:Rent 1000.00 USD
995 Assets:Bank -1000.00 USD
996"#,
997 );
998
999 let result = Loader::new()
1000 .with_filesystem(Box::new(vfs))
1001 .load(Path::new("main.beancount"))
1002 .unwrap();
1003
1004 let opens = result
1006 .directives
1007 .iter()
1008 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1009 .count();
1010 assert_eq!(
1011 opens, 3,
1012 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1013 );
1014
1015 let txns = result
1016 .directives
1017 .iter()
1018 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1019 .count();
1020 assert_eq!(
1021 txns, 2,
1022 "expected 2 transactions from glob-matched files despite ./ prefix"
1023 );
1024
1025 assert!(
1026 result.errors.is_empty(),
1027 "expected no errors, got: {:?}",
1028 result.errors
1029 );
1030 }
1031
1032 #[test]
1033 fn test_virtual_filesystem_glob_no_match() {
1034 let mut vfs = VirtualFileSystem::new();
1035 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1036
1037 let result = Loader::new()
1038 .with_filesystem(Box::new(vfs))
1039 .load(Path::new("main.beancount"))
1040 .unwrap();
1041
1042 let has_glob_error = result
1044 .errors
1045 .iter()
1046 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1047 assert!(
1048 has_glob_error,
1049 "expected GlobNoMatch error, got: {:?}",
1050 result.errors
1051 );
1052 }
1053}