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(feature = "validation")]
72pub use process::{document_source_dirs, resolve_document_dirs, validation_options_from_options};
73
74#[must_use]
80pub fn is_glob_pattern(path: &str) -> bool {
81 path.contains(['*', '?', '['])
82}
83#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
84pub use process::{
85 ErrorLocation, ErrorSeverity, ExtraPlugin, Ledger, LedgerError, LoadOptions, ProcessError,
86 load, load_raw, load_with_fs, process,
87};
88#[cfg(feature = "plugins")]
89pub use process::{PluginPass, run_plugins};
90
91use rustledger_core::{Directive, DisplayContext};
92use rustledger_parser::{ParseError, Span, Spanned};
93use std::collections::HashSet;
94use std::path::{Path, PathBuf};
95use std::process::Command;
96use thiserror::Error;
97
98#[derive(Debug, Error)]
106pub enum LoadError {
107 #[error("failed to read file {path}: {source}")]
109 Io {
110 path: PathBuf,
112 #[source]
114 source: std::io::Error,
115 },
116
117 #[error(
126 "Duplicate filename parsed: \"{}\" (include cycle: {})",
127 .cycle.last().map_or("", String::as_str),
128 .cycle.join(" -> ")
129 )]
130 IncludeCycle {
131 cycle: Vec<String>,
136 },
137
138 #[error("parse errors in {path}")]
140 ParseErrors {
141 path: PathBuf,
143 errors: Vec<ParseError>,
145 },
146
147 #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
149 PathTraversal {
150 include_path: String,
152 base_dir: PathBuf,
154 },
155
156 #[error("failed to decrypt {path}: {message}")]
158 Decryption {
159 path: PathBuf,
161 message: String,
163 },
164
165 #[error("include pattern \"{pattern}\" does not match any files")]
167 GlobNoMatch {
168 pattern: String,
170 },
171
172 #[error("failed to expand include pattern \"{pattern}\": {message}")]
174 GlobError {
175 pattern: String,
177 message: String,
179 },
180
181 #[error("too many files: a ledger may reference at most {limit} files (16-bit file ids)")]
187 TooManyFiles {
188 limit: usize,
190 },
191}
192
193const fn file_id_to_u16(file_id: usize) -> Result<u16, LoadError> {
203 if file_id >= rustledger_parser::SYNTHESIZED_FILE_ID as usize {
204 return Err(LoadError::TooManyFiles {
205 limit: rustledger_parser::SYNTHESIZED_FILE_ID as usize,
206 });
207 }
208 Ok(file_id as u16)
209}
210
211#[derive(Debug)]
213pub struct LoadResult {
214 pub directives: Vec<Spanned<Directive>>,
216 pub options: Options,
218 pub plugins: Vec<Plugin>,
220 pub source_map: SourceMap,
222 pub errors: Vec<LoadError>,
224 pub display_context: DisplayContext,
226}
227
228#[derive(Debug, Clone)]
230pub struct Plugin {
231 pub name: String,
233 pub config: Option<String>,
235 pub span: Span,
237 pub file_id: usize,
239 pub force_python: bool,
241}
242
243pub(crate) fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
248 let output = Command::new("gpg")
249 .args(["--batch", "--decrypt"])
250 .arg(path)
251 .output()
252 .map_err(|e| LoadError::Decryption {
253 path: path.to_path_buf(),
254 message: format!("failed to run gpg: {e}"),
255 })?;
256
257 if !output.status.success() {
258 return Err(LoadError::Decryption {
259 path: path.to_path_buf(),
260 message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
261 });
262 }
263
264 String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
265 path: path.to_path_buf(),
266 message: format!("decrypted content is not valid UTF-8: {e}"),
267 })
268}
269
270#[derive(Debug)]
272pub struct Loader {
273 loaded_files: HashSet<PathBuf>,
275 include_stack: Vec<PathBuf>,
277 include_stack_set: HashSet<PathBuf>,
279 root_dir: Option<PathBuf>,
282 enforce_path_security: bool,
284 fs: Box<dyn FileSystem>,
286}
287
288impl Default for Loader {
289 fn default() -> Self {
290 Self {
291 loaded_files: HashSet::new(),
292 include_stack: Vec::new(),
293 include_stack_set: HashSet::new(),
294 root_dir: None,
295 enforce_path_security: false,
296 fs: Box::new(DiskFileSystem),
297 }
298 }
299}
300
301impl Loader {
302 #[must_use]
304 pub fn new() -> Self {
305 Self::default()
306 }
307
308 #[must_use]
322 pub const fn with_path_security(mut self, enabled: bool) -> Self {
323 self.enforce_path_security = enabled;
324 self
325 }
326
327 #[must_use]
332 pub fn with_root_dir(mut self, root: PathBuf) -> Self {
333 self.root_dir = Some(root);
334 self.enforce_path_security = true;
335 self
336 }
337
338 #[must_use]
354 pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
355 self.fs = fs;
356 self
357 }
358
359 pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
377 let mut directives = Vec::new();
378 let mut options = Options::default();
379 let mut plugins = Vec::new();
380 let mut source_map = SourceMap::new();
381 let mut errors = Vec::new();
382
383 let canonical = self.fs.normalize(path);
385
386 if self.enforce_path_security && self.root_dir.is_none() {
388 self.root_dir = canonical.parent().map(Path::to_path_buf);
389 }
390 if let Some(root) = self.root_dir.take() {
398 self.root_dir = Some(self.fs.normalize(&root));
399 }
400
401 self.load_recursive(
404 &canonical,
405 None,
406 &mut directives,
407 &mut options,
408 &mut plugins,
409 &mut source_map,
410 &mut errors,
411 )?;
412
413 dedup::reintern_directives(&mut directives);
428
429 let display_context = build_display_context(&directives, &options);
431
432 Ok(LoadResult {
433 directives,
434 options,
435 plugins,
436 source_map,
437 errors,
438 display_context,
439 })
440 }
441
442 #[allow(clippy::too_many_arguments)]
443 fn load_recursive(
444 &mut self,
445 path: &Path,
446 pre_parsed: Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>,
447 directives: &mut Vec<Spanned<Directive>>,
448 options: &mut Options,
449 plugins: &mut Vec<Plugin>,
450 source_map: &mut SourceMap,
451 errors: &mut Vec<LoadError>,
452 ) -> Result<(), LoadError> {
453 let path_buf = path.to_path_buf();
455
456 if self.include_stack_set.contains(&path_buf) {
458 let cycle: Vec<String> = self
464 .include_stack
465 .iter()
466 .map(|p| p.display().to_string())
467 .chain(std::iter::once(path.display().to_string()))
468 .collect();
469 return Err(LoadError::IncludeCycle { cycle });
470 }
471
472 if self.loaded_files.contains(&path_buf) {
474 return Ok(());
475 }
476
477 let (source, result) = if let Some(pre) = pre_parsed {
480 pre
481 } else {
482 let src: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
483 self.fs.decrypt(path)?
487 } else {
488 self.fs.read(path)?
489 };
490 let parsed = rustledger_parser::parse_without_occurrences(&src);
493 (src, parsed)
494 };
495
496 let fid_u16 = file_id_to_u16(source_map.files().len())?;
502 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
504 debug_assert_eq!(file_id, fid_u16 as usize);
505
506 self.include_stack_set.insert(path_buf.clone());
508 self.include_stack.push(path_buf.clone());
509 self.loaded_files.insert(path_buf);
510
511 if !result.errors.is_empty() {
513 errors.push(LoadError::ParseErrors {
514 path: path.to_path_buf(),
515 errors: result.errors,
516 });
517 }
518
519 for (key, value, _span) in result.options {
521 options.set(&key, &value);
522 }
523
524 for (name, config, span) in result.plugins {
526 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
528 (stripped.to_string(), true)
529 } else {
530 (name, false)
531 };
532 plugins.push(Plugin {
533 name: actual_name,
534 config,
535 span,
536 file_id,
537 force_python,
538 });
539 }
540
541 let base_dir = path.parent().unwrap_or(Path::new("."));
543 for (include_path, _span) in &result.includes {
544 let has_glob = is_glob_pattern(include_path);
547
548 let full_path = base_dir.join(include_path);
549
550 if self.enforce_path_security
553 && let Some(ref root) = self.root_dir
554 {
555 let path_to_check = if has_glob {
557 let glob_start = include_path
559 .find(['*', '?', '['])
560 .unwrap_or(include_path.len());
561 let prefix = &include_path[..glob_start];
563 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
564 base_dir.join(&include_path[..=last_sep])
565 } else {
566 base_dir.to_path_buf()
567 };
568 self.fs.normalize(&prefix_path)
575 } else {
576 self.fs.normalize(&full_path)
577 };
578
579 if !path_to_check.starts_with(root) {
580 errors.push(LoadError::PathTraversal {
581 include_path: include_path.clone(),
582 base_dir: root.clone(),
583 });
584 continue;
585 }
586 }
587
588 let full_path_str = full_path.to_string_lossy();
589
590 let paths_to_load: Vec<PathBuf> = if has_glob {
592 match self.fs.glob(&full_path_str) {
593 Ok(matched) => matched,
594 Err(e) => {
595 errors.push(LoadError::GlobError {
596 pattern: include_path.clone(),
597 message: e,
598 });
599 continue;
600 }
601 }
602 } else {
603 vec![full_path.clone()]
604 };
605
606 if has_glob && paths_to_load.is_empty() {
608 errors.push(LoadError::GlobNoMatch {
609 pattern: include_path.clone(),
610 });
611 continue;
612 }
613
614 let mut valid_paths = Vec::with_capacity(paths_to_load.len());
616 for matched_path in paths_to_load {
617 let canonical = self.fs.normalize(&matched_path);
618
619 if self.enforce_path_security
621 && let Some(ref root) = self.root_dir
622 && !canonical.starts_with(root)
623 {
624 errors.push(LoadError::PathTraversal {
625 include_path: matched_path.to_string_lossy().into_owned(),
626 base_dir: root.clone(),
627 });
628 continue;
629 }
630
631 valid_paths.push(canonical);
632 }
633
634 if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
643 use rayon::prelude::*;
644
645 let fs = &*self.fs;
654 let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
655 valid_paths
656 .par_iter()
657 .map(|p| {
658 if fs.is_encrypted(p) {
660 return None;
661 }
662 let source = fs.read(p).ok()?;
665 let parsed = rustledger_parser::parse_without_occurrences(&source);
667 Some((source, parsed))
668 })
669 .collect();
670
671 for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
676 if let Err(e) = self.load_recursive(
677 canonical, pre, directives, options, plugins, source_map, errors,
678 ) {
679 errors.push(e);
680 }
681 }
682 } else {
683 for canonical in valid_paths {
685 if let Err(e) = self.load_recursive(
686 &canonical, None, directives, options, plugins, source_map, errors,
687 ) {
688 errors.push(e);
689 }
690 }
691 }
692 }
693
694 directives.extend(result.directives.into_iter().map(|d| {
704 let mut d = d.with_file_id(file_id);
705 if let rustledger_core::Directive::Transaction(ref mut txn) = d.value {
706 for p in &mut txn.postings {
707 p.file_id = fid_u16;
708 }
709 }
710 d
711 }));
712
713 if let Some(popped) = self.include_stack.pop() {
715 self.include_stack_set.remove(&popped);
716 }
717
718 Ok(())
719 }
720}
721
722fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
728 let mut ctx = DisplayContext::new();
729
730 ctx.set_render_commas(options.render_commas);
732
733 for spanned in directives {
735 match &spanned.value {
736 Directive::Transaction(txn) => {
737 for posting in &txn.postings {
738 if let Some(ref units) = posting.units
740 && let (Some(number), Some(currency)) = (units.number(), units.currency())
741 {
742 ctx.update(number, currency);
743 }
744 if let Some(ref cost) = posting.cost
752 && let (Some(number), Some(currency)) = (
753 cost.number
754 .map(|cn| cn.total().or_else(|| cn.per_unit()).unwrap_or_default()),
755 &cost.currency,
756 )
757 {
758 ctx.update(number, currency.as_str());
759 }
760 if let Some(ref price) = posting.price
770 && let Some(amount) = price.amount()
771 {
772 ctx.update(amount.number, amount.currency.as_str());
773 }
774 }
775 }
776 Directive::Balance(bal) => {
777 ctx.update(bal.amount.number, bal.amount.currency.as_str());
778 if let Some(tol) = bal.tolerance {
779 ctx.update(tol, bal.amount.currency.as_str());
780 }
781 }
782 Directive::Price(p) => {
783 ctx.update(p.amount.number, p.amount.currency.as_str());
788 }
789 Directive::Pad(_)
790 | Directive::Open(_)
791 | Directive::Close(_)
792 | Directive::Commodity(_)
793 | Directive::Event(_)
794 | Directive::Query(_)
795 | Directive::Note(_)
796 | Directive::Document(_)
797 | Directive::Custom(_) => {}
798 }
799 }
800
801 for (currency, precision) in &options.display_precision {
803 ctx.set_fixed_precision(currency, *precision);
804 }
805
806 for spanned in directives {
814 if let Directive::Commodity(comm) = &spanned.value
815 && let Some(value) = comm.meta.get("precision")
816 && let Ok(precision) = rustledger_core::parse_precision_meta(value)
817 {
818 ctx.set_fixed_precision(comm.currency.as_str(), precision);
819 }
820 }
821
822 ctx
823}
824
825#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
831pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
832 Loader::new().load(path)
833}
834
835#[cfg(test)]
836mod tests {
837 use super::*;
838 use std::io::Write;
839 use tempfile::NamedTempFile;
840
841 #[test]
842 fn file_id_to_u16_rejects_the_reserved_sentinel_not_panic() {
843 let sentinel = rustledger_parser::SYNTHESIZED_FILE_ID as usize;
844 assert_eq!(file_id_to_u16(0).unwrap(), 0);
846 assert_eq!(
847 file_id_to_u16(sentinel - 1).unwrap(),
848 rustledger_parser::SYNTHESIZED_FILE_ID - 1
849 );
850 assert!(matches!(
855 file_id_to_u16(sentinel),
856 Err(LoadError::TooManyFiles { limit }) if limit == sentinel
857 ));
858 assert!(matches!(
859 file_id_to_u16(sentinel + 1),
860 Err(LoadError::TooManyFiles { .. })
861 ));
862 }
863
864 #[test]
865 fn test_is_encrypted_file_gpg_extension() {
866 let fs = DiskFileSystem;
867 let path = Path::new("test.beancount.gpg");
868 assert!(fs.is_encrypted(path));
869 }
870
871 #[test]
872 fn test_is_encrypted_file_plain_beancount() {
873 let fs = DiskFileSystem;
874 let path = Path::new("test.beancount");
875 assert!(!fs.is_encrypted(path));
876 }
877
878 #[test]
879 fn test_is_encrypted_file_asc_with_pgp_header() {
880 let fs = DiskFileSystem;
881 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
882 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
883 writeln!(file, "some encrypted content").unwrap();
884 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
885 file.flush().unwrap();
886
887 assert!(fs.is_encrypted(file.path()));
888 }
889
890 #[test]
891 fn test_is_encrypted_file_asc_without_pgp_header() {
892 let fs = DiskFileSystem;
893 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
894 writeln!(file, "This is just a plain text file").unwrap();
895 writeln!(file, "with .asc extension but no PGP content").unwrap();
896 file.flush().unwrap();
897
898 assert!(!fs.is_encrypted(file.path()));
899 }
900
901 #[test]
902 fn test_decrypt_gpg_file_missing_gpg() {
903 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
905 writeln!(file, "fake encrypted content").unwrap();
906 file.flush().unwrap();
907
908 let result = decrypt_gpg_file(file.path());
911 assert!(result.is_err());
912
913 if let Err(LoadError::Decryption { path, message }) = result {
914 assert_eq!(path, file.path().to_path_buf());
915 assert!(!message.is_empty());
916 } else {
917 panic!("Expected Decryption error");
918 }
919 }
920
921 #[test]
922 fn test_plugin_force_python_prefix() {
923 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
924 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
925 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
926 file.flush().unwrap();
927
928 let result = Loader::new().load(file.path()).unwrap();
929
930 assert_eq!(result.plugins.len(), 2);
931
932 assert_eq!(result.plugins[0].name, "my_plugin");
934 assert!(result.plugins[0].force_python);
935
936 assert_eq!(result.plugins[1].name, "regular_plugin");
938 assert!(!result.plugins[1].force_python);
939 }
940
941 #[test]
942 fn test_plugin_force_python_with_config() {
943 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
944 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
945 file.flush().unwrap();
946
947 let result = Loader::new().load(file.path()).unwrap();
948
949 assert_eq!(result.plugins.len(), 1);
950 assert_eq!(result.plugins[0].name, "my_plugin");
951 assert!(result.plugins[0].force_python);
952 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
953 }
954
955 #[test]
956 fn test_virtual_filesystem_include_resolution() {
957 let mut vfs = VirtualFileSystem::new();
959 vfs.add_file(
960 "main.beancount",
961 r#"
962include "accounts.beancount"
963
9642024-01-15 * "Coffee"
965 Expenses:Food 5.00 USD
966 Assets:Bank -5.00 USD
967"#,
968 );
969 vfs.add_file(
970 "accounts.beancount",
971 r"
9722024-01-01 open Assets:Bank USD
9732024-01-01 open Expenses:Food USD
974",
975 );
976
977 let result = Loader::new()
979 .with_filesystem(Box::new(vfs))
980 .load(Path::new("main.beancount"))
981 .unwrap();
982
983 assert_eq!(result.directives.len(), 3);
985 assert!(result.errors.is_empty());
986
987 let directive_types: Vec<_> = result
989 .directives
990 .iter()
991 .map(|d| match &d.value {
992 rustledger_core::Directive::Open(_) => "open",
993 rustledger_core::Directive::Transaction(_) => "txn",
994 _ => "other",
995 })
996 .collect();
997 assert_eq!(directive_types, vec!["open", "open", "txn"]);
998 }
999
1000 #[test]
1001 fn test_virtual_filesystem_nested_includes() {
1002 let mut vfs = VirtualFileSystem::new();
1004 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
1005 vfs.add_file(
1006 "level1.beancount",
1007 r#"
1008include "level2.beancount"
10092024-01-01 open Assets:Level1 USD
1010"#,
1011 );
1012 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
1013
1014 let result = Loader::new()
1015 .with_filesystem(Box::new(vfs))
1016 .load(Path::new("main.beancount"))
1017 .unwrap();
1018
1019 assert_eq!(result.directives.len(), 2);
1021 assert!(result.errors.is_empty());
1022 }
1023
1024 #[test]
1025 fn test_virtual_filesystem_missing_include() {
1026 let mut vfs = VirtualFileSystem::new();
1027 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
1028
1029 let result = Loader::new()
1030 .with_filesystem(Box::new(vfs))
1031 .load(Path::new("main.beancount"))
1032 .unwrap();
1033
1034 assert!(!result.errors.is_empty());
1036 let error_msg = result.errors[0].to_string();
1037 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
1038 }
1039
1040 #[test]
1041 fn test_virtual_filesystem_glob_include() {
1042 let mut vfs = VirtualFileSystem::new();
1043 vfs.add_file(
1044 "main.beancount",
1045 r#"
1046include "transactions/*.beancount"
1047
10482024-01-01 open Assets:Bank USD
1049"#,
1050 );
1051 vfs.add_file(
1052 "transactions/2024.beancount",
1053 r#"
10542024-01-01 open Expenses:Food USD
1055
10562024-06-15 * "Groceries"
1057 Expenses:Food 50.00 USD
1058 Assets:Bank -50.00 USD
1059"#,
1060 );
1061 vfs.add_file(
1062 "transactions/2025.beancount",
1063 r#"
10642025-01-01 open Expenses:Rent USD
1065
10662025-02-01 * "Rent"
1067 Expenses:Rent 1000.00 USD
1068 Assets:Bank -1000.00 USD
1069"#,
1070 );
1071 vfs.add_file(
1073 "other/ignored.beancount",
1074 "2024-01-01 open Expenses:Other USD",
1075 );
1076
1077 let result = Loader::new()
1078 .with_filesystem(Box::new(vfs))
1079 .load(Path::new("main.beancount"))
1080 .unwrap();
1081
1082 let opens = result
1084 .directives
1085 .iter()
1086 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1087 .count();
1088 assert_eq!(
1089 opens, 3,
1090 "expected 3 open directives (1 main + 2 transactions)"
1091 );
1092
1093 let txns = result
1094 .directives
1095 .iter()
1096 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1097 .count();
1098 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
1099
1100 assert!(
1101 result.errors.is_empty(),
1102 "expected no errors, got: {:?}",
1103 result.errors
1104 );
1105 }
1106
1107 #[test]
1108 fn test_virtual_filesystem_glob_dot_slash_prefix() {
1109 let mut vfs = VirtualFileSystem::new();
1110 vfs.add_file(
1111 "main.beancount",
1112 r#"
1113include "./transactions/*.beancount"
1114
11152024-01-01 open Assets:Bank USD
1116"#,
1117 );
1118 vfs.add_file(
1119 "transactions/2024.beancount",
1120 r#"
11212024-01-01 open Expenses:Food USD
1122
11232024-06-15 * "Groceries"
1124 Expenses:Food 50.00 USD
1125 Assets:Bank -50.00 USD
1126"#,
1127 );
1128 vfs.add_file(
1129 "transactions/2025.beancount",
1130 r#"
11312025-01-01 open Expenses:Rent USD
1132
11332025-02-01 * "Rent"
1134 Expenses:Rent 1000.00 USD
1135 Assets:Bank -1000.00 USD
1136"#,
1137 );
1138
1139 let result = Loader::new()
1140 .with_filesystem(Box::new(vfs))
1141 .load(Path::new("main.beancount"))
1142 .unwrap();
1143
1144 let opens = result
1146 .directives
1147 .iter()
1148 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1149 .count();
1150 assert_eq!(
1151 opens, 3,
1152 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1153 );
1154
1155 let txns = result
1156 .directives
1157 .iter()
1158 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1159 .count();
1160 assert_eq!(
1161 txns, 2,
1162 "expected 2 transactions from glob-matched files despite ./ prefix"
1163 );
1164
1165 assert!(
1166 result.errors.is_empty(),
1167 "expected no errors, got: {:?}",
1168 result.errors
1169 );
1170 }
1171
1172 #[test]
1173 fn test_virtual_filesystem_glob_no_match() {
1174 let mut vfs = VirtualFileSystem::new();
1175 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1176
1177 let result = Loader::new()
1178 .with_filesystem(Box::new(vfs))
1179 .load(Path::new("main.beancount"))
1180 .unwrap();
1181
1182 let has_glob_error = result
1184 .errors
1185 .iter()
1186 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1187 assert!(
1188 has_glob_error,
1189 "expected GlobNoMatch error, got: {:?}",
1190 result.errors
1191 );
1192 }
1193
1194 #[test]
1201 fn test_vfs_glob_include_within_root_not_flagged_as_traversal() {
1202 let mut vfs = VirtualFileSystem::new();
1203 vfs.add_file("ledger/main.beancount", r#"include "sub/*.beancount""#);
1204 vfs.add_file(
1205 "ledger/sub/a.beancount",
1206 "2024-01-01 open Assets:Cash USD\n",
1207 );
1208
1209 let result = Loader::new()
1210 .with_filesystem(Box::new(vfs))
1211 .with_root_dir(PathBuf::from("ledger")) .load(Path::new("ledger/main.beancount"))
1213 .unwrap();
1214
1215 assert!(
1216 !result
1217 .errors
1218 .iter()
1219 .any(|e| matches!(e, LoadError::PathTraversal { .. })),
1220 "legit in-root VFS glob include wrongly flagged as traversal: {:?}",
1221 result.errors
1222 );
1223 assert!(
1225 !result.directives.is_empty(),
1226 "the in-root include should have loaded; errors: {:?}",
1227 result.errors
1228 );
1229 }
1230
1231 #[test]
1237 fn test_fresh_parse_deduplicates_internedstr_across_files() {
1238 let mut vfs = VirtualFileSystem::new();
1239 vfs.add_file(
1240 "main.beancount",
1241 r#"
12422024-01-01 open Assets:Bank USD
1243include "transactions.beancount"
1244"#,
1245 );
1246 vfs.add_file(
1247 "transactions.beancount",
1248 r#"
12492024-01-15 * "Coffee"
1250 Assets:Bank -5.00 USD
1251 Expenses:Coffee 5.00 USD
1252
12532024-01-16 open Expenses:Coffee
1254"#,
1255 );
1256
1257 let result = Loader::new()
1258 .with_filesystem(Box::new(vfs))
1259 .load(Path::new("main.beancount"))
1260 .unwrap();
1261
1262 let bank_accounts: Vec<&rustledger_core::Account> = result
1266 .directives
1267 .iter()
1268 .filter_map(|s| match &s.value {
1269 rustledger_core::Directive::Open(o) if o.account.as_str() == "Assets:Bank" => {
1270 Some(&o.account)
1271 }
1272 rustledger_core::Directive::Transaction(t) => t
1273 .postings
1274 .iter()
1275 .find(|p| p.account.as_str() == "Assets:Bank")
1276 .map(|p| &p.account),
1277 _ => None,
1278 })
1279 .collect();
1280
1281 assert_eq!(
1282 bank_accounts.len(),
1283 2,
1284 "expected one Open and one posting for Assets:Bank"
1285 );
1286 assert!(
1287 bank_accounts[0]
1288 .as_interned()
1289 .ptr_eq(bank_accounts[1].as_interned()),
1290 "Assets:Bank from cross-file open/posting must share the same Arc<str> \
1291 after Loader::load runs reintern_directives"
1292 );
1293 }
1294
1295 #[test]
1302 fn test_fresh_parse_deduplicates_transaction_fields_across_files() {
1303 let mut vfs = VirtualFileSystem::new();
1304 vfs.add_file(
1305 "main.beancount",
1306 r#"
13072024-01-01 open Assets:Bank USD
13082024-01-01 open Expenses:Coffee
1309
13102024-01-15 * "Cafe Bench" "Latte" #morning
1311 Assets:Bank -5.00 USD
1312 Expenses:Coffee 5.00 USD
1313
1314include "more.beancount"
1315"#,
1316 );
1317 vfs.add_file(
1318 "more.beancount",
1319 r#"
13202024-01-16 * "Cafe Bench" "Espresso" #morning
1321 Assets:Bank -3.00 USD
1322 Expenses:Coffee 3.00 USD
1323"#,
1324 );
1325
1326 let result = Loader::new()
1327 .with_filesystem(Box::new(vfs))
1328 .load(Path::new("main.beancount"))
1329 .unwrap();
1330
1331 let txns: Vec<&rustledger_core::Transaction> = result
1332 .directives
1333 .iter()
1334 .filter_map(|s| match &s.value {
1335 rustledger_core::Directive::Transaction(t) => Some(t),
1336 _ => None,
1337 })
1338 .collect();
1339
1340 assert_eq!(txns.len(), 2, "expected the two transactions");
1341 let p1 = txns[0].payee.as_ref().expect("first txn has payee");
1342 let p2 = txns[1].payee.as_ref().expect("second txn has payee");
1343 assert!(
1344 p1.ptr_eq(p2),
1345 "Identical payee \"Cafe Bench\" across files must share one Arc<str>"
1346 );
1347
1348 assert!(!txns[0].tags.is_empty() && !txns[1].tags.is_empty());
1349 assert!(
1350 txns[0].tags[0].ptr_eq(&txns[1].tags[0]),
1351 "Identical tag #morning across files must share one Arc<str>"
1352 );
1353 }
1354
1355 #[test]
1367 fn test_fresh_parse_deduplicates_metavalue_across_files() {
1368 use rustledger_core::MetaValue;
1369
1370 let mut vfs = VirtualFileSystem::new();
1371 vfs.add_file(
1372 "main.beancount",
1373 r#"
13742024-01-01 open Assets:Bank USD
13752024-01-01 open Expenses:Coffee
1376
13772024-01-15 * "Latte"
1378 counterparty_account: Assets:Bank
1379 preferred_currency: USD
1380 category_tag: #coffee
1381 receipt_link: ^receipt-2024
1382 fee_amount: 0.50 USD
1383 Assets:Bank -5.00 USD
1384 settled_with: Assets:Bank
1385 Expenses:Coffee 5.00 USD
1386
1387include "more.beancount"
1388"#,
1389 );
1390 vfs.add_file(
1391 "more.beancount",
1392 r#"
13932024-01-16 * "Espresso"
1394 counterparty_account: Assets:Bank
1395 preferred_currency: USD
1396 category_tag: #coffee
1397 receipt_link: ^receipt-2024
1398 fee_amount: 0.50 USD
1399 Assets:Bank -3.00 USD
1400 settled_with: Assets:Bank
1401 Expenses:Coffee 3.00 USD
1402"#,
1403 );
1404
1405 let result = Loader::new()
1406 .with_filesystem(Box::new(vfs))
1407 .load(Path::new("main.beancount"))
1408 .unwrap();
1409
1410 let txns: Vec<&rustledger_core::Transaction> = result
1411 .directives
1412 .iter()
1413 .filter_map(|s| match &s.value {
1414 rustledger_core::Directive::Transaction(t) => Some(t),
1415 _ => None,
1416 })
1417 .collect();
1418 assert_eq!(txns.len(), 2);
1419
1420 let MetaValue::Account(a1) = &txns[0].meta["counterparty_account"] else {
1423 panic!("expected MetaValue::Account");
1424 };
1425 let MetaValue::Account(a2) = &txns[1].meta["counterparty_account"] else {
1426 panic!("expected MetaValue::Account");
1427 };
1428 assert!(
1429 a1.ptr_eq(a2),
1430 "MetaValue::Account in cross-file meta must share Arc<str>"
1431 );
1432
1433 let MetaValue::Currency(c1) = &txns[0].meta["preferred_currency"] else {
1434 panic!("expected MetaValue::Currency");
1435 };
1436 let MetaValue::Currency(c2) = &txns[1].meta["preferred_currency"] else {
1437 panic!("expected MetaValue::Currency");
1438 };
1439 assert!(
1440 c1.ptr_eq(c2),
1441 "MetaValue::Currency in cross-file meta must share Arc<str>"
1442 );
1443
1444 let MetaValue::Tag(t1) = &txns[0].meta["category_tag"] else {
1445 panic!("expected MetaValue::Tag");
1446 };
1447 let MetaValue::Tag(t2) = &txns[1].meta["category_tag"] else {
1448 panic!("expected MetaValue::Tag");
1449 };
1450 assert!(
1451 t1.ptr_eq(t2),
1452 "MetaValue::Tag in cross-file meta must share Arc<str>"
1453 );
1454
1455 let MetaValue::Link(l1) = &txns[0].meta["receipt_link"] else {
1456 panic!("expected MetaValue::Link");
1457 };
1458 let MetaValue::Link(l2) = &txns[1].meta["receipt_link"] else {
1459 panic!("expected MetaValue::Link");
1460 };
1461 assert!(
1462 l1.ptr_eq(l2),
1463 "MetaValue::Link in cross-file meta must share Arc<str>"
1464 );
1465
1466 let MetaValue::Amount(am1) = &txns[0].meta["fee_amount"] else {
1467 panic!("expected MetaValue::Amount");
1468 };
1469 let MetaValue::Amount(am2) = &txns[1].meta["fee_amount"] else {
1470 panic!("expected MetaValue::Amount");
1471 };
1472 assert!(
1473 am1.currency.ptr_eq(&am2.currency),
1474 "MetaValue::Amount.currency in cross-file meta must share Arc<str>"
1475 );
1476
1477 let first_posting_0 = &txns[0].postings[0].value;
1480 let first_posting_1 = &txns[1].postings[0].value;
1481 let MetaValue::Account(p1) = &first_posting_0.meta["settled_with"] else {
1482 panic!("expected MetaValue::Account in posting meta");
1483 };
1484 let MetaValue::Account(p2) = &first_posting_1.meta["settled_with"] else {
1485 panic!("expected MetaValue::Account in posting meta");
1486 };
1487 assert!(
1488 p1.ptr_eq(p2),
1489 "Posting-level MetaValue::Account in cross-file meta must share Arc<str> \
1490 (verifies the per-posting `intern_meta` call, not just the directive-level one)"
1491 );
1492 }
1493}