rustledger_loader/lib.rs
1//! Beancount file loader with include resolution.
2//!
3//! This crate handles loading beancount files, resolving includes,
4//! and collecting options. It builds on the parser to provide a
5//! complete loading pipeline.
6//!
7//! # Features
8//!
9//! - Recursive include resolution with cycle detection
10//! - Options collection and parsing
11//! - Plugin directive collection
12//! - Source map for error reporting
13//! - Push/pop tag and metadata handling
14//! - Automatic GPG decryption for encrypted files (`.gpg`, `.asc`)
15//!
16//! # Example
17//!
18//! ```ignore
19//! use rustledger_loader::Loader;
20//! use std::path::Path;
21//!
22//! let result = Loader::new().load(Path::new("ledger.beancount"))?;
23//! for directive in result.directives {
24//! println!("{:?}", directive);
25//! }
26//! ```
27
28#![forbid(unsafe_code)]
29#![warn(missing_docs)]
30// Never-panic surface: the loader resolves attacker-controlled `include` paths
31// and parser output, so production code must not `unwrap`/`expect`. `not(test)`
32// scopes the deny to non-test builds, so this crate's own `#[cfg(test)]` /
33// `#[test]` code (incl. `#[cfg(all(test, feature = ...))]` modules) is exempt — it
34// compiles with `cfg(test)`. (Integration tests under `tests/` are separate crates
35// and aren't governed by this attribute either way.) Proven-safe production sites
36// carry an audited `#[allow]` with a justification.
37#![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used))]
38
39#[cfg(feature = "cache")]
40pub mod cache;
41mod dedup;
42pub mod discover;
43mod options;
44mod phase;
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// Note: `FailedBookings` is NOT re-exported. It's internal to the
54// pipeline (flowing from `book` to `finalize`) and accessed via the
55// `crate::phase::FailedBookings` path within the crate.
56
57#[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 discover::{COMMON_ROOT_NAMES, discover_journal_file, discover_journal_upward};
65pub use options::Options;
66pub use source_map::{SourceFile, SourceMap};
67pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
68
69// Re-export processing API when features are enabled
70/// Shared option→validation mapping and document-dir resolution — single source
71/// of truth so the LSP/MCP diagnostics cannot drift from `check` (issue #1648).
72#[cfg(feature = "validation")]
73pub use process::{document_source_dirs, validation_options_from_options};
74
75/// Resolve `documents` roots against the ledger's directory.
76///
77/// Ungated: the loader itself calls these to raise E7006, and it needs to do so
78/// whether or not `validation` is on.
79pub use process::{document_root_warnings, resolve_document_dirs};
80
81/// Whether an `include`/glob path contains glob metacharacters (`*`, `?`, `[`).
82///
83/// Single source of truth shared with the LSP's document-link resolver so the
84/// two agree on what counts as a glob — a literal-path existence check on a glob
85/// wrongly reports "File not found" (issue #1647).
86#[must_use]
87pub fn is_glob_pattern(path: &str) -> bool {
88 path.contains(['*', '?', '['])
89}
90pub use process::{
91 ErrorLocation, ErrorSeverity, ExtraPlugin, Ledger, LedgerError, LoadOptions, ProcessError,
92 load, load_raw, load_with_fs, process,
93};
94#[cfg(feature = "plugins")]
95pub use process::{PluginPass, run_plugins};
96
97use rustledger_core::{Directive, DisplayContext};
98use rustledger_parser::{ParseError, Span, Spanned};
99use std::collections::HashSet;
100use std::path::{Path, PathBuf};
101use std::process::Command;
102use thiserror::Error;
103
104// Path normalization lives in a single place: `FileSystem::normalize`
105// (`DiskFileSystem::normalize` is the disk implementation, `VirtualFileSystem`
106// the in-memory one). The free `normalize_path` that used to duplicate the disk
107// body was removed — all callers go through the injected filesystem so the
108// include path-traversal guard normalizes consistently in one namespace.
109
110/// Errors that can occur during loading.
111#[derive(Debug, Error)]
112pub enum LoadError {
113 /// IO error reading a file.
114 #[error("failed to read file {path}: {source}")]
115 Io {
116 /// The path that failed to read.
117 path: PathBuf,
118 /// The underlying IO error.
119 #[source]
120 source: std::io::Error,
121 },
122
123 /// Include cycle detected.
124 ///
125 /// The Display string intentionally begins with `Duplicate filename
126 /// parsed:` to match Python beancount's wording for the same
127 /// condition. The pta-standards `include-cycle-detection`
128 /// conformance test asserts on the substring `"Duplicate filename"`,
129 /// so this wording is load-bearing (#765). The full cycle path is
130 /// preserved in a trailing parenthetical for debuggability.
131 #[error(
132 "Duplicate filename parsed: \"{}\" (include cycle: {})",
133 .cycle.last().map_or("", String::as_str),
134 .cycle.join(" -> ")
135 )]
136 IncludeCycle {
137 /// The cycle of file paths. The last element is the
138 /// re-encountered filename (equal to one of the earlier
139 /// entries), and it's the one quoted in the `"Duplicate
140 /// filename parsed:"` prefix.
141 cycle: Vec<String>,
142 },
143
144 /// The same file was reached twice in the include graph without forming a
145 /// cycle — a "diamond", e.g. a shared prices file included from two
146 /// monthly journals.
147 ///
148 /// The file's directives are loaded ONCE, as beancount does; this records
149 /// that the duplicate happened. The wording matches beancount's for the
150 /// same condition, and deliberately omits the `(include cycle: …)`
151 /// parenthetical of [`LoadError::IncludeCycle`], which this is not.
152 ///
153 /// Previously the second encounter returned `Ok(())` in silence, so a
154 /// ledger beancount rejects with `Duplicate filename parsed` loaded here
155 /// without a word (#2084 follow-up).
156 #[error("Duplicate filename parsed: \"{path}\"")]
157 DuplicateInclude {
158 /// The path reached a second time.
159 path: String,
160 },
161
162 /// Parse errors occurred.
163 #[error("parse errors in {path}")]
164 ParseErrors {
165 /// The file with parse errors.
166 path: PathBuf,
167 /// The parse errors.
168 errors: Vec<ParseError>,
169 },
170
171 /// Path traversal attempt detected.
172 #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
173 PathTraversal {
174 /// The include path that attempted traversal.
175 include_path: String,
176 /// The base directory.
177 base_dir: PathBuf,
178 },
179
180 /// GPG decryption failed.
181 #[error("failed to decrypt {path}: {message}")]
182 Decryption {
183 /// The encrypted file path.
184 path: PathBuf,
185 /// Error message from GPG.
186 message: String,
187 },
188
189 /// Glob pattern did not match any files.
190 #[error("include pattern \"{pattern}\" does not match any files")]
191 GlobNoMatch {
192 /// The glob pattern that matched nothing.
193 pattern: String,
194 },
195
196 /// Glob pattern expansion failed.
197 #[error("failed to expand include pattern \"{pattern}\": {message}")]
198 GlobError {
199 /// The glob pattern that failed.
200 pattern: String,
201 /// The error message.
202 message: String,
203 },
204
205 /// More files were referenced than the 16-bit file-id space allows.
206 ///
207 /// File ids are `u16` on every `Spanned` value, so a ledger may reference at
208 /// most `u16::MAX` files via includes/globs. Reported as an error rather
209 /// than panicking, since `load` is a never-panic-on-input surface.
210 #[error("too many files: a ledger may reference at most {limit} files (16-bit file ids)")]
211 TooManyFiles {
212 /// The maximum number of files supported.
213 limit: usize,
214 },
215}
216
217/// Convert a 0-based file index to the `u16` file id stored on `Spanned`
218/// values, returning [`LoadError::TooManyFiles`] instead of panicking when a
219/// ledger references more files than the id space allows.
220///
221/// `SYNTHESIZED_FILE_ID` (`u16::MAX`) is reserved as the plugin-synthesized
222/// sentinel, so a real file id must be strictly below it — we reject `>=` it,
223/// not merely `> u16::MAX`, otherwise the 65,535th file would alias onto the
224/// sentinel. (This is also the boundary `SourceMap::add_file` asserts, so the
225/// loader must check it *before* calling `add_file`.)
226const fn file_id_to_u16(file_id: usize) -> Result<u16, LoadError> {
227 if file_id >= rustledger_parser::SYNTHESIZED_FILE_ID as usize {
228 return Err(LoadError::TooManyFiles {
229 limit: rustledger_parser::SYNTHESIZED_FILE_ID as usize,
230 });
231 }
232 Ok(file_id as u16)
233}
234
235/// Result of loading a beancount file.
236#[derive(Debug)]
237pub struct LoadResult {
238 /// All directives from all files, in order.
239 pub directives: Vec<Spanned<Directive>>,
240 /// Parsed options.
241 pub options: Options,
242 /// Plugins to load.
243 pub plugins: Vec<Plugin>,
244 /// Source map for error reporting.
245 pub source_map: SourceMap,
246 /// All errors encountered during loading.
247 pub errors: Vec<LoadError>,
248 /// Display context for formatting numbers (tracks precision per currency).
249 pub display_context: DisplayContext,
250}
251
252/// A plugin directive.
253#[derive(Debug, Clone)]
254pub struct Plugin {
255 /// Plugin module name (with any `python:` prefix stripped).
256 pub name: String,
257 /// Optional configuration string.
258 pub config: Option<String>,
259 /// Source location.
260 pub span: Span,
261 /// File this plugin was declared in.
262 pub file_id: usize,
263 /// Whether the `python:` prefix was used to force Python execution.
264 pub force_python: bool,
265}
266
267/// Decrypt a GPG-encrypted file using the system `gpg` command.
268///
269/// This uses `gpg --batch --decrypt` which will use the user's
270/// GPG keyring and gpg-agent for passphrase handling.
271pub(crate) fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
272 let output = Command::new("gpg")
273 .args(["--batch", "--decrypt"])
274 .arg(path)
275 .output()
276 .map_err(|e| LoadError::Decryption {
277 path: path.to_path_buf(),
278 message: format!("failed to run gpg: {e}"),
279 })?;
280
281 if !output.status.success() {
282 return Err(LoadError::Decryption {
283 path: path.to_path_buf(),
284 message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
285 });
286 }
287
288 String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
289 path: path.to_path_buf(),
290 message: format!("decrypted content is not valid UTF-8: {e}"),
291 })
292}
293
294/// Beancount file loader.
295#[derive(Debug)]
296pub struct Loader {
297 /// Files that have been loaded (for cycle detection).
298 loaded_files: HashSet<PathBuf>,
299 /// Stack for cycle detection during loading (maintains order for error messages).
300 include_stack: Vec<PathBuf>,
301 /// Set for O(1) cycle detection (mirrors `include_stack`).
302 include_stack_set: HashSet<PathBuf>,
303 /// Root directory for path traversal protection.
304 /// If set, includes must resolve to paths within this directory.
305 root_dir: Option<PathBuf>,
306 /// Whether to enforce path traversal protection.
307 enforce_path_security: bool,
308 /// Filesystem abstraction for reading files.
309 fs: Box<dyn FileSystem>,
310}
311
312impl Default for Loader {
313 fn default() -> Self {
314 Self {
315 loaded_files: HashSet::new(),
316 include_stack: Vec::new(),
317 include_stack_set: HashSet::new(),
318 root_dir: None,
319 enforce_path_security: false,
320 fs: Box::new(DiskFileSystem),
321 }
322 }
323}
324
325impl Loader {
326 /// Create a new loader.
327 #[must_use]
328 pub fn new() -> Self {
329 Self::default()
330 }
331
332 /// Enable path traversal protection.
333 ///
334 /// When enabled, include directives cannot escape the root directory
335 /// of the main beancount file. This prevents malicious ledger files
336 /// from accessing sensitive files outside the ledger directory.
337 ///
338 /// # Example
339 ///
340 /// ```ignore
341 /// let result = Loader::new()
342 /// .with_path_security(true)
343 /// .load(Path::new("ledger.beancount"))?;
344 /// ```
345 #[must_use]
346 pub const fn with_path_security(mut self, enabled: bool) -> Self {
347 self.enforce_path_security = enabled;
348 self
349 }
350
351 /// Set a custom root directory for path security.
352 ///
353 /// By default, the root directory is the parent directory of the main file.
354 /// This method allows overriding that to a custom directory.
355 #[must_use]
356 pub fn with_root_dir(mut self, root: PathBuf) -> Self {
357 self.root_dir = Some(root);
358 self.enforce_path_security = true;
359 self
360 }
361
362 /// Set a custom filesystem for file loading.
363 ///
364 /// This allows using a virtual filesystem (e.g., for WASM) instead of
365 /// the default disk filesystem.
366 ///
367 /// # Example
368 ///
369 /// ```
370 /// use rustledger_loader::{Loader, VirtualFileSystem};
371 ///
372 /// let mut vfs = VirtualFileSystem::new();
373 /// vfs.add_file("main.beancount", "2024-01-01 open Assets:Bank USD");
374 ///
375 /// let loader = Loader::new().with_filesystem(Box::new(vfs));
376 /// ```
377 #[must_use]
378 pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
379 self.fs = fs;
380 self
381 }
382
383 /// Load a beancount file and all its includes.
384 ///
385 /// Uses parallel file parsing when multiple files are discovered via
386 /// include directives. The root file is parsed first to resolve the
387 /// include tree, then all included files are read and parsed in
388 /// parallel using rayon.
389 ///
390 /// # Errors
391 ///
392 /// Returns [`LoadError`] in the following cases:
393 ///
394 /// - [`LoadError::Io`] - Failed to read the file or an included file
395 /// - [`LoadError::IncludeCycle`] - Circular include detected
396 ///
397 /// Note: Parse errors and path traversal errors are collected in
398 /// [`LoadResult::errors`] rather than returned directly, allowing
399 /// partial results to be returned.
400 pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
401 let mut directives = Vec::new();
402 let mut options = Options::default();
403 let mut plugins = Vec::new();
404 let mut source_map = SourceMap::new();
405 let mut errors = Vec::new();
406
407 // Get normalized path (uses filesystem-specific normalization)
408 let canonical = self.fs.normalize(path);
409
410 // Set root directory for path security if enabled but not explicitly set
411 if self.enforce_path_security && self.root_dir.is_none() {
412 self.root_dir = canonical.parent().map(Path::to_path_buf);
413 }
414 // Normalize the root through the SAME filesystem namespace as the paths
415 // it is compared against. An explicit `with_root_dir(...)` may be
416 // relative/un-normalized, and on disk `normalize` makes paths absolute —
417 // so without this, a relative root never `starts_with` a normalized path
418 // and every include would be falsely rejected as a traversal. (The
419 // default-derived root is already normalized, so re-normalizing is a
420 // no-op there.)
421 if let Some(root) = self.root_dir.take() {
422 self.root_dir = Some(self.fs.normalize(&root));
423 }
424
425 // Phase 1: Parse the root file to discover includes.
426 // The root file is typically small (just includes + options).
427 self.load_recursive(
428 &canonical,
429 None,
430 &mut directives,
431 &mut options,
432 &mut plugins,
433 &mut source_map,
434 &mut errors,
435 )?;
436
437 // Deduplicate every `InternedStr` reachable from a directive
438 // across files. Each file parses with its own per-file
439 // `StringInterner`, so identical strings — accounts,
440 // currencies, tags, links, payees, narrations — appearing in
441 // two included files land in two different `Arc<str>`
442 // allocations, defeating the `Arc::ptr_eq` fast path in
443 // `InternedStr`'s `PartialEq` and forcing all cross-file
444 // equality through byte comparison.
445 //
446 // This is the fresh-parse path, and it is the only one that needs
447 // the walk. A cache hit has no per-file split to repair: it
448 // deserializes one archive under an `InternScope` (see
449 // `cache::load_cache_entry`), which interns as it builds and so
450 // arrives at the same postcondition without a second pass. The
451 // WASM parsed-ledger constructor has no scope either and calls
452 // `reintern_plain_directives` for itself.
453 //
454 // Either way every consumer of `LoadResult` sees a deduplicated
455 // directive list regardless of how it was produced. Closes #1071.
456 dedup::reintern_directives(&mut directives);
457
458 // Build display context from directives and options
459 let display_context = build_display_context(&directives, &options);
460
461 // E7006: `option "documents"` roots that do not exist.
462 //
463 // This runs here rather than in option parsing because a relative
464 // `documents` path is relative to the LEDGER FILE — the same rule
465 // beancount and `include` use — and only the source map knows where
466 // that file is. Doing it during parsing meant `Path::new(value)`,
467 // which asked about the process CWD: `rledger check sub/ledger.bean`
468 // reported a missing document root that was present, and the same
469 // ledger checked clean from inside `sub/` (#1999).
470 let base_dir = source_map.files().first().and_then(|f| f.path.parent());
471 let doc_warnings =
472 process::document_root_warnings(&options.documents, base_dir, self.fs.as_ref());
473 options.warnings.extend(doc_warnings);
474
475 Ok(LoadResult {
476 directives,
477 options,
478 plugins,
479 source_map,
480 errors,
481 display_context,
482 })
483 }
484
485 #[allow(clippy::too_many_arguments)]
486 fn load_recursive(
487 &mut self,
488 path: &Path,
489 pre_parsed: Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>,
490 directives: &mut Vec<Spanned<Directive>>,
491 options: &mut Options,
492 plugins: &mut Vec<Plugin>,
493 source_map: &mut SourceMap,
494 errors: &mut Vec<LoadError>,
495 ) -> Result<(), LoadError> {
496 // Allocate path once for reuse
497 let path_buf = path.to_path_buf();
498
499 // Check for cycles using O(1) HashSet lookup
500 if self.include_stack_set.contains(&path_buf) {
501 // `collect::<Vec<_>>()` on a chain of two `ExactSizeIterator`s
502 // preallocates the exact capacity via `size_hint`, so an
503 // explicit `Vec::with_capacity(...)` + `extend` + `push` is
504 // equivalent and noisier. This is the cycle-error cold path
505 // anyway — readability wins over micro-optimization.
506 let cycle: Vec<String> = self
507 .include_stack
508 .iter()
509 .map(|p| p.display().to_string())
510 .chain(std::iter::once(path.display().to_string()))
511 .collect();
512 return Err(LoadError::IncludeCycle { cycle });
513 }
514
515 // Check if already loaded. Beancount reports this and carries on with
516 // the single copy it already has, and `bean-check` exits non-zero for
517 // it; record the same and do the same.
518 if self.loaded_files.contains(&path_buf) {
519 errors.push(LoadError::DuplicateInclude {
520 path: path.display().to_string(),
521 });
522 return Ok(());
523 }
524
525 // Use pre-parsed data if available (from parallel loading path),
526 // otherwise read and parse the file.
527 let (source, result) = if let Some(pre) = pre_parsed {
528 pre
529 } else {
530 let src: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
531 // Route decryption through the filesystem so a sandboxed fs (the
532 // WASI component) can delegate to a host capability (#1667); the
533 // default impl shells out to gpg.
534 self.fs.decrypt(path)?
535 } else {
536 self.fs.read(path)?
537 };
538 // The processing pipeline never reads currency/account occurrences
539 // (LSP-only); skip collecting them — see `parse_without_occurrences`.
540 let parsed = rustledger_parser::parse_without_occurrences(&src);
541 (src, parsed)
542 };
543
544 // Validate the prospective file id BEFORE `add_file` — `add_file`
545 // asserts `id < SYNTHESIZED_FILE_ID` and would panic, but `load` must
546 // never panic on input. The next id `add_file` assigns is the current
547 // file count; reject it here (collected into `LoadResult::errors` by the
548 // caller) so an over-large include/glob set fails loudly without a panic.
549 let fid_u16 = file_id_to_u16(source_map.files().len())?;
550 // Add to source map (Arc::clone is cheap - just increments refcount)
551 let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
552 debug_assert_eq!(file_id, fid_u16 as usize);
553
554 // Mark as loading (update both stack and set)
555 self.include_stack_set.insert(path_buf.clone());
556 self.include_stack.push(path_buf.clone());
557 self.loaded_files.insert(path_buf);
558
559 // Collect parse errors
560 if !result.errors.is_empty() {
561 errors.push(LoadError::ParseErrors {
562 path: path.to_path_buf(),
563 errors: result.errors,
564 });
565 }
566
567 // Process options.
568 //
569 // `include_stack` was pushed with this file just above, so a length of
570 // one means we are in the top-level ledger. Options outside
571 // `ACCUMULATE_ACROSS_INCLUDES` are taken from that file only: an
572 // included sub-ledger must not silently change how the whole tree
573 // books or what counts as balanced (#2151).
574 let top_level = self.include_stack.len() == 1;
575 for (key, value, _span) in result.options {
576 options.set_scoped(&key, &value, top_level);
577 }
578
579 // Process plugins
580 for (name, config, span) in result.plugins {
581 // Check for "python:" prefix to force Python execution
582 let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
583 (stripped.to_string(), true)
584 } else {
585 (name, false)
586 };
587 plugins.push(Plugin {
588 name: actual_name,
589 config,
590 span,
591 file_id,
592 force_python,
593 });
594 }
595
596 // Process includes (with glob pattern support)
597 let base_dir = path.parent().unwrap_or(Path::new("."));
598 for (include_path, _span) in &result.includes {
599 // Check if the include path contains glob metacharacters
600 // (check on include_path, not full_path, to avoid false positives from directory names)
601 let has_glob = is_glob_pattern(include_path);
602
603 let full_path = base_dir.join(include_path);
604
605 // Path traversal protection: check BEFORE glob expansion to avoid
606 // enumerating files outside the allowed root directory
607 if self.enforce_path_security
608 && let Some(ref root) = self.root_dir
609 {
610 // For glob patterns, extract and check the non-glob prefix
611 let path_to_check = if has_glob {
612 // Find where the first glob metacharacter is
613 let glob_start = include_path
614 .find(['*', '?', '['])
615 .unwrap_or(include_path.len());
616 // Get the directory prefix before the glob
617 let prefix = &include_path[..glob_start];
618 let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
619 base_dir.join(&include_path[..=last_sep])
620 } else {
621 base_dir.to_path_buf()
622 };
623 // Normalize via the injected filesystem (not a hardcoded
624 // disk path fn), so this pre-glob traversal guard and the
625 // per-matched-file guard below resolve in the SAME namespace
626 // — under a `VirtualFileSystem` the disk `normalize_path`
627 // would have compared a disk-canonicalized prefix against a
628 // pure-string root.
629 self.fs.normalize(&prefix_path)
630 } else {
631 self.fs.normalize(&full_path)
632 };
633
634 if !path_to_check.starts_with(root) {
635 errors.push(LoadError::PathTraversal {
636 include_path: include_path.clone(),
637 base_dir: root.clone(),
638 });
639 continue;
640 }
641 }
642
643 let full_path_str = full_path.to_string_lossy();
644
645 // Expand glob patterns or use literal path
646 let paths_to_load: Vec<PathBuf> = if has_glob {
647 match self.fs.glob(&full_path_str) {
648 Ok(matched) => matched,
649 Err(e) => {
650 errors.push(LoadError::GlobError {
651 pattern: include_path.clone(),
652 message: e,
653 });
654 continue;
655 }
656 }
657 } else {
658 vec![full_path.clone()]
659 };
660
661 // Check if glob matched nothing
662 if has_glob && paths_to_load.is_empty() {
663 errors.push(LoadError::GlobNoMatch {
664 pattern: include_path.clone(),
665 });
666 continue;
667 }
668
669 // Normalize and security-check all matched paths first.
670 let mut valid_paths = Vec::with_capacity(paths_to_load.len());
671 for matched_path in paths_to_load {
672 let canonical = self.fs.normalize(&matched_path);
673
674 // Security check: glob could match files outside root via symlinks
675 if self.enforce_path_security
676 && let Some(ref root) = self.root_dir
677 && !canonical.starts_with(root)
678 {
679 errors.push(LoadError::PathTraversal {
680 include_path: matched_path.to_string_lossy().into_owned(),
681 base_dir: root.clone(),
682 });
683 continue;
684 }
685
686 valid_paths.push(canonical);
687 }
688
689 // Parallel optimization: when loading multiple sibling includes
690 // from disk, read and parse them in parallel. The expensive work
691 // (I/O + tokenize + parse) runs on rayon's thread pool while the
692 // main thread coordinates the include tree walk.
693 //
694 // Each file is read and parsed independently. Results are then
695 // merged sequentially to preserve include order and process any
696 // nested includes via recursive calls.
697 if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
698 use rayon::prelude::*;
699
700 // Read + parse non-encrypted files in parallel, preserving
701 // original include order. Each entry becomes either
702 // Some((source, parsed)) for successful reads, or None for
703 // encrypted/failed files (which fall back to sequential).
704 //
705 // We keep the original index to merge results in order,
706 // ensuring option/directive precedence matches the declared
707 // include sequence.
708 let fs = &*self.fs;
709 let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
710 valid_paths
711 .par_iter()
712 .map(|p| {
713 // Skip encrypted files — they need sequential GPG decryption
714 if fs.is_encrypted(p) {
715 return None;
716 }
717 // Read through the FileSystem trait so all I/O goes
718 // through one code path (UTF-8 handling, error types, etc.)
719 let source = fs.read(p).ok()?;
720 // Occurrences are LSP-only; skip them on the load path.
721 let parsed = rustledger_parser::parse_without_occurrences(&source);
722 Some((source, parsed))
723 })
724 .collect();
725
726 // Merge in original include order. Files that were
727 // pre-parsed pass their data to load_recursive; files
728 // that weren't (encrypted or I/O error) are loaded
729 // sequentially as a fallback.
730 for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
731 if let Err(e) = self.load_recursive(
732 canonical, pre, directives, options, plugins, source_map, errors,
733 ) {
734 errors.push(e);
735 }
736 }
737 } else {
738 // Sequential fallback: single file or VFS.
739 for canonical in valid_paths {
740 if let Err(e) = self.load_recursive(
741 &canonical, None, directives, options, plugins, source_map, errors,
742 ) {
743 errors.push(e);
744 }
745 }
746 }
747 }
748
749 // Add directives from this file, setting the file_id on the outer
750 // Spanned<Directive> and on each inner Spanned<Posting> inside
751 // transactions. Postings inside an included file share that file's
752 // ID; this keeps inner spans consistent with their containing
753 // directive so consumers don't need to traverse parent pointers.
754 //
755 // file_id is `u16` everywhere (see `Spanned::file_id` rustdoc). `fid_u16`
756 // was validated above (before this file was added to the source map), so
757 // no overflow/panic is possible here.
758 directives.extend(result.directives.into_iter().map(|d| {
759 let mut d = d.with_file_id(file_id);
760 if let rustledger_core::Directive::Transaction(ref mut txn) = d.value {
761 for p in &mut txn.postings {
762 p.file_id = fid_u16;
763 }
764 }
765 d
766 }));
767
768 // Pop from stack and set
769 if let Some(popped) = self.include_stack.pop() {
770 self.include_stack_set.remove(&popped);
771 }
772
773 Ok(())
774 }
775}
776
777/// Build a display context from loaded directives and options.
778///
779/// Thin wrapper over the canonical builder
780/// [`DisplayContext::from_directives`] (moved to `rustledger-core` so the
781/// FFI component's `session.format` shares the exact sampling rules —
782/// #1766): amount-scan inference, then `option "display_precision"`
783/// overrides, then per-commodity `precision:` metadata. Only
784/// `render_commas` — presentation policy, not precision — is applied here.
785fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
786 DisplayContext::from_directives(
787 directives.iter().map(|s| &s.value),
788 options
789 .display_precision
790 .iter()
791 .map(|(c, p)| (c.as_str(), *p)),
792 options.render_commas,
793 )
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799 use std::io::Write;
800 use tempfile::NamedTempFile;
801
802 #[test]
803 fn file_id_to_u16_rejects_the_reserved_sentinel_not_panic() {
804 let sentinel = rustledger_parser::SYNTHESIZED_FILE_ID as usize;
805 // The last valid real id is one BELOW the reserved sentinel.
806 assert_eq!(file_id_to_u16(0).unwrap(), 0);
807 assert_eq!(
808 file_id_to_u16(sentinel - 1).unwrap(),
809 rustledger_parser::SYNTHESIZED_FILE_ID - 1
810 );
811 // The sentinel itself (and beyond) is rejected with a LoadError — NOT a
812 // panic, and NOT aliased onto SYNTHESIZED_FILE_ID. `load` is a
813 // never-panic-on-input surface; the old `expect`/`assert` would have
814 // aborted the whole embedder / wasm module at this boundary.
815 assert!(matches!(
816 file_id_to_u16(sentinel),
817 Err(LoadError::TooManyFiles { limit }) if limit == sentinel
818 ));
819 assert!(matches!(
820 file_id_to_u16(sentinel + 1),
821 Err(LoadError::TooManyFiles { .. })
822 ));
823 }
824
825 #[test]
826 fn test_is_encrypted_file_gpg_extension() {
827 let fs = DiskFileSystem;
828 let path = Path::new("test.beancount.gpg");
829 assert!(fs.is_encrypted(path));
830 }
831
832 #[test]
833 fn test_is_encrypted_file_plain_beancount() {
834 let fs = DiskFileSystem;
835 let path = Path::new("test.beancount");
836 assert!(!fs.is_encrypted(path));
837 }
838
839 #[test]
840 fn test_is_encrypted_file_asc_with_pgp_header() {
841 let fs = DiskFileSystem;
842 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
843 writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
844 writeln!(file, "some encrypted content").unwrap();
845 writeln!(file, "-----END PGP MESSAGE-----").unwrap();
846 file.flush().unwrap();
847
848 assert!(fs.is_encrypted(file.path()));
849 }
850
851 #[test]
852 fn test_is_encrypted_file_asc_without_pgp_header() {
853 let fs = DiskFileSystem;
854 let mut file = NamedTempFile::with_suffix(".asc").unwrap();
855 writeln!(file, "This is just a plain text file").unwrap();
856 writeln!(file, "with .asc extension but no PGP content").unwrap();
857 file.flush().unwrap();
858
859 assert!(!fs.is_encrypted(file.path()));
860 }
861
862 #[test]
863 fn test_decrypt_gpg_file_missing_gpg() {
864 // Create a fake .gpg file
865 let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
866 writeln!(file, "fake encrypted content").unwrap();
867 file.flush().unwrap();
868
869 // This will fail because the content isn't actually GPG-encrypted
870 // (or gpg isn't installed, or there's no matching key)
871 let result = decrypt_gpg_file(file.path());
872 assert!(result.is_err());
873
874 if let Err(LoadError::Decryption { path, message }) = result {
875 assert_eq!(path, file.path().to_path_buf());
876 assert!(!message.is_empty());
877 } else {
878 panic!("Expected Decryption error");
879 }
880 }
881
882 #[test]
883 fn test_plugin_force_python_prefix() {
884 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
885 writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
886 writeln!(file, r#"plugin "regular_plugin""#).unwrap();
887 file.flush().unwrap();
888
889 let result = Loader::new().load(file.path()).unwrap();
890
891 assert_eq!(result.plugins.len(), 2);
892
893 // First plugin should have force_python = true and name without prefix
894 assert_eq!(result.plugins[0].name, "my_plugin");
895 assert!(result.plugins[0].force_python);
896
897 // Second plugin should have force_python = false
898 assert_eq!(result.plugins[1].name, "regular_plugin");
899 assert!(!result.plugins[1].force_python);
900 }
901
902 #[test]
903 fn test_plugin_force_python_with_config() {
904 let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
905 writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
906 file.flush().unwrap();
907
908 let result = Loader::new().load(file.path()).unwrap();
909
910 assert_eq!(result.plugins.len(), 1);
911 assert_eq!(result.plugins[0].name, "my_plugin");
912 assert!(result.plugins[0].force_python);
913 assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
914 }
915
916 #[test]
917 fn test_virtual_filesystem_include_resolution() {
918 // Create a virtual filesystem with multiple files
919 let mut vfs = VirtualFileSystem::new();
920 vfs.add_file(
921 "main.beancount",
922 r#"
923include "accounts.beancount"
924
9252024-01-15 * "Coffee"
926 Expenses:Food 5.00 USD
927 Assets:Bank -5.00 USD
928"#,
929 );
930 vfs.add_file(
931 "accounts.beancount",
932 r"
9332024-01-01 open Assets:Bank USD
9342024-01-01 open Expenses:Food USD
935",
936 );
937
938 // Load with virtual filesystem
939 let result = Loader::new()
940 .with_filesystem(Box::new(vfs))
941 .load(Path::new("main.beancount"))
942 .unwrap();
943
944 // Should have 3 directives: 2 opens + 1 transaction
945 assert_eq!(result.directives.len(), 3);
946 assert!(result.errors.is_empty());
947
948 // Verify directive types
949 let directive_types: Vec<_> = result
950 .directives
951 .iter()
952 .map(|d| match &d.value {
953 rustledger_core::Directive::Open(_) => "open",
954 rustledger_core::Directive::Transaction(_) => "txn",
955 _ => "other",
956 })
957 .collect();
958 assert_eq!(directive_types, vec!["open", "open", "txn"]);
959 }
960
961 #[test]
962 fn test_virtual_filesystem_nested_includes() {
963 // Test deeply nested includes
964 let mut vfs = VirtualFileSystem::new();
965 vfs.add_file("main.beancount", r#"include "level1.beancount""#);
966 vfs.add_file(
967 "level1.beancount",
968 r#"
969include "level2.beancount"
9702024-01-01 open Assets:Level1 USD
971"#,
972 );
973 vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
974
975 let result = Loader::new()
976 .with_filesystem(Box::new(vfs))
977 .load(Path::new("main.beancount"))
978 .unwrap();
979
980 // Should have 2 open directives from nested includes
981 assert_eq!(result.directives.len(), 2);
982 assert!(result.errors.is_empty());
983 }
984
985 #[test]
986 fn test_virtual_filesystem_missing_include() {
987 let mut vfs = VirtualFileSystem::new();
988 vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
989
990 let result = Loader::new()
991 .with_filesystem(Box::new(vfs))
992 .load(Path::new("main.beancount"))
993 .unwrap();
994
995 // Should have an error for missing file
996 assert!(!result.errors.is_empty());
997 let error_msg = result.errors[0].to_string();
998 assert!(error_msg.contains("not found") || error_msg.contains("Io"));
999 }
1000
1001 #[test]
1002 fn test_virtual_filesystem_glob_include() {
1003 let mut vfs = VirtualFileSystem::new();
1004 vfs.add_file(
1005 "main.beancount",
1006 r#"
1007include "transactions/*.beancount"
1008
10092024-01-01 open Assets:Bank USD
1010"#,
1011 );
1012 vfs.add_file(
1013 "transactions/2024.beancount",
1014 r#"
10152024-01-01 open Expenses:Food USD
1016
10172024-06-15 * "Groceries"
1018 Expenses:Food 50.00 USD
1019 Assets:Bank -50.00 USD
1020"#,
1021 );
1022 vfs.add_file(
1023 "transactions/2025.beancount",
1024 r#"
10252025-01-01 open Expenses:Rent USD
1026
10272025-02-01 * "Rent"
1028 Expenses:Rent 1000.00 USD
1029 Assets:Bank -1000.00 USD
1030"#,
1031 );
1032 // This file should NOT be matched by the glob
1033 vfs.add_file(
1034 "other/ignored.beancount",
1035 "2024-01-01 open Expenses:Other USD",
1036 );
1037
1038 let result = Loader::new()
1039 .with_filesystem(Box::new(vfs))
1040 .load(Path::new("main.beancount"))
1041 .unwrap();
1042
1043 // Should have: 1 open from main + 2 opens from transactions + 2 txns
1044 let opens = result
1045 .directives
1046 .iter()
1047 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1048 .count();
1049 assert_eq!(
1050 opens, 3,
1051 "expected 3 open directives (1 main + 2 transactions)"
1052 );
1053
1054 let txns = result
1055 .directives
1056 .iter()
1057 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1058 .count();
1059 assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
1060
1061 assert!(
1062 result.errors.is_empty(),
1063 "expected no errors, got: {:?}",
1064 result.errors
1065 );
1066 }
1067
1068 #[test]
1069 fn test_virtual_filesystem_glob_dot_slash_prefix() {
1070 let mut vfs = VirtualFileSystem::new();
1071 vfs.add_file(
1072 "main.beancount",
1073 r#"
1074include "./transactions/*.beancount"
1075
10762024-01-01 open Assets:Bank USD
1077"#,
1078 );
1079 vfs.add_file(
1080 "transactions/2024.beancount",
1081 r#"
10822024-01-01 open Expenses:Food USD
1083
10842024-06-15 * "Groceries"
1085 Expenses:Food 50.00 USD
1086 Assets:Bank -50.00 USD
1087"#,
1088 );
1089 vfs.add_file(
1090 "transactions/2025.beancount",
1091 r#"
10922025-01-01 open Expenses:Rent USD
1093
10942025-02-01 * "Rent"
1095 Expenses:Rent 1000.00 USD
1096 Assets:Bank -1000.00 USD
1097"#,
1098 );
1099
1100 let result = Loader::new()
1101 .with_filesystem(Box::new(vfs))
1102 .load(Path::new("main.beancount"))
1103 .unwrap();
1104
1105 // Should have: 1 open from main + 2 opens from transactions + 2 txns
1106 let opens = result
1107 .directives
1108 .iter()
1109 .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1110 .count();
1111 assert_eq!(
1112 opens, 3,
1113 "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1114 );
1115
1116 let txns = result
1117 .directives
1118 .iter()
1119 .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1120 .count();
1121 assert_eq!(
1122 txns, 2,
1123 "expected 2 transactions from glob-matched files despite ./ prefix"
1124 );
1125
1126 assert!(
1127 result.errors.is_empty(),
1128 "expected no errors, got: {:?}",
1129 result.errors
1130 );
1131 }
1132
1133 #[test]
1134 fn test_virtual_filesystem_glob_no_match() {
1135 let mut vfs = VirtualFileSystem::new();
1136 vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1137
1138 let result = Loader::new()
1139 .with_filesystem(Box::new(vfs))
1140 .load(Path::new("main.beancount"))
1141 .unwrap();
1142
1143 // Should have a GlobNoMatch error
1144 let has_glob_error = result
1145 .errors
1146 .iter()
1147 .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1148 assert!(
1149 has_glob_error,
1150 "expected GlobNoMatch error, got: {:?}",
1151 result.errors
1152 );
1153 }
1154
1155 /// Regression: with path security under a `VirtualFileSystem`, the pre-glob
1156 /// traversal guard used the disk `normalize_path` while the per-file guard
1157 /// used `self.fs.normalize` — different namespaces. So a LEGITIMATE glob
1158 /// include within the root got its prefix disk-normalized to a real-CWD path
1159 /// that didn't `starts_with` the VFS root, and was falsely rejected as a
1160 /// path traversal. Both guards now normalize through the injected filesystem.
1161 #[test]
1162 fn test_vfs_glob_include_within_root_not_flagged_as_traversal() {
1163 let mut vfs = VirtualFileSystem::new();
1164 vfs.add_file("ledger/main.beancount", r#"include "sub/*.beancount""#);
1165 vfs.add_file(
1166 "ledger/sub/a.beancount",
1167 "2024-01-01 open Assets:Cash USD\n",
1168 );
1169
1170 let result = Loader::new()
1171 .with_filesystem(Box::new(vfs))
1172 .with_root_dir(PathBuf::from("ledger")) // enables path security
1173 .load(Path::new("ledger/main.beancount"))
1174 .unwrap();
1175
1176 assert!(
1177 !result
1178 .errors
1179 .iter()
1180 .any(|e| matches!(e, LoadError::PathTraversal { .. })),
1181 "legit in-root VFS glob include wrongly flagged as traversal: {:?}",
1182 result.errors
1183 );
1184 // The included file actually loaded (its `open` is present).
1185 assert!(
1186 !result.directives.is_empty(),
1187 "the in-root include should have loaded; errors: {:?}",
1188 result.errors
1189 );
1190 }
1191
1192 /// Regression test for #1071: a fresh multi-file parse must produce
1193 /// deduplicated `InternedStr` values, so two `Posting`s referencing
1194 /// the same account from different files share one `Arc<str>`.
1195 /// Pre-fix the per-file `StringInterner` kept the two `Arc`s
1196 /// distinct and `Arc::ptr_eq` fell through to byte comparison.
1197 #[test]
1198 fn test_fresh_parse_deduplicates_internedstr_across_files() {
1199 let mut vfs = VirtualFileSystem::new();
1200 vfs.add_file(
1201 "main.beancount",
1202 r#"
12032024-01-01 open Assets:Bank USD
1204include "transactions.beancount"
1205"#,
1206 );
1207 vfs.add_file(
1208 "transactions.beancount",
1209 r#"
12102024-01-15 * "Coffee"
1211 Assets:Bank -5.00 USD
1212 Expenses:Coffee 5.00 USD
1213
12142024-01-16 open Expenses:Coffee
1215"#,
1216 );
1217
1218 let result = Loader::new()
1219 .with_filesystem(Box::new(vfs))
1220 .load(Path::new("main.beancount"))
1221 .unwrap();
1222
1223 // Collect every `Assets:Bank` `Account` (one from `open`, one
1224 // from the posting). They originate in different files, so
1225 // pre-fix they had distinct `Arc<str>` allocations.
1226 let bank_accounts: Vec<&rustledger_core::Account> = result
1227 .directives
1228 .iter()
1229 .filter_map(|s| match &s.value {
1230 rustledger_core::Directive::Open(o) if o.account.as_str() == "Assets:Bank" => {
1231 Some(&o.account)
1232 }
1233 rustledger_core::Directive::Transaction(t) => t
1234 .postings
1235 .iter()
1236 .find(|p| p.account.as_str() == "Assets:Bank")
1237 .map(|p| &p.account),
1238 _ => None,
1239 })
1240 .collect();
1241
1242 assert_eq!(
1243 bank_accounts.len(),
1244 2,
1245 "expected one Open and one posting for Assets:Bank"
1246 );
1247 assert!(
1248 bank_accounts[0]
1249 .as_interned()
1250 .ptr_eq(bank_accounts[1].as_interned()),
1251 "Assets:Bank from cross-file open/posting must share the same Arc<str> \
1252 after Loader::load runs reintern_directives"
1253 );
1254 }
1255
1256 /// Companion to the previous test — covers the Transaction-level
1257 /// `InternedStr` fields (payee, narration, tags, links) that the
1258 /// pre-Copilot version of `reintern_directive` silently skipped
1259 /// (Copilot review on PR #1081). Two transactions in different
1260 /// files share the same payee + tag; after `Loader::load` they
1261 /// must share one `Arc<str>` per string.
1262 #[test]
1263 fn test_fresh_parse_deduplicates_transaction_fields_across_files() {
1264 let mut vfs = VirtualFileSystem::new();
1265 vfs.add_file(
1266 "main.beancount",
1267 r#"
12682024-01-01 open Assets:Bank USD
12692024-01-01 open Expenses:Coffee
1270
12712024-01-15 * "Cafe Bench" "Latte" #morning
1272 Assets:Bank -5.00 USD
1273 Expenses:Coffee 5.00 USD
1274
1275include "more.beancount"
1276"#,
1277 );
1278 vfs.add_file(
1279 "more.beancount",
1280 r#"
12812024-01-16 * "Cafe Bench" "Espresso" #morning
1282 Assets:Bank -3.00 USD
1283 Expenses:Coffee 3.00 USD
1284"#,
1285 );
1286
1287 let result = Loader::new()
1288 .with_filesystem(Box::new(vfs))
1289 .load(Path::new("main.beancount"))
1290 .unwrap();
1291
1292 let txns: Vec<&rustledger_core::Transaction> = result
1293 .directives
1294 .iter()
1295 .filter_map(|s| match &s.value {
1296 rustledger_core::Directive::Transaction(t) => Some(t),
1297 _ => None,
1298 })
1299 .collect();
1300
1301 assert_eq!(txns.len(), 2, "expected the two transactions");
1302 let p1 = txns[0].payee.as_ref().expect("first txn has payee");
1303 let p2 = txns[1].payee.as_ref().expect("second txn has payee");
1304 assert!(
1305 p1.ptr_eq(p2),
1306 "Identical payee \"Cafe Bench\" across files must share one Arc<str>"
1307 );
1308
1309 assert!(!txns[0].tags.is_empty() && !txns[1].tags.is_empty());
1310 assert!(
1311 txns[0].tags[0].ptr_eq(&txns[1].tags[0]),
1312 "Identical tag #morning across files must share one Arc<str>"
1313 );
1314 }
1315
1316 /// Regression test responding to Copilot review on PR #1174: the
1317 /// dedup pass must walk every interned payload type inside
1318 /// `Metadata` maps — `MetaValue::{Account, Currency, Tag, Link,
1319 /// Amount.currency}` — at both the transaction level and the
1320 /// posting level. Before the meta walk was added, cross-file
1321 /// metadata values held distinct `Arc<str>` allocations even when
1322 /// they referenced identical strings.
1323 ///
1324 /// One multi-file fixture exercises all five variants in a single
1325 /// load to keep the test focused on the dedup invariant rather
1326 /// than the parse machinery.
1327 #[test]
1328 fn test_fresh_parse_deduplicates_metavalue_across_files() {
1329 use rustledger_core::MetaValue;
1330
1331 let mut vfs = VirtualFileSystem::new();
1332 vfs.add_file(
1333 "main.beancount",
1334 r#"
13352024-01-01 open Assets:Bank USD
13362024-01-01 open Expenses:Coffee
1337
13382024-01-15 * "Latte"
1339 counterparty_account: Assets:Bank
1340 preferred_currency: USD
1341 category_tag: #coffee
1342 receipt_link: ^receipt-2024
1343 fee_amount: 0.50 USD
1344 Assets:Bank -5.00 USD
1345 settled_with: Assets:Bank
1346 Expenses:Coffee 5.00 USD
1347
1348include "more.beancount"
1349"#,
1350 );
1351 vfs.add_file(
1352 "more.beancount",
1353 r#"
13542024-01-16 * "Espresso"
1355 counterparty_account: Assets:Bank
1356 preferred_currency: USD
1357 category_tag: #coffee
1358 receipt_link: ^receipt-2024
1359 fee_amount: 0.50 USD
1360 Assets:Bank -3.00 USD
1361 settled_with: Assets:Bank
1362 Expenses:Coffee 3.00 USD
1363"#,
1364 );
1365
1366 let result = Loader::new()
1367 .with_filesystem(Box::new(vfs))
1368 .load(Path::new("main.beancount"))
1369 .unwrap();
1370
1371 let txns: Vec<&rustledger_core::Transaction> = result
1372 .directives
1373 .iter()
1374 .filter_map(|s| match &s.value {
1375 rustledger_core::Directive::Transaction(t) => Some(t),
1376 _ => None,
1377 })
1378 .collect();
1379 assert_eq!(txns.len(), 2);
1380
1381 // --- Transaction-level meta: all four typed variants + Amount.currency ---
1382
1383 let MetaValue::Account(a1) = &txns[0].meta["counterparty_account"] else {
1384 panic!("expected MetaValue::Account");
1385 };
1386 let MetaValue::Account(a2) = &txns[1].meta["counterparty_account"] else {
1387 panic!("expected MetaValue::Account");
1388 };
1389 assert!(
1390 a1.ptr_eq(a2),
1391 "MetaValue::Account in cross-file meta must share Arc<str>"
1392 );
1393
1394 let MetaValue::Currency(c1) = &txns[0].meta["preferred_currency"] else {
1395 panic!("expected MetaValue::Currency");
1396 };
1397 let MetaValue::Currency(c2) = &txns[1].meta["preferred_currency"] else {
1398 panic!("expected MetaValue::Currency");
1399 };
1400 assert!(
1401 c1.ptr_eq(c2),
1402 "MetaValue::Currency in cross-file meta must share Arc<str>"
1403 );
1404
1405 let MetaValue::Tag(t1) = &txns[0].meta["category_tag"] else {
1406 panic!("expected MetaValue::Tag");
1407 };
1408 let MetaValue::Tag(t2) = &txns[1].meta["category_tag"] else {
1409 panic!("expected MetaValue::Tag");
1410 };
1411 assert!(
1412 t1.ptr_eq(t2),
1413 "MetaValue::Tag in cross-file meta must share Arc<str>"
1414 );
1415
1416 let MetaValue::Link(l1) = &txns[0].meta["receipt_link"] else {
1417 panic!("expected MetaValue::Link");
1418 };
1419 let MetaValue::Link(l2) = &txns[1].meta["receipt_link"] else {
1420 panic!("expected MetaValue::Link");
1421 };
1422 assert!(
1423 l1.ptr_eq(l2),
1424 "MetaValue::Link in cross-file meta must share Arc<str>"
1425 );
1426
1427 let MetaValue::Amount(am1) = &txns[0].meta["fee_amount"] else {
1428 panic!("expected MetaValue::Amount");
1429 };
1430 let MetaValue::Amount(am2) = &txns[1].meta["fee_amount"] else {
1431 panic!("expected MetaValue::Amount");
1432 };
1433 assert!(
1434 am1.currency.ptr_eq(&am2.currency),
1435 "MetaValue::Amount.currency in cross-file meta must share Arc<str>"
1436 );
1437
1438 // --- Posting-level meta: the per-posting `intern_meta` call ---
1439
1440 let first_posting_0 = &txns[0].postings[0].value;
1441 let first_posting_1 = &txns[1].postings[0].value;
1442 let MetaValue::Account(p1) = &first_posting_0.meta["settled_with"] else {
1443 panic!("expected MetaValue::Account in posting meta");
1444 };
1445 let MetaValue::Account(p2) = &first_posting_1.meta["settled_with"] else {
1446 panic!("expected MetaValue::Account in posting meta");
1447 };
1448 assert!(
1449 p1.ptr_eq(p2),
1450 "Posting-level MetaValue::Account in cross-file meta must share Arc<str> \
1451 (verifies the per-posting `intern_meta` call, not just the directive-level one)"
1452 );
1453 }
1454}