Skip to main content

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;
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// 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 options::Options;
65pub use source_map::{SourceFile, SourceMap};
66pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
67
68// Re-export processing API when features are enabled
69/// Shared option→validation mapping and document-dir resolution — single source
70/// of truth so the LSP/MCP diagnostics cannot drift from `check` (issue #1648).
71#[cfg(feature = "validation")]
72pub use process::{document_source_dirs, resolve_document_dirs, validation_options_from_options};
73
74/// Whether an `include`/glob path contains glob metacharacters (`*`, `?`, `[`).
75///
76/// Single source of truth shared with the LSP's document-link resolver so the
77/// two agree on what counts as a glob — a literal-path existence check on a glob
78/// wrongly reports "File not found" (issue #1647).
79#[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// Path normalization lives in a single place: `FileSystem::normalize`
99// (`DiskFileSystem::normalize` is the disk implementation, `VirtualFileSystem`
100// the in-memory one). The free `normalize_path` that used to duplicate the disk
101// body was removed — all callers go through the injected filesystem so the
102// include path-traversal guard normalizes consistently in one namespace.
103
104/// Errors that can occur during loading.
105#[derive(Debug, Error)]
106pub enum LoadError {
107    /// IO error reading a file.
108    #[error("failed to read file {path}: {source}")]
109    Io {
110        /// The path that failed to read.
111        path: PathBuf,
112        /// The underlying IO error.
113        #[source]
114        source: std::io::Error,
115    },
116
117    /// Include cycle detected.
118    ///
119    /// The Display string intentionally begins with `Duplicate filename
120    /// parsed:` to match Python beancount's wording for the same
121    /// condition. The pta-standards `include-cycle-detection`
122    /// conformance test asserts on the substring `"Duplicate filename"`,
123    /// so this wording is load-bearing (#765). The full cycle path is
124    /// preserved in a trailing parenthetical for debuggability.
125    #[error(
126        "Duplicate filename parsed: \"{}\" (include cycle: {})",
127        .cycle.last().map_or("", String::as_str),
128        .cycle.join(" -> ")
129    )]
130    IncludeCycle {
131        /// The cycle of file paths. The last element is the
132        /// re-encountered filename (equal to one of the earlier
133        /// entries), and it's the one quoted in the `"Duplicate
134        /// filename parsed:"` prefix.
135        cycle: Vec<String>,
136    },
137
138    /// Parse errors occurred.
139    #[error("parse errors in {path}")]
140    ParseErrors {
141        /// The file with parse errors.
142        path: PathBuf,
143        /// The parse errors.
144        errors: Vec<ParseError>,
145    },
146
147    /// Path traversal attempt detected.
148    #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
149    PathTraversal {
150        /// The include path that attempted traversal.
151        include_path: String,
152        /// The base directory.
153        base_dir: PathBuf,
154    },
155
156    /// GPG decryption failed.
157    #[error("failed to decrypt {path}: {message}")]
158    Decryption {
159        /// The encrypted file path.
160        path: PathBuf,
161        /// Error message from GPG.
162        message: String,
163    },
164
165    /// Glob pattern did not match any files.
166    #[error("include pattern \"{pattern}\" does not match any files")]
167    GlobNoMatch {
168        /// The glob pattern that matched nothing.
169        pattern: String,
170    },
171
172    /// Glob pattern expansion failed.
173    #[error("failed to expand include pattern \"{pattern}\": {message}")]
174    GlobError {
175        /// The glob pattern that failed.
176        pattern: String,
177        /// The error message.
178        message: String,
179    },
180
181    /// More files were referenced than the 16-bit file-id space allows.
182    ///
183    /// File ids are `u16` on every `Spanned` value, so a ledger may reference at
184    /// most `u16::MAX` files via includes/globs. Reported as an error rather
185    /// than panicking, since `load` is a never-panic-on-input surface.
186    #[error("too many files: a ledger may reference at most {limit} files (16-bit file ids)")]
187    TooManyFiles {
188        /// The maximum number of files supported.
189        limit: usize,
190    },
191}
192
193/// Convert a 0-based file index to the `u16` file id stored on `Spanned`
194/// values, returning [`LoadError::TooManyFiles`] instead of panicking when a
195/// ledger references more files than the id space allows.
196///
197/// `SYNTHESIZED_FILE_ID` (`u16::MAX`) is reserved as the plugin-synthesized
198/// sentinel, so a real file id must be strictly below it — we reject `>=` it,
199/// not merely `> u16::MAX`, otherwise the 65,535th file would alias onto the
200/// sentinel. (This is also the boundary `SourceMap::add_file` asserts, so the
201/// loader must check it *before* calling `add_file`.)
202const 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/// Result of loading a beancount file.
212#[derive(Debug)]
213pub struct LoadResult {
214    /// All directives from all files, in order.
215    pub directives: Vec<Spanned<Directive>>,
216    /// Parsed options.
217    pub options: Options,
218    /// Plugins to load.
219    pub plugins: Vec<Plugin>,
220    /// Source map for error reporting.
221    pub source_map: SourceMap,
222    /// All errors encountered during loading.
223    pub errors: Vec<LoadError>,
224    /// Display context for formatting numbers (tracks precision per currency).
225    pub display_context: DisplayContext,
226}
227
228/// A plugin directive.
229#[derive(Debug, Clone)]
230pub struct Plugin {
231    /// Plugin module name (with any `python:` prefix stripped).
232    pub name: String,
233    /// Optional configuration string.
234    pub config: Option<String>,
235    /// Source location.
236    pub span: Span,
237    /// File this plugin was declared in.
238    pub file_id: usize,
239    /// Whether the `python:` prefix was used to force Python execution.
240    pub force_python: bool,
241}
242
243/// Decrypt a GPG-encrypted file using the system `gpg` command.
244///
245/// This uses `gpg --batch --decrypt` which will use the user's
246/// GPG keyring and gpg-agent for passphrase handling.
247pub(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/// Beancount file loader.
271#[derive(Debug)]
272pub struct Loader {
273    /// Files that have been loaded (for cycle detection).
274    loaded_files: HashSet<PathBuf>,
275    /// Stack for cycle detection during loading (maintains order for error messages).
276    include_stack: Vec<PathBuf>,
277    /// Set for O(1) cycle detection (mirrors `include_stack`).
278    include_stack_set: HashSet<PathBuf>,
279    /// Root directory for path traversal protection.
280    /// If set, includes must resolve to paths within this directory.
281    root_dir: Option<PathBuf>,
282    /// Whether to enforce path traversal protection.
283    enforce_path_security: bool,
284    /// Filesystem abstraction for reading files.
285    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    /// Create a new loader.
303    #[must_use]
304    pub fn new() -> Self {
305        Self::default()
306    }
307
308    /// Enable path traversal protection.
309    ///
310    /// When enabled, include directives cannot escape the root directory
311    /// of the main beancount file. This prevents malicious ledger files
312    /// from accessing sensitive files outside the ledger directory.
313    ///
314    /// # Example
315    ///
316    /// ```ignore
317    /// let result = Loader::new()
318    ///     .with_path_security(true)
319    ///     .load(Path::new("ledger.beancount"))?;
320    /// ```
321    #[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    /// Set a custom root directory for path security.
328    ///
329    /// By default, the root directory is the parent directory of the main file.
330    /// This method allows overriding that to a custom directory.
331    #[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    /// Set a custom filesystem for file loading.
339    ///
340    /// This allows using a virtual filesystem (e.g., for WASM) instead of
341    /// the default disk filesystem.
342    ///
343    /// # Example
344    ///
345    /// ```
346    /// use rustledger_loader::{Loader, VirtualFileSystem};
347    ///
348    /// let mut vfs = VirtualFileSystem::new();
349    /// vfs.add_file("main.beancount", "2024-01-01 open Assets:Bank USD");
350    ///
351    /// let loader = Loader::new().with_filesystem(Box::new(vfs));
352    /// ```
353    #[must_use]
354    pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
355        self.fs = fs;
356        self
357    }
358
359    /// Load a beancount file and all its includes.
360    ///
361    /// Uses parallel file parsing when multiple files are discovered via
362    /// include directives. The root file is parsed first to resolve the
363    /// include tree, then all included files are read and parsed in
364    /// parallel using rayon.
365    ///
366    /// # Errors
367    ///
368    /// Returns [`LoadError`] in the following cases:
369    ///
370    /// - [`LoadError::Io`] - Failed to read the file or an included file
371    /// - [`LoadError::IncludeCycle`] - Circular include detected
372    ///
373    /// Note: Parse errors and path traversal errors are collected in
374    /// [`LoadResult::errors`] rather than returned directly, allowing
375    /// partial results to be returned.
376    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        // Get normalized path (uses filesystem-specific normalization)
384        let canonical = self.fs.normalize(path);
385
386        // Set root directory for path security if enabled but not explicitly set
387        if self.enforce_path_security && self.root_dir.is_none() {
388            self.root_dir = canonical.parent().map(Path::to_path_buf);
389        }
390        // Normalize the root through the SAME filesystem namespace as the paths
391        // it is compared against. An explicit `with_root_dir(...)` may be
392        // relative/un-normalized, and on disk `normalize` makes paths absolute —
393        // so without this, a relative root never `starts_with` a normalized path
394        // and every include would be falsely rejected as a traversal. (The
395        // default-derived root is already normalized, so re-normalizing is a
396        // no-op there.)
397        if let Some(root) = self.root_dir.take() {
398            self.root_dir = Some(self.fs.normalize(&root));
399        }
400
401        // Phase 1: Parse the root file to discover includes.
402        // The root file is typically small (just includes + options).
403        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        // Deduplicate every `InternedStr` reachable from a directive
414        // across files. Each file parses with its own per-file
415        // `StringInterner`, so identical strings — accounts,
416        // currencies, tags, links, payees, narrations — appearing in
417        // two included files land in two different `Arc<str>`
418        // allocations, defeating the `Arc::ptr_eq` fast path in
419        // `InternedStr`'s `PartialEq` and forcing all cross-file
420        // equality through byte comparison.
421        //
422        // The cache-hit path already runs `reintern_directives` to fix
423        // this (see `crates/rustledger/src/cmd/check.rs`). Doing the
424        // same here aligns the fresh-parse path with the cache path:
425        // every consumer of `LoadResult` sees a deduplicated directive
426        // list regardless of how it was produced. Closes #1071.
427        dedup::reintern_directives(&mut directives);
428
429        // Build display context from directives and options
430        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        // Allocate path once for reuse
454        let path_buf = path.to_path_buf();
455
456        // Check for cycles using O(1) HashSet lookup
457        if self.include_stack_set.contains(&path_buf) {
458            // `collect::<Vec<_>>()` on a chain of two `ExactSizeIterator`s
459            // preallocates the exact capacity via `size_hint`, so an
460            // explicit `Vec::with_capacity(...)` + `extend` + `push` is
461            // equivalent and noisier. This is the cycle-error cold path
462            // anyway — readability wins over micro-optimization.
463            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        // Check if already loaded
473        if self.loaded_files.contains(&path_buf) {
474            return Ok(());
475        }
476
477        // Use pre-parsed data if available (from parallel loading path),
478        // otherwise read and parse the file.
479        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                // Route decryption through the filesystem so a sandboxed fs (the
484                // WASI component) can delegate to a host capability (#1667); the
485                // default impl shells out to gpg.
486                self.fs.decrypt(path)?
487            } else {
488                self.fs.read(path)?
489            };
490            // The processing pipeline never reads currency/account occurrences
491            // (LSP-only); skip collecting them — see `parse_without_occurrences`.
492            let parsed = rustledger_parser::parse_without_occurrences(&src);
493            (src, parsed)
494        };
495
496        // Validate the prospective file id BEFORE `add_file` — `add_file`
497        // asserts `id < SYNTHESIZED_FILE_ID` and would panic, but `load` must
498        // never panic on input. The next id `add_file` assigns is the current
499        // file count; reject it here (collected into `LoadResult::errors` by the
500        // caller) so an over-large include/glob set fails loudly without a panic.
501        let fid_u16 = file_id_to_u16(source_map.files().len())?;
502        // Add to source map (Arc::clone is cheap - just increments refcount)
503        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        // Mark as loading (update both stack and set)
507        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        // Collect parse errors
512        if !result.errors.is_empty() {
513            errors.push(LoadError::ParseErrors {
514                path: path.to_path_buf(),
515                errors: result.errors,
516            });
517        }
518
519        // Process options
520        for (key, value, _span) in result.options {
521            options.set(&key, &value);
522        }
523
524        // Process plugins
525        for (name, config, span) in result.plugins {
526            // Check for "python:" prefix to force Python execution
527            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        // Process includes (with glob pattern support)
542        let base_dir = path.parent().unwrap_or(Path::new("."));
543        for (include_path, _span) in &result.includes {
544            // Check if the include path contains glob metacharacters
545            // (check on include_path, not full_path, to avoid false positives from directory names)
546            let has_glob = is_glob_pattern(include_path);
547
548            let full_path = base_dir.join(include_path);
549
550            // Path traversal protection: check BEFORE glob expansion to avoid
551            // enumerating files outside the allowed root directory
552            if self.enforce_path_security
553                && let Some(ref root) = self.root_dir
554            {
555                // For glob patterns, extract and check the non-glob prefix
556                let path_to_check = if has_glob {
557                    // Find where the first glob metacharacter is
558                    let glob_start = include_path
559                        .find(['*', '?', '['])
560                        .unwrap_or(include_path.len());
561                    // Get the directory prefix before the glob
562                    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                    // Normalize via the injected filesystem (not a hardcoded
569                    // disk path fn), so this pre-glob traversal guard and the
570                    // per-matched-file guard below resolve in the SAME namespace
571                    // — under a `VirtualFileSystem` the disk `normalize_path`
572                    // would have compared a disk-canonicalized prefix against a
573                    // pure-string root.
574                    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            // Expand glob patterns or use literal path
591            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            // Check if glob matched nothing
607            if has_glob && paths_to_load.is_empty() {
608                errors.push(LoadError::GlobNoMatch {
609                    pattern: include_path.clone(),
610                });
611                continue;
612            }
613
614            // Normalize and security-check all matched paths first.
615            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                // Security check: glob could match files outside root via symlinks
620                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            // Parallel optimization: when loading multiple sibling includes
635            // from disk, read and parse them in parallel. The expensive work
636            // (I/O + tokenize + parse) runs on rayon's thread pool while the
637            // main thread coordinates the include tree walk.
638            //
639            // Each file is read and parsed independently. Results are then
640            // merged sequentially to preserve include order and process any
641            // nested includes via recursive calls.
642            if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
643                use rayon::prelude::*;
644
645                // Read + parse non-encrypted files in parallel, preserving
646                // original include order. Each entry becomes either
647                // Some((source, parsed)) for successful reads, or None for
648                // encrypted/failed files (which fall back to sequential).
649                //
650                // We keep the original index to merge results in order,
651                // ensuring option/directive precedence matches the declared
652                // include sequence.
653                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                            // Skip encrypted files — they need sequential GPG decryption
659                            if fs.is_encrypted(p) {
660                                return None;
661                            }
662                            // Read through the FileSystem trait so all I/O goes
663                            // through one code path (UTF-8 handling, error types, etc.)
664                            let source = fs.read(p).ok()?;
665                            // Occurrences are LSP-only; skip them on the load path.
666                            let parsed = rustledger_parser::parse_without_occurrences(&source);
667                            Some((source, parsed))
668                        })
669                        .collect();
670
671                // Merge in original include order. Files that were
672                // pre-parsed pass their data to load_recursive; files
673                // that weren't (encrypted or I/O error) are loaded
674                // sequentially as a fallback.
675                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                // Sequential fallback: single file or VFS.
684                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        // Add directives from this file, setting the file_id on the outer
695        // Spanned<Directive> and on each inner Spanned<Posting> inside
696        // transactions. Postings inside an included file share that file's
697        // ID; this keeps inner spans consistent with their containing
698        // directive so consumers don't need to traverse parent pointers.
699        //
700        // file_id is `u16` everywhere (see `Spanned::file_id` rustdoc). `fid_u16`
701        // was validated above (before this file was added to the source map), so
702        // no overflow/panic is possible here.
703        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        // Pop from stack and set
714        if let Some(popped) = self.include_stack.pop() {
715            self.include_stack_set.remove(&popped);
716        }
717
718        Ok(())
719    }
720}
721
722/// Build a display context from loaded directives and options.
723///
724/// This scans all directives for amounts and tracks the maximum precision seen
725/// for each currency. Fixed precisions from `option "display_precision"` override
726/// the inferred values.
727fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
728    let mut ctx = DisplayContext::new();
729
730    // Set render_commas from options
731    ctx.set_render_commas(options.render_commas);
732
733    // Scan directives for amounts to infer precision
734    for spanned in directives {
735        match &spanned.value {
736            Directive::Transaction(txn) => {
737                for posting in &txn.postings {
738                    // Units (IncompleteAmount)
739                    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                    // Cost (CostSpec) — feed the user-written amount to
745                    // the display-context inference. Prefer `total()`
746                    // over `per_unit()` so that for `PerUnitFromTotal`
747                    // we sample the user's literal `{{ total }}` rather
748                    // than the booker-derived per-unit (which has been
749                    // divided by |units| and typically carries far more
750                    // trailing precision than the source spec).
751                    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                    // Price annotations: included so the per-currency dist
761                    // sees them, matching Python beancount's DisplayContext
762                    // population. With the default `Precision::MostCommon`
763                    // policy (introduced for bean-query parity), high-
764                    // precision computed exchange rates are naturally
765                    // ignored by the mode — they're a small minority next
766                    // to mainstream postings. Pre-fix (under MAX policy)
767                    // they were excluded to avoid inflating display
768                    // precision; that exclusion is no longer needed.
769                    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                // Same rationale as posting price annotations above —
784                // included now that MostCommon is the default. The single
785                // 28dp computed-rate price won't shift the mode for a
786                // currency with hundreds of mainstream postings.
787                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    // Apply fixed precisions from options (these override inferred values)
802    for (currency, precision) in &options.display_precision {
803        ctx.set_fixed_precision(currency, *precision);
804    }
805
806    // Apply per-commodity `precision: N` metadata (issue #991), AFTER the
807    // options loop so a commodity-level declaration wins over the global
808    // option. Multi-declaration of the same currency is last-wins (matches
809    // typical option-stacking semantics). Invalid values are silently
810    // skipped here — `rustledger-validate` surfaces them as
811    // `InvalidPrecisionMetadata` warnings (E5003) so users see the problem
812    // without breaking loading.
813    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/// Load a beancount file without processing.
826///
827/// This is a convenience function that creates a loader and loads a single file.
828/// For fully processed results (booking, plugins, validation), use the
829/// [`load`] function with [`LoadOptions`] instead.
830#[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        // The last valid real id is one BELOW the reserved sentinel.
845        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        // The sentinel itself (and beyond) is rejected with a LoadError — NOT a
851        // panic, and NOT aliased onto SYNTHESIZED_FILE_ID. `load` is a
852        // never-panic-on-input surface; the old `expect`/`assert` would have
853        // aborted the whole embedder / wasm module at this boundary.
854        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        // Create a fake .gpg file
904        let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
905        writeln!(file, "fake encrypted content").unwrap();
906        file.flush().unwrap();
907
908        // This will fail because the content isn't actually GPG-encrypted
909        // (or gpg isn't installed, or there's no matching key)
910        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        // First plugin should have force_python = true and name without prefix
933        assert_eq!(result.plugins[0].name, "my_plugin");
934        assert!(result.plugins[0].force_python);
935
936        // Second plugin should have force_python = false
937        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        // Create a virtual filesystem with multiple files
958        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        // Load with virtual filesystem
978        let result = Loader::new()
979            .with_filesystem(Box::new(vfs))
980            .load(Path::new("main.beancount"))
981            .unwrap();
982
983        // Should have 3 directives: 2 opens + 1 transaction
984        assert_eq!(result.directives.len(), 3);
985        assert!(result.errors.is_empty());
986
987        // Verify directive types
988        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        // Test deeply nested includes
1003        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        // Should have 2 open directives from nested includes
1020        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        // Should have an error for missing file
1035        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        // This file should NOT be matched by the glob
1072        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        // Should have: 1 open from main + 2 opens from transactions + 2 txns
1083        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        // Should have: 1 open from main + 2 opens from transactions + 2 txns
1145        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        // Should have a GlobNoMatch error
1183        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    /// Regression: with path security under a `VirtualFileSystem`, the pre-glob
1195    /// traversal guard used the disk `normalize_path` while the per-file guard
1196    /// used `self.fs.normalize` — different namespaces. So a LEGITIMATE glob
1197    /// include within the root got its prefix disk-normalized to a real-CWD path
1198    /// that didn't `starts_with` the VFS root, and was falsely rejected as a
1199    /// path traversal. Both guards now normalize through the injected filesystem.
1200    #[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")) // enables path security
1212            .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        // The included file actually loaded (its `open` is present).
1224        assert!(
1225            !result.directives.is_empty(),
1226            "the in-root include should have loaded; errors: {:?}",
1227            result.errors
1228        );
1229    }
1230
1231    /// Regression test for #1071: a fresh multi-file parse must produce
1232    /// deduplicated `InternedStr` values, so two `Posting`s referencing
1233    /// the same account from different files share one `Arc<str>`.
1234    /// Pre-fix the per-file `StringInterner` kept the two `Arc`s
1235    /// distinct and `Arc::ptr_eq` fell through to byte comparison.
1236    #[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        // Collect every `Assets:Bank` `Account` (one from `open`, one
1263        // from the posting). They originate in different files, so
1264        // pre-fix they had distinct `Arc<str>` allocations.
1265        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    /// Companion to the previous test — covers the Transaction-level
1296    /// `InternedStr` fields (payee, narration, tags, links) that the
1297    /// pre-Copilot version of `reintern_directive` silently skipped
1298    /// (Copilot review on PR #1081). Two transactions in different
1299    /// files share the same payee + tag; after `Loader::load` they
1300    /// must share one `Arc<str>` per string.
1301    #[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    /// Regression test responding to Copilot review on PR #1174: the
1356    /// dedup pass must walk every interned payload type inside
1357    /// `Metadata` maps — `MetaValue::{Account, Currency, Tag, Link,
1358    /// Amount.currency}` — at both the transaction level and the
1359    /// posting level. Before the meta walk was added, cross-file
1360    /// metadata values held distinct `Arc<str>` allocations even when
1361    /// they referenced identical strings.
1362    ///
1363    /// One multi-file fixture exercises all five variants in a single
1364    /// load to keep the test focused on the dedup invariant rather
1365    /// than the parse machinery.
1366    #[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        // --- Transaction-level meta: all four typed variants + Amount.currency ---
1421
1422        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        // --- Posting-level meta: the per-posting `intern_meta` call ---
1478
1479        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}