1#![forbid(unsafe_code)]
29#![warn(missing_docs)]
30#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
38
39#[cfg(feature = "cache")]
40pub mod cache;
41mod dedup;
42mod options;
43mod phase;
44#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
45mod process;
46mod source_map;
47mod vfs;
48
49pub use phase::{
50 Booked, Directives, EarlyValidated, Finalized, LateValidated, Phase, Raw,
51 RegularPluginsApplied, Sorted, Synthed,
52};
53#[cfg(feature = "cache")]
58pub use cache::{
59 CACHE_FILENAME_ENV, CacheEntry, CachedOptions, CachedPlugin, DISABLE_CACHE_ENV,
60 cache_disabled_by_env, cache_path, default_cache_path, invalidate_cache, load_cache_entry,
61 save_cache_entry,
62};
63pub use dedup::{reintern_directives, reintern_plain_directives};
64pub use options::Options;
65pub use source_map::{SourceFile, SourceMap};
66pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
67
68#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
70pub use process::{
71 ErrorLocation, ErrorSeverity, ExtraPlugin, Ledger, LedgerError, LoadOptions, ProcessError,
72 load, load_raw, process,
73};
74#[cfg(feature = "plugins")]
75pub use process::{PluginPass, run_plugins};
76
77use rustledger_core::{Directive, DisplayContext};
78use rustledger_parser::{ParseError, Span, Spanned};
79use std::collections::HashSet;
80use std::path::{Path, PathBuf};
81use std::process::Command;
82use thiserror::Error;
83
84#[derive(Debug, Error)]
92pub enum LoadError {
93 #[error("failed to read file {path}: {source}")]
95 Io {
96 path: PathBuf,
98 #[source]
100 source: std::io::Error,
101 },
102
103 #[error(
112 "Duplicate filename parsed: \"{}\" (include cycle: {})",
113 .cycle.last().map_or("", String::as_str),
114 .cycle.join(" -> ")
115 )]
116 IncludeCycle {
117 cycle: Vec<String>,
122 },
123
124 #[error("parse errors in {path}")]
126 ParseErrors {
127 path: PathBuf,
129 errors: Vec<ParseError>,
131 },
132
133 #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
135 PathTraversal {
136 include_path: String,
138 base_dir: PathBuf,
140 },
141
142 #[error("failed to decrypt {path}: {message}")]
144 Decryption {
145 path: PathBuf,
147 message: String,
149 },
150
151 #[error("include pattern \"{pattern}\" does not match any files")]
153 GlobNoMatch {
154 pattern: String,
156 },
157
158 #[error("failed to expand include pattern \"{pattern}\": {message}")]
160 GlobError {
161 pattern: String,
163 message: String,
165 },
166
167 #[error("too many files: a ledger may reference at most {limit} files (16-bit file ids)")]
173 TooManyFiles {
174 limit: usize,
176 },
177}
178
179const fn file_id_to_u16(file_id: usize) -> Result<u16, LoadError> {
189 if file_id >= rustledger_parser::SYNTHESIZED_FILE_ID as usize {
190 return Err(LoadError::TooManyFiles {
191 limit: rustledger_parser::SYNTHESIZED_FILE_ID as usize,
192 });
193 }
194 Ok(file_id as u16)
195}
196
197#[derive(Debug)]
199pub struct LoadResult {
200 pub directives: Vec<Spanned<Directive>>,
202 pub options: Options,
204 pub plugins: Vec<Plugin>,
206 pub source_map: SourceMap,
208 pub errors: Vec<LoadError>,
210 pub display_context: DisplayContext,
212}
213
214#[derive(Debug, Clone)]
216pub struct Plugin {
217 pub name: String,
219 pub config: Option<String>,
221 pub span: Span,
223 pub file_id: usize,
225 pub force_python: bool,
227}
228
229fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
234 let output = Command::new("gpg")
235 .args(["--batch", "--decrypt"])
236 .arg(path)
237 .output()
238 .map_err(|e| LoadError::Decryption {
239 path: path.to_path_buf(),
240 message: format!("failed to run gpg: {e}"),
241 })?;
242
243 if !output.status.success() {
244 return Err(LoadError::Decryption {
245 path: path.to_path_buf(),
246 message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
247 });
248 }
249
250 String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
251 path: path.to_path_buf(),
252 message: format!("decrypted content is not valid UTF-8: {e}"),
253 })
254}
255
256#[derive(Debug)]
258pub struct Loader {
259 loaded_files: HashSet<PathBuf>,
261 include_stack: Vec<PathBuf>,
263 include_stack_set: HashSet<PathBuf>,
265 root_dir: Option<PathBuf>,
268 enforce_path_security: bool,
270 fs: Box<dyn FileSystem>,
272}
273
274impl Default for Loader {
275 fn default() -> Self {
276 Self {
277 loaded_files: HashSet::new(),
278 include_stack: Vec::new(),
279 include_stack_set: HashSet::new(),
280 root_dir: None,
281 enforce_path_security: false,
282 fs: Box::new(DiskFileSystem),
283 }
284 }
285}
286
287impl Loader {
288 #[must_use]
290 pub fn new() -> Self {
291 Self::default()
292 }
293
294 #[must_use]
308 pub const fn with_path_security(mut self, enabled: bool) -> Self {
309 self.enforce_path_security = enabled;
310 self
311 }
312
313 #[must_use]
318 pub fn with_root_dir(mut self, root: PathBuf) -> Self {
319 self.root_dir = Some(root);
320 self.enforce_path_security = true;
321 self
322 }
323
324 #[must_use]
340 pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
341 self.fs = fs;
342 self
343 }
344
345 pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
363 let mut directives = Vec::new();
364 let mut options = Options::default();
365 let mut plugins = Vec::new();
366 let mut source_map = SourceMap::new();
367 let mut errors = Vec::new();
368
369 let canonical = self.fs.normalize(path);
371
372 if self.enforce_path_security && self.root_dir.is_none() {
374 self.root_dir = canonical.parent().map(Path::to_path_buf);
375 }
376 if let Some(root) = self.root_dir.take() {
384 self.root_dir = Some(self.fs.normalize(&root));
385 }
386
387 self.load_recursive(
390 &canonical,
391 None,
392 &mut directives,
393 &mut options,
394 &mut plugins,
395 &mut source_map,
396 &mut errors,
397 )?;
398
399 dedup::reintern_directives(&mut directives);
414
415 let display_context = build_display_context(&directives, &options);
417
418 Ok(LoadResult {
419 directives,
420 options,
421 plugins,
422 source_map,
423 errors,
424 display_context,
425 })
426 }
427
428 #[allow(clippy::too_many_arguments)]
429 fn load_recursive(
430 &mut self,
431 path: &Path,
432 pre_parsed: Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>,
433 directives: &mut Vec<Spanned<Directive>>,
434 options: &mut Options,
435 plugins: &mut Vec<Plugin>,
436 source_map: &mut SourceMap,
437 errors: &mut Vec<LoadError>,
438 ) -> Result<(), LoadError> {
439 let path_buf = path.to_path_buf();
441
442 if self.include_stack_set.contains(&path_buf) {
444 let cycle: Vec<String> = self
450 .include_stack
451 .iter()
452 .map(|p| p.display().to_string())
453 .chain(std::iter::once(path.display().to_string()))
454 .collect();
455 return Err(LoadError::IncludeCycle { cycle });
456 }
457
458 if self.loaded_files.contains(&path_buf) {
460 return Ok(());
461 }
462
463 let (source, result) = if let Some(pre) = pre_parsed {
466 pre
467 } else {
468 let src: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
469 decrypt_gpg_file(path)?.into()
470 } else {
471 self.fs.read(path)?
472 };
473 let parsed = rustledger_parser::parse_without_occurrences(&src);
476 (src, parsed)
477 };
478
479 let fid_u16 = file_id_to_u16(source_map.files().len())?;
485 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
487 debug_assert_eq!(file_id, fid_u16 as usize);
488
489 self.include_stack_set.insert(path_buf.clone());
491 self.include_stack.push(path_buf.clone());
492 self.loaded_files.insert(path_buf);
493
494 if !result.errors.is_empty() {
496 errors.push(LoadError::ParseErrors {
497 path: path.to_path_buf(),
498 errors: result.errors,
499 });
500 }
501
502 for (key, value, _span) in result.options {
504 options.set(&key, &value);
505 }
506
507 for (name, config, span) in result.plugins {
509 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
511 (stripped.to_string(), true)
512 } else {
513 (name, false)
514 };
515 plugins.push(Plugin {
516 name: actual_name,
517 config,
518 span,
519 file_id,
520 force_python,
521 });
522 }
523
524 let base_dir = path.parent().unwrap_or(Path::new("."));
526 for (include_path, _span) in &result.includes {
527 let has_glob = include_path.contains('*')
530 || include_path.contains('?')
531 || include_path.contains('[');
532
533 let full_path = base_dir.join(include_path);
534
535 if self.enforce_path_security
538 && let Some(ref root) = self.root_dir
539 {
540 let path_to_check = if has_glob {
542 let glob_start = include_path
544 .find(['*', '?', '['])
545 .unwrap_or(include_path.len());
546 let prefix = &include_path[..glob_start];
548 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
549 base_dir.join(&include_path[..=last_sep])
550 } else {
551 base_dir.to_path_buf()
552 };
553 self.fs.normalize(&prefix_path)
560 } else {
561 self.fs.normalize(&full_path)
562 };
563
564 if !path_to_check.starts_with(root) {
565 errors.push(LoadError::PathTraversal {
566 include_path: include_path.clone(),
567 base_dir: root.clone(),
568 });
569 continue;
570 }
571 }
572
573 let full_path_str = full_path.to_string_lossy();
574
575 let paths_to_load: Vec<PathBuf> = if has_glob {
577 match self.fs.glob(&full_path_str) {
578 Ok(matched) => matched,
579 Err(e) => {
580 errors.push(LoadError::GlobError {
581 pattern: include_path.clone(),
582 message: e,
583 });
584 continue;
585 }
586 }
587 } else {
588 vec![full_path.clone()]
589 };
590
591 if has_glob && paths_to_load.is_empty() {
593 errors.push(LoadError::GlobNoMatch {
594 pattern: include_path.clone(),
595 });
596 continue;
597 }
598
599 let mut valid_paths = Vec::with_capacity(paths_to_load.len());
601 for matched_path in paths_to_load {
602 let canonical = self.fs.normalize(&matched_path);
603
604 if self.enforce_path_security
606 && let Some(ref root) = self.root_dir
607 && !canonical.starts_with(root)
608 {
609 errors.push(LoadError::PathTraversal {
610 include_path: matched_path.to_string_lossy().into_owned(),
611 base_dir: root.clone(),
612 });
613 continue;
614 }
615
616 valid_paths.push(canonical);
617 }
618
619 if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
628 use rayon::prelude::*;
629
630 let fs = &*self.fs;
639 let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
640 valid_paths
641 .par_iter()
642 .map(|p| {
643 if fs.is_encrypted(p) {
645 return None;
646 }
647 let source = fs.read(p).ok()?;
650 let parsed = rustledger_parser::parse_without_occurrences(&source);
652 Some((source, parsed))
653 })
654 .collect();
655
656 for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
661 if let Err(e) = self.load_recursive(
662 canonical, pre, directives, options, plugins, source_map, errors,
663 ) {
664 errors.push(e);
665 }
666 }
667 } else {
668 for canonical in valid_paths {
670 if let Err(e) = self.load_recursive(
671 &canonical, None, directives, options, plugins, source_map, errors,
672 ) {
673 errors.push(e);
674 }
675 }
676 }
677 }
678
679 directives.extend(result.directives.into_iter().map(|d| {
689 let mut d = d.with_file_id(file_id);
690 if let rustledger_core::Directive::Transaction(ref mut txn) = d.value {
691 for p in &mut txn.postings {
692 p.file_id = fid_u16;
693 }
694 }
695 d
696 }));
697
698 if let Some(popped) = self.include_stack.pop() {
700 self.include_stack_set.remove(&popped);
701 }
702
703 Ok(())
704 }
705}
706
707fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
713 let mut ctx = DisplayContext::new();
714
715 ctx.set_render_commas(options.render_commas);
717
718 for spanned in directives {
720 match &spanned.value {
721 Directive::Transaction(txn) => {
722 for posting in &txn.postings {
723 if let Some(ref units) = posting.units
725 && let (Some(number), Some(currency)) = (units.number(), units.currency())
726 {
727 ctx.update(number, currency);
728 }
729 if let Some(ref cost) = posting.cost
737 && let (Some(number), Some(currency)) = (
738 cost.number
739 .map(|cn| cn.total().or_else(|| cn.per_unit()).unwrap_or_default()),
740 &cost.currency,
741 )
742 {
743 ctx.update(number, currency.as_str());
744 }
745 if let Some(ref price) = posting.price
755 && let Some(amount) = price.amount()
756 {
757 ctx.update(amount.number, amount.currency.as_str());
758 }
759 }
760 }
761 Directive::Balance(bal) => {
762 ctx.update(bal.amount.number, bal.amount.currency.as_str());
763 if let Some(tol) = bal.tolerance {
764 ctx.update(tol, bal.amount.currency.as_str());
765 }
766 }
767 Directive::Price(p) => {
768 ctx.update(p.amount.number, p.amount.currency.as_str());
773 }
774 Directive::Pad(_)
775 | Directive::Open(_)
776 | Directive::Close(_)
777 | Directive::Commodity(_)
778 | Directive::Event(_)
779 | Directive::Query(_)
780 | Directive::Note(_)
781 | Directive::Document(_)
782 | Directive::Custom(_) => {}
783 }
784 }
785
786 for (currency, precision) in &options.display_precision {
788 ctx.set_fixed_precision(currency, *precision);
789 }
790
791 for spanned in directives {
799 if let Directive::Commodity(comm) = &spanned.value
800 && let Some(value) = comm.meta.get("precision")
801 && let Ok(precision) = rustledger_core::parse_precision_meta(value)
802 {
803 ctx.set_fixed_precision(comm.currency.as_str(), precision);
804 }
805 }
806
807 ctx
808}
809
810#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
816pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
817 Loader::new().load(path)
818}
819
820#[cfg(test)]
821mod tests {
822 use super::*;
823 use std::io::Write;
824 use tempfile::NamedTempFile;
825
826 #[test]
827 fn file_id_to_u16_rejects_the_reserved_sentinel_not_panic() {
828 let sentinel = rustledger_parser::SYNTHESIZED_FILE_ID as usize;
829 assert_eq!(file_id_to_u16(0).unwrap(), 0);
831 assert_eq!(
832 file_id_to_u16(sentinel - 1).unwrap(),
833 rustledger_parser::SYNTHESIZED_FILE_ID - 1
834 );
835 assert!(matches!(
840 file_id_to_u16(sentinel),
841 Err(LoadError::TooManyFiles { limit }) if limit == sentinel
842 ));
843 assert!(matches!(
844 file_id_to_u16(sentinel + 1),
845 Err(LoadError::TooManyFiles { .. })
846 ));
847 }
848
849 #[test]
850 fn test_is_encrypted_file_gpg_extension() {
851 let fs = DiskFileSystem;
852 let path = Path::new("test.beancount.gpg");
853 assert!(fs.is_encrypted(path));
854 }
855
856 #[test]
857 fn test_is_encrypted_file_plain_beancount() {
858 let fs = DiskFileSystem;
859 let path = Path::new("test.beancount");
860 assert!(!fs.is_encrypted(path));
861 }
862
863 #[test]
864 fn test_is_encrypted_file_asc_with_pgp_header() {
865 let fs = DiskFileSystem;
866 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
867 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
868 writeln!(file, "some encrypted content").unwrap();
869 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
870 file.flush().unwrap();
871
872 assert!(fs.is_encrypted(file.path()));
873 }
874
875 #[test]
876 fn test_is_encrypted_file_asc_without_pgp_header() {
877 let fs = DiskFileSystem;
878 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
879 writeln!(file, "This is just a plain text file").unwrap();
880 writeln!(file, "with .asc extension but no PGP content").unwrap();
881 file.flush().unwrap();
882
883 assert!(!fs.is_encrypted(file.path()));
884 }
885
886 #[test]
887 fn test_decrypt_gpg_file_missing_gpg() {
888 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
890 writeln!(file, "fake encrypted content").unwrap();
891 file.flush().unwrap();
892
893 let result = decrypt_gpg_file(file.path());
896 assert!(result.is_err());
897
898 if let Err(LoadError::Decryption { path, message }) = result {
899 assert_eq!(path, file.path().to_path_buf());
900 assert!(!message.is_empty());
901 } else {
902 panic!("Expected Decryption error");
903 }
904 }
905
906 #[test]
907 fn test_plugin_force_python_prefix() {
908 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
909 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
910 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
911 file.flush().unwrap();
912
913 let result = Loader::new().load(file.path()).unwrap();
914
915 assert_eq!(result.plugins.len(), 2);
916
917 assert_eq!(result.plugins[0].name, "my_plugin");
919 assert!(result.plugins[0].force_python);
920
921 assert_eq!(result.plugins[1].name, "regular_plugin");
923 assert!(!result.plugins[1].force_python);
924 }
925
926 #[test]
927 fn test_plugin_force_python_with_config() {
928 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
929 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
930 file.flush().unwrap();
931
932 let result = Loader::new().load(file.path()).unwrap();
933
934 assert_eq!(result.plugins.len(), 1);
935 assert_eq!(result.plugins[0].name, "my_plugin");
936 assert!(result.plugins[0].force_python);
937 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
938 }
939
940 #[test]
941 fn test_virtual_filesystem_include_resolution() {
942 let mut vfs = VirtualFileSystem::new();
944 vfs.add_file(
945 "main.beancount",
946 r#"
947include "accounts.beancount"
948
9492024-01-15 * "Coffee"
950 Expenses:Food 5.00 USD
951 Assets:Bank -5.00 USD
952"#,
953 );
954 vfs.add_file(
955 "accounts.beancount",
956 r"
9572024-01-01 open Assets:Bank USD
9582024-01-01 open Expenses:Food USD
959",
960 );
961
962 let result = Loader::new()
964 .with_filesystem(Box::new(vfs))
965 .load(Path::new("main.beancount"))
966 .unwrap();
967
968 assert_eq!(result.directives.len(), 3);
970 assert!(result.errors.is_empty());
971
972 let directive_types: Vec<_> = result
974 .directives
975 .iter()
976 .map(|d| match &d.value {
977 rustledger_core::Directive::Open(_) => "open",
978 rustledger_core::Directive::Transaction(_) => "txn",
979 _ => "other",
980 })
981 .collect();
982 assert_eq!(directive_types, vec!["open", "open", "txn"]);
983 }
984
985 #[test]
986 fn test_virtual_filesystem_nested_includes() {
987 let mut vfs = VirtualFileSystem::new();
989 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
990 vfs.add_file(
991 "level1.beancount",
992 r#"
993include "level2.beancount"
9942024-01-01 open Assets:Level1 USD
995"#,
996 );
997 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
998
999 let result = Loader::new()
1000 .with_filesystem(Box::new(vfs))
1001 .load(Path::new("main.beancount"))
1002 .unwrap();
1003
1004 assert_eq!(result.directives.len(), 2);
1006 assert!(result.errors.is_empty());
1007 }
1008
1009 #[test]
1010 fn test_virtual_filesystem_missing_include() {
1011 let mut vfs = VirtualFileSystem::new();
1012 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
1013
1014 let result = Loader::new()
1015 .with_filesystem(Box::new(vfs))
1016 .load(Path::new("main.beancount"))
1017 .unwrap();
1018
1019 assert!(!result.errors.is_empty());
1021 let error_msg = result.errors[0].to_string();
1022 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
1023 }
1024
1025 #[test]
1026 fn test_virtual_filesystem_glob_include() {
1027 let mut vfs = VirtualFileSystem::new();
1028 vfs.add_file(
1029 "main.beancount",
1030 r#"
1031include "transactions/*.beancount"
1032
10332024-01-01 open Assets:Bank USD
1034"#,
1035 );
1036 vfs.add_file(
1037 "transactions/2024.beancount",
1038 r#"
10392024-01-01 open Expenses:Food USD
1040
10412024-06-15 * "Groceries"
1042 Expenses:Food 50.00 USD
1043 Assets:Bank -50.00 USD
1044"#,
1045 );
1046 vfs.add_file(
1047 "transactions/2025.beancount",
1048 r#"
10492025-01-01 open Expenses:Rent USD
1050
10512025-02-01 * "Rent"
1052 Expenses:Rent 1000.00 USD
1053 Assets:Bank -1000.00 USD
1054"#,
1055 );
1056 vfs.add_file(
1058 "other/ignored.beancount",
1059 "2024-01-01 open Expenses:Other USD",
1060 );
1061
1062 let result = Loader::new()
1063 .with_filesystem(Box::new(vfs))
1064 .load(Path::new("main.beancount"))
1065 .unwrap();
1066
1067 let opens = result
1069 .directives
1070 .iter()
1071 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1072 .count();
1073 assert_eq!(
1074 opens, 3,
1075 "expected 3 open directives (1 main + 2 transactions)"
1076 );
1077
1078 let txns = result
1079 .directives
1080 .iter()
1081 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1082 .count();
1083 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
1084
1085 assert!(
1086 result.errors.is_empty(),
1087 "expected no errors, got: {:?}",
1088 result.errors
1089 );
1090 }
1091
1092 #[test]
1093 fn test_virtual_filesystem_glob_dot_slash_prefix() {
1094 let mut vfs = VirtualFileSystem::new();
1095 vfs.add_file(
1096 "main.beancount",
1097 r#"
1098include "./transactions/*.beancount"
1099
11002024-01-01 open Assets:Bank USD
1101"#,
1102 );
1103 vfs.add_file(
1104 "transactions/2024.beancount",
1105 r#"
11062024-01-01 open Expenses:Food USD
1107
11082024-06-15 * "Groceries"
1109 Expenses:Food 50.00 USD
1110 Assets:Bank -50.00 USD
1111"#,
1112 );
1113 vfs.add_file(
1114 "transactions/2025.beancount",
1115 r#"
11162025-01-01 open Expenses:Rent USD
1117
11182025-02-01 * "Rent"
1119 Expenses:Rent 1000.00 USD
1120 Assets:Bank -1000.00 USD
1121"#,
1122 );
1123
1124 let result = Loader::new()
1125 .with_filesystem(Box::new(vfs))
1126 .load(Path::new("main.beancount"))
1127 .unwrap();
1128
1129 let opens = result
1131 .directives
1132 .iter()
1133 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1134 .count();
1135 assert_eq!(
1136 opens, 3,
1137 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1138 );
1139
1140 let txns = result
1141 .directives
1142 .iter()
1143 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1144 .count();
1145 assert_eq!(
1146 txns, 2,
1147 "expected 2 transactions from glob-matched files despite ./ prefix"
1148 );
1149
1150 assert!(
1151 result.errors.is_empty(),
1152 "expected no errors, got: {:?}",
1153 result.errors
1154 );
1155 }
1156
1157 #[test]
1158 fn test_virtual_filesystem_glob_no_match() {
1159 let mut vfs = VirtualFileSystem::new();
1160 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1161
1162 let result = Loader::new()
1163 .with_filesystem(Box::new(vfs))
1164 .load(Path::new("main.beancount"))
1165 .unwrap();
1166
1167 let has_glob_error = result
1169 .errors
1170 .iter()
1171 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1172 assert!(
1173 has_glob_error,
1174 "expected GlobNoMatch error, got: {:?}",
1175 result.errors
1176 );
1177 }
1178
1179 #[test]
1186 fn test_vfs_glob_include_within_root_not_flagged_as_traversal() {
1187 let mut vfs = VirtualFileSystem::new();
1188 vfs.add_file("ledger/main.beancount", r#"include "sub/*.beancount""#);
1189 vfs.add_file(
1190 "ledger/sub/a.beancount",
1191 "2024-01-01 open Assets:Cash USD\n",
1192 );
1193
1194 let result = Loader::new()
1195 .with_filesystem(Box::new(vfs))
1196 .with_root_dir(PathBuf::from("ledger")) .load(Path::new("ledger/main.beancount"))
1198 .unwrap();
1199
1200 assert!(
1201 !result
1202 .errors
1203 .iter()
1204 .any(|e| matches!(e, LoadError::PathTraversal { .. })),
1205 "legit in-root VFS glob include wrongly flagged as traversal: {:?}",
1206 result.errors
1207 );
1208 assert!(
1210 !result.directives.is_empty(),
1211 "the in-root include should have loaded; errors: {:?}",
1212 result.errors
1213 );
1214 }
1215
1216 #[test]
1222 fn test_fresh_parse_deduplicates_internedstr_across_files() {
1223 let mut vfs = VirtualFileSystem::new();
1224 vfs.add_file(
1225 "main.beancount",
1226 r#"
12272024-01-01 open Assets:Bank USD
1228include "transactions.beancount"
1229"#,
1230 );
1231 vfs.add_file(
1232 "transactions.beancount",
1233 r#"
12342024-01-15 * "Coffee"
1235 Assets:Bank -5.00 USD
1236 Expenses:Coffee 5.00 USD
1237
12382024-01-16 open Expenses:Coffee
1239"#,
1240 );
1241
1242 let result = Loader::new()
1243 .with_filesystem(Box::new(vfs))
1244 .load(Path::new("main.beancount"))
1245 .unwrap();
1246
1247 let bank_accounts: Vec<&rustledger_core::Account> = result
1251 .directives
1252 .iter()
1253 .filter_map(|s| match &s.value {
1254 rustledger_core::Directive::Open(o) if o.account.as_str() == "Assets:Bank" => {
1255 Some(&o.account)
1256 }
1257 rustledger_core::Directive::Transaction(t) => t
1258 .postings
1259 .iter()
1260 .find(|p| p.account.as_str() == "Assets:Bank")
1261 .map(|p| &p.account),
1262 _ => None,
1263 })
1264 .collect();
1265
1266 assert_eq!(
1267 bank_accounts.len(),
1268 2,
1269 "expected one Open and one posting for Assets:Bank"
1270 );
1271 assert!(
1272 bank_accounts[0]
1273 .as_interned()
1274 .ptr_eq(bank_accounts[1].as_interned()),
1275 "Assets:Bank from cross-file open/posting must share the same Arc<str> \
1276 after Loader::load runs reintern_directives"
1277 );
1278 }
1279
1280 #[test]
1287 fn test_fresh_parse_deduplicates_transaction_fields_across_files() {
1288 let mut vfs = VirtualFileSystem::new();
1289 vfs.add_file(
1290 "main.beancount",
1291 r#"
12922024-01-01 open Assets:Bank USD
12932024-01-01 open Expenses:Coffee
1294
12952024-01-15 * "Cafe Bench" "Latte" #morning
1296 Assets:Bank -5.00 USD
1297 Expenses:Coffee 5.00 USD
1298
1299include "more.beancount"
1300"#,
1301 );
1302 vfs.add_file(
1303 "more.beancount",
1304 r#"
13052024-01-16 * "Cafe Bench" "Espresso" #morning
1306 Assets:Bank -3.00 USD
1307 Expenses:Coffee 3.00 USD
1308"#,
1309 );
1310
1311 let result = Loader::new()
1312 .with_filesystem(Box::new(vfs))
1313 .load(Path::new("main.beancount"))
1314 .unwrap();
1315
1316 let txns: Vec<&rustledger_core::Transaction> = result
1317 .directives
1318 .iter()
1319 .filter_map(|s| match &s.value {
1320 rustledger_core::Directive::Transaction(t) => Some(t),
1321 _ => None,
1322 })
1323 .collect();
1324
1325 assert_eq!(txns.len(), 2, "expected the two transactions");
1326 let p1 = txns[0].payee.as_ref().expect("first txn has payee");
1327 let p2 = txns[1].payee.as_ref().expect("second txn has payee");
1328 assert!(
1329 p1.ptr_eq(p2),
1330 "Identical payee \"Cafe Bench\" across files must share one Arc<str>"
1331 );
1332
1333 assert!(!txns[0].tags.is_empty() && !txns[1].tags.is_empty());
1334 assert!(
1335 txns[0].tags[0].ptr_eq(&txns[1].tags[0]),
1336 "Identical tag #morning across files must share one Arc<str>"
1337 );
1338 }
1339
1340 #[test]
1352 fn test_fresh_parse_deduplicates_metavalue_across_files() {
1353 use rustledger_core::MetaValue;
1354
1355 let mut vfs = VirtualFileSystem::new();
1356 vfs.add_file(
1357 "main.beancount",
1358 r#"
13592024-01-01 open Assets:Bank USD
13602024-01-01 open Expenses:Coffee
1361
13622024-01-15 * "Latte"
1363 counterparty_account: Assets:Bank
1364 preferred_currency: USD
1365 category_tag: #coffee
1366 receipt_link: ^receipt-2024
1367 fee_amount: 0.50 USD
1368 Assets:Bank -5.00 USD
1369 settled_with: Assets:Bank
1370 Expenses:Coffee 5.00 USD
1371
1372include "more.beancount"
1373"#,
1374 );
1375 vfs.add_file(
1376 "more.beancount",
1377 r#"
13782024-01-16 * "Espresso"
1379 counterparty_account: Assets:Bank
1380 preferred_currency: USD
1381 category_tag: #coffee
1382 receipt_link: ^receipt-2024
1383 fee_amount: 0.50 USD
1384 Assets:Bank -3.00 USD
1385 settled_with: Assets:Bank
1386 Expenses:Coffee 3.00 USD
1387"#,
1388 );
1389
1390 let result = Loader::new()
1391 .with_filesystem(Box::new(vfs))
1392 .load(Path::new("main.beancount"))
1393 .unwrap();
1394
1395 let txns: Vec<&rustledger_core::Transaction> = result
1396 .directives
1397 .iter()
1398 .filter_map(|s| match &s.value {
1399 rustledger_core::Directive::Transaction(t) => Some(t),
1400 _ => None,
1401 })
1402 .collect();
1403 assert_eq!(txns.len(), 2);
1404
1405 let MetaValue::Account(a1) = &txns[0].meta["counterparty_account"] else {
1408 panic!("expected MetaValue::Account");
1409 };
1410 let MetaValue::Account(a2) = &txns[1].meta["counterparty_account"] else {
1411 panic!("expected MetaValue::Account");
1412 };
1413 assert!(
1414 a1.ptr_eq(a2),
1415 "MetaValue::Account in cross-file meta must share Arc<str>"
1416 );
1417
1418 let MetaValue::Currency(c1) = &txns[0].meta["preferred_currency"] else {
1419 panic!("expected MetaValue::Currency");
1420 };
1421 let MetaValue::Currency(c2) = &txns[1].meta["preferred_currency"] else {
1422 panic!("expected MetaValue::Currency");
1423 };
1424 assert!(
1425 c1.ptr_eq(c2),
1426 "MetaValue::Currency in cross-file meta must share Arc<str>"
1427 );
1428
1429 let MetaValue::Tag(t1) = &txns[0].meta["category_tag"] else {
1430 panic!("expected MetaValue::Tag");
1431 };
1432 let MetaValue::Tag(t2) = &txns[1].meta["category_tag"] else {
1433 panic!("expected MetaValue::Tag");
1434 };
1435 assert!(
1436 t1.ptr_eq(t2),
1437 "MetaValue::Tag in cross-file meta must share Arc<str>"
1438 );
1439
1440 let MetaValue::Link(l1) = &txns[0].meta["receipt_link"] else {
1441 panic!("expected MetaValue::Link");
1442 };
1443 let MetaValue::Link(l2) = &txns[1].meta["receipt_link"] else {
1444 panic!("expected MetaValue::Link");
1445 };
1446 assert!(
1447 l1.ptr_eq(l2),
1448 "MetaValue::Link in cross-file meta must share Arc<str>"
1449 );
1450
1451 let MetaValue::Amount(am1) = &txns[0].meta["fee_amount"] else {
1452 panic!("expected MetaValue::Amount");
1453 };
1454 let MetaValue::Amount(am2) = &txns[1].meta["fee_amount"] else {
1455 panic!("expected MetaValue::Amount");
1456 };
1457 assert!(
1458 am1.currency.ptr_eq(&am2.currency),
1459 "MetaValue::Amount.currency in cross-file meta must share Arc<str>"
1460 );
1461
1462 let first_posting_0 = &txns[0].postings[0].value;
1465 let first_posting_1 = &txns[1].postings[0].value;
1466 let MetaValue::Account(p1) = &first_posting_0.meta["settled_with"] else {
1467 panic!("expected MetaValue::Account in posting meta");
1468 };
1469 let MetaValue::Account(p2) = &first_posting_1.meta["settled_with"] else {
1470 panic!("expected MetaValue::Account in posting meta");
1471 };
1472 assert!(
1473 p1.ptr_eq(p2),
1474 "Posting-level MetaValue::Account in cross-file meta must share Arc<str> \
1475 (verifies the per-posting `intern_meta` call, not just the directive-level one)"
1476 );
1477 }
1478}