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, 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
243fn 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 decrypt_gpg_file(path)?.into()
484 } else {
485 self.fs.read(path)?
486 };
487 let parsed = rustledger_parser::parse_without_occurrences(&src);
490 (src, parsed)
491 };
492
493 let fid_u16 = file_id_to_u16(source_map.files().len())?;
499 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
501 debug_assert_eq!(file_id, fid_u16 as usize);
502
503 self.include_stack_set.insert(path_buf.clone());
505 self.include_stack.push(path_buf.clone());
506 self.loaded_files.insert(path_buf);
507
508 if !result.errors.is_empty() {
510 errors.push(LoadError::ParseErrors {
511 path: path.to_path_buf(),
512 errors: result.errors,
513 });
514 }
515
516 for (key, value, _span) in result.options {
518 options.set(&key, &value);
519 }
520
521 for (name, config, span) in result.plugins {
523 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
525 (stripped.to_string(), true)
526 } else {
527 (name, false)
528 };
529 plugins.push(Plugin {
530 name: actual_name,
531 config,
532 span,
533 file_id,
534 force_python,
535 });
536 }
537
538 let base_dir = path.parent().unwrap_or(Path::new("."));
540 for (include_path, _span) in &result.includes {
541 let has_glob = is_glob_pattern(include_path);
544
545 let full_path = base_dir.join(include_path);
546
547 if self.enforce_path_security
550 && let Some(ref root) = self.root_dir
551 {
552 let path_to_check = if has_glob {
554 let glob_start = include_path
556 .find(['*', '?', '['])
557 .unwrap_or(include_path.len());
558 let prefix = &include_path[..glob_start];
560 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
561 base_dir.join(&include_path[..=last_sep])
562 } else {
563 base_dir.to_path_buf()
564 };
565 self.fs.normalize(&prefix_path)
572 } else {
573 self.fs.normalize(&full_path)
574 };
575
576 if !path_to_check.starts_with(root) {
577 errors.push(LoadError::PathTraversal {
578 include_path: include_path.clone(),
579 base_dir: root.clone(),
580 });
581 continue;
582 }
583 }
584
585 let full_path_str = full_path.to_string_lossy();
586
587 let paths_to_load: Vec<PathBuf> = if has_glob {
589 match self.fs.glob(&full_path_str) {
590 Ok(matched) => matched,
591 Err(e) => {
592 errors.push(LoadError::GlobError {
593 pattern: include_path.clone(),
594 message: e,
595 });
596 continue;
597 }
598 }
599 } else {
600 vec![full_path.clone()]
601 };
602
603 if has_glob && paths_to_load.is_empty() {
605 errors.push(LoadError::GlobNoMatch {
606 pattern: include_path.clone(),
607 });
608 continue;
609 }
610
611 let mut valid_paths = Vec::with_capacity(paths_to_load.len());
613 for matched_path in paths_to_load {
614 let canonical = self.fs.normalize(&matched_path);
615
616 if self.enforce_path_security
618 && let Some(ref root) = self.root_dir
619 && !canonical.starts_with(root)
620 {
621 errors.push(LoadError::PathTraversal {
622 include_path: matched_path.to_string_lossy().into_owned(),
623 base_dir: root.clone(),
624 });
625 continue;
626 }
627
628 valid_paths.push(canonical);
629 }
630
631 if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
640 use rayon::prelude::*;
641
642 let fs = &*self.fs;
651 let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
652 valid_paths
653 .par_iter()
654 .map(|p| {
655 if fs.is_encrypted(p) {
657 return None;
658 }
659 let source = fs.read(p).ok()?;
662 let parsed = rustledger_parser::parse_without_occurrences(&source);
664 Some((source, parsed))
665 })
666 .collect();
667
668 for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
673 if let Err(e) = self.load_recursive(
674 canonical, pre, directives, options, plugins, source_map, errors,
675 ) {
676 errors.push(e);
677 }
678 }
679 } else {
680 for canonical in valid_paths {
682 if let Err(e) = self.load_recursive(
683 &canonical, None, directives, options, plugins, source_map, errors,
684 ) {
685 errors.push(e);
686 }
687 }
688 }
689 }
690
691 directives.extend(result.directives.into_iter().map(|d| {
701 let mut d = d.with_file_id(file_id);
702 if let rustledger_core::Directive::Transaction(ref mut txn) = d.value {
703 for p in &mut txn.postings {
704 p.file_id = fid_u16;
705 }
706 }
707 d
708 }));
709
710 if let Some(popped) = self.include_stack.pop() {
712 self.include_stack_set.remove(&popped);
713 }
714
715 Ok(())
716 }
717}
718
719fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
725 let mut ctx = DisplayContext::new();
726
727 ctx.set_render_commas(options.render_commas);
729
730 for spanned in directives {
732 match &spanned.value {
733 Directive::Transaction(txn) => {
734 for posting in &txn.postings {
735 if let Some(ref units) = posting.units
737 && let (Some(number), Some(currency)) = (units.number(), units.currency())
738 {
739 ctx.update(number, currency);
740 }
741 if let Some(ref cost) = posting.cost
749 && let (Some(number), Some(currency)) = (
750 cost.number
751 .map(|cn| cn.total().or_else(|| cn.per_unit()).unwrap_or_default()),
752 &cost.currency,
753 )
754 {
755 ctx.update(number, currency.as_str());
756 }
757 if let Some(ref price) = posting.price
767 && let Some(amount) = price.amount()
768 {
769 ctx.update(amount.number, amount.currency.as_str());
770 }
771 }
772 }
773 Directive::Balance(bal) => {
774 ctx.update(bal.amount.number, bal.amount.currency.as_str());
775 if let Some(tol) = bal.tolerance {
776 ctx.update(tol, bal.amount.currency.as_str());
777 }
778 }
779 Directive::Price(p) => {
780 ctx.update(p.amount.number, p.amount.currency.as_str());
785 }
786 Directive::Pad(_)
787 | Directive::Open(_)
788 | Directive::Close(_)
789 | Directive::Commodity(_)
790 | Directive::Event(_)
791 | Directive::Query(_)
792 | Directive::Note(_)
793 | Directive::Document(_)
794 | Directive::Custom(_) => {}
795 }
796 }
797
798 for (currency, precision) in &options.display_precision {
800 ctx.set_fixed_precision(currency, *precision);
801 }
802
803 for spanned in directives {
811 if let Directive::Commodity(comm) = &spanned.value
812 && let Some(value) = comm.meta.get("precision")
813 && let Ok(precision) = rustledger_core::parse_precision_meta(value)
814 {
815 ctx.set_fixed_precision(comm.currency.as_str(), precision);
816 }
817 }
818
819 ctx
820}
821
822#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
828pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
829 Loader::new().load(path)
830}
831
832#[cfg(test)]
833mod tests {
834 use super::*;
835 use std::io::Write;
836 use tempfile::NamedTempFile;
837
838 #[test]
839 fn file_id_to_u16_rejects_the_reserved_sentinel_not_panic() {
840 let sentinel = rustledger_parser::SYNTHESIZED_FILE_ID as usize;
841 assert_eq!(file_id_to_u16(0).unwrap(), 0);
843 assert_eq!(
844 file_id_to_u16(sentinel - 1).unwrap(),
845 rustledger_parser::SYNTHESIZED_FILE_ID - 1
846 );
847 assert!(matches!(
852 file_id_to_u16(sentinel),
853 Err(LoadError::TooManyFiles { limit }) if limit == sentinel
854 ));
855 assert!(matches!(
856 file_id_to_u16(sentinel + 1),
857 Err(LoadError::TooManyFiles { .. })
858 ));
859 }
860
861 #[test]
862 fn test_is_encrypted_file_gpg_extension() {
863 let fs = DiskFileSystem;
864 let path = Path::new("test.beancount.gpg");
865 assert!(fs.is_encrypted(path));
866 }
867
868 #[test]
869 fn test_is_encrypted_file_plain_beancount() {
870 let fs = DiskFileSystem;
871 let path = Path::new("test.beancount");
872 assert!(!fs.is_encrypted(path));
873 }
874
875 #[test]
876 fn test_is_encrypted_file_asc_with_pgp_header() {
877 let fs = DiskFileSystem;
878 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
879 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
880 writeln!(file, "some encrypted content").unwrap();
881 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
882 file.flush().unwrap();
883
884 assert!(fs.is_encrypted(file.path()));
885 }
886
887 #[test]
888 fn test_is_encrypted_file_asc_without_pgp_header() {
889 let fs = DiskFileSystem;
890 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
891 writeln!(file, "This is just a plain text file").unwrap();
892 writeln!(file, "with .asc extension but no PGP content").unwrap();
893 file.flush().unwrap();
894
895 assert!(!fs.is_encrypted(file.path()));
896 }
897
898 #[test]
899 fn test_decrypt_gpg_file_missing_gpg() {
900 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
902 writeln!(file, "fake encrypted content").unwrap();
903 file.flush().unwrap();
904
905 let result = decrypt_gpg_file(file.path());
908 assert!(result.is_err());
909
910 if let Err(LoadError::Decryption { path, message }) = result {
911 assert_eq!(path, file.path().to_path_buf());
912 assert!(!message.is_empty());
913 } else {
914 panic!("Expected Decryption error");
915 }
916 }
917
918 #[test]
919 fn test_plugin_force_python_prefix() {
920 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
921 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
922 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
923 file.flush().unwrap();
924
925 let result = Loader::new().load(file.path()).unwrap();
926
927 assert_eq!(result.plugins.len(), 2);
928
929 assert_eq!(result.plugins[0].name, "my_plugin");
931 assert!(result.plugins[0].force_python);
932
933 assert_eq!(result.plugins[1].name, "regular_plugin");
935 assert!(!result.plugins[1].force_python);
936 }
937
938 #[test]
939 fn test_plugin_force_python_with_config() {
940 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
941 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
942 file.flush().unwrap();
943
944 let result = Loader::new().load(file.path()).unwrap();
945
946 assert_eq!(result.plugins.len(), 1);
947 assert_eq!(result.plugins[0].name, "my_plugin");
948 assert!(result.plugins[0].force_python);
949 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
950 }
951
952 #[test]
953 fn test_virtual_filesystem_include_resolution() {
954 let mut vfs = VirtualFileSystem::new();
956 vfs.add_file(
957 "main.beancount",
958 r#"
959include "accounts.beancount"
960
9612024-01-15 * "Coffee"
962 Expenses:Food 5.00 USD
963 Assets:Bank -5.00 USD
964"#,
965 );
966 vfs.add_file(
967 "accounts.beancount",
968 r"
9692024-01-01 open Assets:Bank USD
9702024-01-01 open Expenses:Food USD
971",
972 );
973
974 let result = Loader::new()
976 .with_filesystem(Box::new(vfs))
977 .load(Path::new("main.beancount"))
978 .unwrap();
979
980 assert_eq!(result.directives.len(), 3);
982 assert!(result.errors.is_empty());
983
984 let directive_types: Vec<_> = result
986 .directives
987 .iter()
988 .map(|d| match &d.value {
989 rustledger_core::Directive::Open(_) => "open",
990 rustledger_core::Directive::Transaction(_) => "txn",
991 _ => "other",
992 })
993 .collect();
994 assert_eq!(directive_types, vec!["open", "open", "txn"]);
995 }
996
997 #[test]
998 fn test_virtual_filesystem_nested_includes() {
999 let mut vfs = VirtualFileSystem::new();
1001 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
1002 vfs.add_file(
1003 "level1.beancount",
1004 r#"
1005include "level2.beancount"
10062024-01-01 open Assets:Level1 USD
1007"#,
1008 );
1009 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
1010
1011 let result = Loader::new()
1012 .with_filesystem(Box::new(vfs))
1013 .load(Path::new("main.beancount"))
1014 .unwrap();
1015
1016 assert_eq!(result.directives.len(), 2);
1018 assert!(result.errors.is_empty());
1019 }
1020
1021 #[test]
1022 fn test_virtual_filesystem_missing_include() {
1023 let mut vfs = VirtualFileSystem::new();
1024 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
1025
1026 let result = Loader::new()
1027 .with_filesystem(Box::new(vfs))
1028 .load(Path::new("main.beancount"))
1029 .unwrap();
1030
1031 assert!(!result.errors.is_empty());
1033 let error_msg = result.errors[0].to_string();
1034 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
1035 }
1036
1037 #[test]
1038 fn test_virtual_filesystem_glob_include() {
1039 let mut vfs = VirtualFileSystem::new();
1040 vfs.add_file(
1041 "main.beancount",
1042 r#"
1043include "transactions/*.beancount"
1044
10452024-01-01 open Assets:Bank USD
1046"#,
1047 );
1048 vfs.add_file(
1049 "transactions/2024.beancount",
1050 r#"
10512024-01-01 open Expenses:Food USD
1052
10532024-06-15 * "Groceries"
1054 Expenses:Food 50.00 USD
1055 Assets:Bank -50.00 USD
1056"#,
1057 );
1058 vfs.add_file(
1059 "transactions/2025.beancount",
1060 r#"
10612025-01-01 open Expenses:Rent USD
1062
10632025-02-01 * "Rent"
1064 Expenses:Rent 1000.00 USD
1065 Assets:Bank -1000.00 USD
1066"#,
1067 );
1068 vfs.add_file(
1070 "other/ignored.beancount",
1071 "2024-01-01 open Expenses:Other USD",
1072 );
1073
1074 let result = Loader::new()
1075 .with_filesystem(Box::new(vfs))
1076 .load(Path::new("main.beancount"))
1077 .unwrap();
1078
1079 let opens = result
1081 .directives
1082 .iter()
1083 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1084 .count();
1085 assert_eq!(
1086 opens, 3,
1087 "expected 3 open directives (1 main + 2 transactions)"
1088 );
1089
1090 let txns = result
1091 .directives
1092 .iter()
1093 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1094 .count();
1095 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
1096
1097 assert!(
1098 result.errors.is_empty(),
1099 "expected no errors, got: {:?}",
1100 result.errors
1101 );
1102 }
1103
1104 #[test]
1105 fn test_virtual_filesystem_glob_dot_slash_prefix() {
1106 let mut vfs = VirtualFileSystem::new();
1107 vfs.add_file(
1108 "main.beancount",
1109 r#"
1110include "./transactions/*.beancount"
1111
11122024-01-01 open Assets:Bank USD
1113"#,
1114 );
1115 vfs.add_file(
1116 "transactions/2024.beancount",
1117 r#"
11182024-01-01 open Expenses:Food USD
1119
11202024-06-15 * "Groceries"
1121 Expenses:Food 50.00 USD
1122 Assets:Bank -50.00 USD
1123"#,
1124 );
1125 vfs.add_file(
1126 "transactions/2025.beancount",
1127 r#"
11282025-01-01 open Expenses:Rent USD
1129
11302025-02-01 * "Rent"
1131 Expenses:Rent 1000.00 USD
1132 Assets:Bank -1000.00 USD
1133"#,
1134 );
1135
1136 let result = Loader::new()
1137 .with_filesystem(Box::new(vfs))
1138 .load(Path::new("main.beancount"))
1139 .unwrap();
1140
1141 let opens = result
1143 .directives
1144 .iter()
1145 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1146 .count();
1147 assert_eq!(
1148 opens, 3,
1149 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1150 );
1151
1152 let txns = result
1153 .directives
1154 .iter()
1155 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1156 .count();
1157 assert_eq!(
1158 txns, 2,
1159 "expected 2 transactions from glob-matched files despite ./ prefix"
1160 );
1161
1162 assert!(
1163 result.errors.is_empty(),
1164 "expected no errors, got: {:?}",
1165 result.errors
1166 );
1167 }
1168
1169 #[test]
1170 fn test_virtual_filesystem_glob_no_match() {
1171 let mut vfs = VirtualFileSystem::new();
1172 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1173
1174 let result = Loader::new()
1175 .with_filesystem(Box::new(vfs))
1176 .load(Path::new("main.beancount"))
1177 .unwrap();
1178
1179 let has_glob_error = result
1181 .errors
1182 .iter()
1183 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1184 assert!(
1185 has_glob_error,
1186 "expected GlobNoMatch error, got: {:?}",
1187 result.errors
1188 );
1189 }
1190
1191 #[test]
1198 fn test_vfs_glob_include_within_root_not_flagged_as_traversal() {
1199 let mut vfs = VirtualFileSystem::new();
1200 vfs.add_file("ledger/main.beancount", r#"include "sub/*.beancount""#);
1201 vfs.add_file(
1202 "ledger/sub/a.beancount",
1203 "2024-01-01 open Assets:Cash USD\n",
1204 );
1205
1206 let result = Loader::new()
1207 .with_filesystem(Box::new(vfs))
1208 .with_root_dir(PathBuf::from("ledger")) .load(Path::new("ledger/main.beancount"))
1210 .unwrap();
1211
1212 assert!(
1213 !result
1214 .errors
1215 .iter()
1216 .any(|e| matches!(e, LoadError::PathTraversal { .. })),
1217 "legit in-root VFS glob include wrongly flagged as traversal: {:?}",
1218 result.errors
1219 );
1220 assert!(
1222 !result.directives.is_empty(),
1223 "the in-root include should have loaded; errors: {:?}",
1224 result.errors
1225 );
1226 }
1227
1228 #[test]
1234 fn test_fresh_parse_deduplicates_internedstr_across_files() {
1235 let mut vfs = VirtualFileSystem::new();
1236 vfs.add_file(
1237 "main.beancount",
1238 r#"
12392024-01-01 open Assets:Bank USD
1240include "transactions.beancount"
1241"#,
1242 );
1243 vfs.add_file(
1244 "transactions.beancount",
1245 r#"
12462024-01-15 * "Coffee"
1247 Assets:Bank -5.00 USD
1248 Expenses:Coffee 5.00 USD
1249
12502024-01-16 open Expenses:Coffee
1251"#,
1252 );
1253
1254 let result = Loader::new()
1255 .with_filesystem(Box::new(vfs))
1256 .load(Path::new("main.beancount"))
1257 .unwrap();
1258
1259 let bank_accounts: Vec<&rustledger_core::Account> = result
1263 .directives
1264 .iter()
1265 .filter_map(|s| match &s.value {
1266 rustledger_core::Directive::Open(o) if o.account.as_str() == "Assets:Bank" => {
1267 Some(&o.account)
1268 }
1269 rustledger_core::Directive::Transaction(t) => t
1270 .postings
1271 .iter()
1272 .find(|p| p.account.as_str() == "Assets:Bank")
1273 .map(|p| &p.account),
1274 _ => None,
1275 })
1276 .collect();
1277
1278 assert_eq!(
1279 bank_accounts.len(),
1280 2,
1281 "expected one Open and one posting for Assets:Bank"
1282 );
1283 assert!(
1284 bank_accounts[0]
1285 .as_interned()
1286 .ptr_eq(bank_accounts[1].as_interned()),
1287 "Assets:Bank from cross-file open/posting must share the same Arc<str> \
1288 after Loader::load runs reintern_directives"
1289 );
1290 }
1291
1292 #[test]
1299 fn test_fresh_parse_deduplicates_transaction_fields_across_files() {
1300 let mut vfs = VirtualFileSystem::new();
1301 vfs.add_file(
1302 "main.beancount",
1303 r#"
13042024-01-01 open Assets:Bank USD
13052024-01-01 open Expenses:Coffee
1306
13072024-01-15 * "Cafe Bench" "Latte" #morning
1308 Assets:Bank -5.00 USD
1309 Expenses:Coffee 5.00 USD
1310
1311include "more.beancount"
1312"#,
1313 );
1314 vfs.add_file(
1315 "more.beancount",
1316 r#"
13172024-01-16 * "Cafe Bench" "Espresso" #morning
1318 Assets:Bank -3.00 USD
1319 Expenses:Coffee 3.00 USD
1320"#,
1321 );
1322
1323 let result = Loader::new()
1324 .with_filesystem(Box::new(vfs))
1325 .load(Path::new("main.beancount"))
1326 .unwrap();
1327
1328 let txns: Vec<&rustledger_core::Transaction> = result
1329 .directives
1330 .iter()
1331 .filter_map(|s| match &s.value {
1332 rustledger_core::Directive::Transaction(t) => Some(t),
1333 _ => None,
1334 })
1335 .collect();
1336
1337 assert_eq!(txns.len(), 2, "expected the two transactions");
1338 let p1 = txns[0].payee.as_ref().expect("first txn has payee");
1339 let p2 = txns[1].payee.as_ref().expect("second txn has payee");
1340 assert!(
1341 p1.ptr_eq(p2),
1342 "Identical payee \"Cafe Bench\" across files must share one Arc<str>"
1343 );
1344
1345 assert!(!txns[0].tags.is_empty() && !txns[1].tags.is_empty());
1346 assert!(
1347 txns[0].tags[0].ptr_eq(&txns[1].tags[0]),
1348 "Identical tag #morning across files must share one Arc<str>"
1349 );
1350 }
1351
1352 #[test]
1364 fn test_fresh_parse_deduplicates_metavalue_across_files() {
1365 use rustledger_core::MetaValue;
1366
1367 let mut vfs = VirtualFileSystem::new();
1368 vfs.add_file(
1369 "main.beancount",
1370 r#"
13712024-01-01 open Assets:Bank USD
13722024-01-01 open Expenses:Coffee
1373
13742024-01-15 * "Latte"
1375 counterparty_account: Assets:Bank
1376 preferred_currency: USD
1377 category_tag: #coffee
1378 receipt_link: ^receipt-2024
1379 fee_amount: 0.50 USD
1380 Assets:Bank -5.00 USD
1381 settled_with: Assets:Bank
1382 Expenses:Coffee 5.00 USD
1383
1384include "more.beancount"
1385"#,
1386 );
1387 vfs.add_file(
1388 "more.beancount",
1389 r#"
13902024-01-16 * "Espresso"
1391 counterparty_account: Assets:Bank
1392 preferred_currency: USD
1393 category_tag: #coffee
1394 receipt_link: ^receipt-2024
1395 fee_amount: 0.50 USD
1396 Assets:Bank -3.00 USD
1397 settled_with: Assets:Bank
1398 Expenses:Coffee 3.00 USD
1399"#,
1400 );
1401
1402 let result = Loader::new()
1403 .with_filesystem(Box::new(vfs))
1404 .load(Path::new("main.beancount"))
1405 .unwrap();
1406
1407 let txns: Vec<&rustledger_core::Transaction> = result
1408 .directives
1409 .iter()
1410 .filter_map(|s| match &s.value {
1411 rustledger_core::Directive::Transaction(t) => Some(t),
1412 _ => None,
1413 })
1414 .collect();
1415 assert_eq!(txns.len(), 2);
1416
1417 let MetaValue::Account(a1) = &txns[0].meta["counterparty_account"] else {
1420 panic!("expected MetaValue::Account");
1421 };
1422 let MetaValue::Account(a2) = &txns[1].meta["counterparty_account"] else {
1423 panic!("expected MetaValue::Account");
1424 };
1425 assert!(
1426 a1.ptr_eq(a2),
1427 "MetaValue::Account in cross-file meta must share Arc<str>"
1428 );
1429
1430 let MetaValue::Currency(c1) = &txns[0].meta["preferred_currency"] else {
1431 panic!("expected MetaValue::Currency");
1432 };
1433 let MetaValue::Currency(c2) = &txns[1].meta["preferred_currency"] else {
1434 panic!("expected MetaValue::Currency");
1435 };
1436 assert!(
1437 c1.ptr_eq(c2),
1438 "MetaValue::Currency in cross-file meta must share Arc<str>"
1439 );
1440
1441 let MetaValue::Tag(t1) = &txns[0].meta["category_tag"] else {
1442 panic!("expected MetaValue::Tag");
1443 };
1444 let MetaValue::Tag(t2) = &txns[1].meta["category_tag"] else {
1445 panic!("expected MetaValue::Tag");
1446 };
1447 assert!(
1448 t1.ptr_eq(t2),
1449 "MetaValue::Tag in cross-file meta must share Arc<str>"
1450 );
1451
1452 let MetaValue::Link(l1) = &txns[0].meta["receipt_link"] else {
1453 panic!("expected MetaValue::Link");
1454 };
1455 let MetaValue::Link(l2) = &txns[1].meta["receipt_link"] else {
1456 panic!("expected MetaValue::Link");
1457 };
1458 assert!(
1459 l1.ptr_eq(l2),
1460 "MetaValue::Link in cross-file meta must share Arc<str>"
1461 );
1462
1463 let MetaValue::Amount(am1) = &txns[0].meta["fee_amount"] else {
1464 panic!("expected MetaValue::Amount");
1465 };
1466 let MetaValue::Amount(am2) = &txns[1].meta["fee_amount"] else {
1467 panic!("expected MetaValue::Amount");
1468 };
1469 assert!(
1470 am1.currency.ptr_eq(&am2.currency),
1471 "MetaValue::Amount.currency in cross-file meta must share Arc<str>"
1472 );
1473
1474 let first_posting_0 = &txns[0].postings[0].value;
1477 let first_posting_1 = &txns[1].postings[0].value;
1478 let MetaValue::Account(p1) = &first_posting_0.meta["settled_with"] else {
1479 panic!("expected MetaValue::Account in posting meta");
1480 };
1481 let MetaValue::Account(p2) = &first_posting_1.meta["settled_with"] else {
1482 panic!("expected MetaValue::Account in posting meta");
1483 };
1484 assert!(
1485 p1.ptr_eq(p2),
1486 "Posting-level MetaValue::Account in cross-file meta must share Arc<str> \
1487 (verifies the per-posting `intern_meta` call, not just the directive-level one)"
1488 );
1489 }
1490}