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;
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        for (key, value, _span) in result.options {
569            options.set(&key, &value);
570        }
571
572        // Process plugins
573        for (name, config, span) in result.plugins {
574            // Check for "python:" prefix to force Python execution
575            let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
576                (stripped.to_string(), true)
577            } else {
578                (name, false)
579            };
580            plugins.push(Plugin {
581                name: actual_name,
582                config,
583                span,
584                file_id,
585                force_python,
586            });
587        }
588
589        // Process includes (with glob pattern support)
590        let base_dir = path.parent().unwrap_or(Path::new("."));
591        for (include_path, _span) in &result.includes {
592            // Check if the include path contains glob metacharacters
593            // (check on include_path, not full_path, to avoid false positives from directory names)
594            let has_glob = is_glob_pattern(include_path);
595
596            let full_path = base_dir.join(include_path);
597
598            // Path traversal protection: check BEFORE glob expansion to avoid
599            // enumerating files outside the allowed root directory
600            if self.enforce_path_security
601                && let Some(ref root) = self.root_dir
602            {
603                // For glob patterns, extract and check the non-glob prefix
604                let path_to_check = if has_glob {
605                    // Find where the first glob metacharacter is
606                    let glob_start = include_path
607                        .find(['*', '?', '['])
608                        .unwrap_or(include_path.len());
609                    // Get the directory prefix before the glob
610                    let prefix = &include_path[..glob_start];
611                    let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
612                        base_dir.join(&include_path[..=last_sep])
613                    } else {
614                        base_dir.to_path_buf()
615                    };
616                    // Normalize via the injected filesystem (not a hardcoded
617                    // disk path fn), so this pre-glob traversal guard and the
618                    // per-matched-file guard below resolve in the SAME namespace
619                    // — under a `VirtualFileSystem` the disk `normalize_path`
620                    // would have compared a disk-canonicalized prefix against a
621                    // pure-string root.
622                    self.fs.normalize(&prefix_path)
623                } else {
624                    self.fs.normalize(&full_path)
625                };
626
627                if !path_to_check.starts_with(root) {
628                    errors.push(LoadError::PathTraversal {
629                        include_path: include_path.clone(),
630                        base_dir: root.clone(),
631                    });
632                    continue;
633                }
634            }
635
636            let full_path_str = full_path.to_string_lossy();
637
638            // Expand glob patterns or use literal path
639            let paths_to_load: Vec<PathBuf> = if has_glob {
640                match self.fs.glob(&full_path_str) {
641                    Ok(matched) => matched,
642                    Err(e) => {
643                        errors.push(LoadError::GlobError {
644                            pattern: include_path.clone(),
645                            message: e,
646                        });
647                        continue;
648                    }
649                }
650            } else {
651                vec![full_path.clone()]
652            };
653
654            // Check if glob matched nothing
655            if has_glob && paths_to_load.is_empty() {
656                errors.push(LoadError::GlobNoMatch {
657                    pattern: include_path.clone(),
658                });
659                continue;
660            }
661
662            // Normalize and security-check all matched paths first.
663            let mut valid_paths = Vec::with_capacity(paths_to_load.len());
664            for matched_path in paths_to_load {
665                let canonical = self.fs.normalize(&matched_path);
666
667                // Security check: glob could match files outside root via symlinks
668                if self.enforce_path_security
669                    && let Some(ref root) = self.root_dir
670                    && !canonical.starts_with(root)
671                {
672                    errors.push(LoadError::PathTraversal {
673                        include_path: matched_path.to_string_lossy().into_owned(),
674                        base_dir: root.clone(),
675                    });
676                    continue;
677                }
678
679                valid_paths.push(canonical);
680            }
681
682            // Parallel optimization: when loading multiple sibling includes
683            // from disk, read and parse them in parallel. The expensive work
684            // (I/O + tokenize + parse) runs on rayon's thread pool while the
685            // main thread coordinates the include tree walk.
686            //
687            // Each file is read and parsed independently. Results are then
688            // merged sequentially to preserve include order and process any
689            // nested includes via recursive calls.
690            if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
691                use rayon::prelude::*;
692
693                // Read + parse non-encrypted files in parallel, preserving
694                // original include order. Each entry becomes either
695                // Some((source, parsed)) for successful reads, or None for
696                // encrypted/failed files (which fall back to sequential).
697                //
698                // We keep the original index to merge results in order,
699                // ensuring option/directive precedence matches the declared
700                // include sequence.
701                let fs = &*self.fs;
702                let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
703                    valid_paths
704                        .par_iter()
705                        .map(|p| {
706                            // Skip encrypted files — they need sequential GPG decryption
707                            if fs.is_encrypted(p) {
708                                return None;
709                            }
710                            // Read through the FileSystem trait so all I/O goes
711                            // through one code path (UTF-8 handling, error types, etc.)
712                            let source = fs.read(p).ok()?;
713                            // Occurrences are LSP-only; skip them on the load path.
714                            let parsed = rustledger_parser::parse_without_occurrences(&source);
715                            Some((source, parsed))
716                        })
717                        .collect();
718
719                // Merge in original include order. Files that were
720                // pre-parsed pass their data to load_recursive; files
721                // that weren't (encrypted or I/O error) are loaded
722                // sequentially as a fallback.
723                for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
724                    if let Err(e) = self.load_recursive(
725                        canonical, pre, directives, options, plugins, source_map, errors,
726                    ) {
727                        errors.push(e);
728                    }
729                }
730            } else {
731                // Sequential fallback: single file or VFS.
732                for canonical in valid_paths {
733                    if let Err(e) = self.load_recursive(
734                        &canonical, None, directives, options, plugins, source_map, errors,
735                    ) {
736                        errors.push(e);
737                    }
738                }
739            }
740        }
741
742        // Add directives from this file, setting the file_id on the outer
743        // Spanned<Directive> and on each inner Spanned<Posting> inside
744        // transactions. Postings inside an included file share that file's
745        // ID; this keeps inner spans consistent with their containing
746        // directive so consumers don't need to traverse parent pointers.
747        //
748        // file_id is `u16` everywhere (see `Spanned::file_id` rustdoc). `fid_u16`
749        // was validated above (before this file was added to the source map), so
750        // no overflow/panic is possible here.
751        directives.extend(result.directives.into_iter().map(|d| {
752            let mut d = d.with_file_id(file_id);
753            if let rustledger_core::Directive::Transaction(ref mut txn) = d.value {
754                for p in &mut txn.postings {
755                    p.file_id = fid_u16;
756                }
757            }
758            d
759        }));
760
761        // Pop from stack and set
762        if let Some(popped) = self.include_stack.pop() {
763            self.include_stack_set.remove(&popped);
764        }
765
766        Ok(())
767    }
768}
769
770/// Build a display context from loaded directives and options.
771///
772/// Thin wrapper over the canonical builder
773/// [`DisplayContext::from_directives`] (moved to `rustledger-core` so the
774/// FFI component's `session.format` shares the exact sampling rules —
775/// #1766): amount-scan inference, then `option "display_precision"`
776/// overrides, then per-commodity `precision:` metadata. Only
777/// `render_commas` — presentation policy, not precision — is applied here.
778fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
779    DisplayContext::from_directives(
780        directives.iter().map(|s| &s.value),
781        options
782            .display_precision
783            .iter()
784            .map(|(c, p)| (c.as_str(), *p)),
785        options.render_commas,
786    )
787}
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792    use std::io::Write;
793    use tempfile::NamedTempFile;
794
795    #[test]
796    fn file_id_to_u16_rejects_the_reserved_sentinel_not_panic() {
797        let sentinel = rustledger_parser::SYNTHESIZED_FILE_ID as usize;
798        // The last valid real id is one BELOW the reserved sentinel.
799        assert_eq!(file_id_to_u16(0).unwrap(), 0);
800        assert_eq!(
801            file_id_to_u16(sentinel - 1).unwrap(),
802            rustledger_parser::SYNTHESIZED_FILE_ID - 1
803        );
804        // The sentinel itself (and beyond) is rejected with a LoadError — NOT a
805        // panic, and NOT aliased onto SYNTHESIZED_FILE_ID. `load` is a
806        // never-panic-on-input surface; the old `expect`/`assert` would have
807        // aborted the whole embedder / wasm module at this boundary.
808        assert!(matches!(
809            file_id_to_u16(sentinel),
810            Err(LoadError::TooManyFiles { limit }) if limit == sentinel
811        ));
812        assert!(matches!(
813            file_id_to_u16(sentinel + 1),
814            Err(LoadError::TooManyFiles { .. })
815        ));
816    }
817
818    #[test]
819    fn test_is_encrypted_file_gpg_extension() {
820        let fs = DiskFileSystem;
821        let path = Path::new("test.beancount.gpg");
822        assert!(fs.is_encrypted(path));
823    }
824
825    #[test]
826    fn test_is_encrypted_file_plain_beancount() {
827        let fs = DiskFileSystem;
828        let path = Path::new("test.beancount");
829        assert!(!fs.is_encrypted(path));
830    }
831
832    #[test]
833    fn test_is_encrypted_file_asc_with_pgp_header() {
834        let fs = DiskFileSystem;
835        let mut file = NamedTempFile::with_suffix(".asc").unwrap();
836        writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
837        writeln!(file, "some encrypted content").unwrap();
838        writeln!(file, "-----END PGP MESSAGE-----").unwrap();
839        file.flush().unwrap();
840
841        assert!(fs.is_encrypted(file.path()));
842    }
843
844    #[test]
845    fn test_is_encrypted_file_asc_without_pgp_header() {
846        let fs = DiskFileSystem;
847        let mut file = NamedTempFile::with_suffix(".asc").unwrap();
848        writeln!(file, "This is just a plain text file").unwrap();
849        writeln!(file, "with .asc extension but no PGP content").unwrap();
850        file.flush().unwrap();
851
852        assert!(!fs.is_encrypted(file.path()));
853    }
854
855    #[test]
856    fn test_decrypt_gpg_file_missing_gpg() {
857        // Create a fake .gpg file
858        let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
859        writeln!(file, "fake encrypted content").unwrap();
860        file.flush().unwrap();
861
862        // This will fail because the content isn't actually GPG-encrypted
863        // (or gpg isn't installed, or there's no matching key)
864        let result = decrypt_gpg_file(file.path());
865        assert!(result.is_err());
866
867        if let Err(LoadError::Decryption { path, message }) = result {
868            assert_eq!(path, file.path().to_path_buf());
869            assert!(!message.is_empty());
870        } else {
871            panic!("Expected Decryption error");
872        }
873    }
874
875    #[test]
876    fn test_plugin_force_python_prefix() {
877        let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
878        writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
879        writeln!(file, r#"plugin "regular_plugin""#).unwrap();
880        file.flush().unwrap();
881
882        let result = Loader::new().load(file.path()).unwrap();
883
884        assert_eq!(result.plugins.len(), 2);
885
886        // First plugin should have force_python = true and name without prefix
887        assert_eq!(result.plugins[0].name, "my_plugin");
888        assert!(result.plugins[0].force_python);
889
890        // Second plugin should have force_python = false
891        assert_eq!(result.plugins[1].name, "regular_plugin");
892        assert!(!result.plugins[1].force_python);
893    }
894
895    #[test]
896    fn test_plugin_force_python_with_config() {
897        let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
898        writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
899        file.flush().unwrap();
900
901        let result = Loader::new().load(file.path()).unwrap();
902
903        assert_eq!(result.plugins.len(), 1);
904        assert_eq!(result.plugins[0].name, "my_plugin");
905        assert!(result.plugins[0].force_python);
906        assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
907    }
908
909    #[test]
910    fn test_virtual_filesystem_include_resolution() {
911        // Create a virtual filesystem with multiple files
912        let mut vfs = VirtualFileSystem::new();
913        vfs.add_file(
914            "main.beancount",
915            r#"
916include "accounts.beancount"
917
9182024-01-15 * "Coffee"
919  Expenses:Food  5.00 USD
920  Assets:Bank   -5.00 USD
921"#,
922        );
923        vfs.add_file(
924            "accounts.beancount",
925            r"
9262024-01-01 open Assets:Bank USD
9272024-01-01 open Expenses:Food USD
928",
929        );
930
931        // Load with virtual filesystem
932        let result = Loader::new()
933            .with_filesystem(Box::new(vfs))
934            .load(Path::new("main.beancount"))
935            .unwrap();
936
937        // Should have 3 directives: 2 opens + 1 transaction
938        assert_eq!(result.directives.len(), 3);
939        assert!(result.errors.is_empty());
940
941        // Verify directive types
942        let directive_types: Vec<_> = result
943            .directives
944            .iter()
945            .map(|d| match &d.value {
946                rustledger_core::Directive::Open(_) => "open",
947                rustledger_core::Directive::Transaction(_) => "txn",
948                _ => "other",
949            })
950            .collect();
951        assert_eq!(directive_types, vec!["open", "open", "txn"]);
952    }
953
954    #[test]
955    fn test_virtual_filesystem_nested_includes() {
956        // Test deeply nested includes
957        let mut vfs = VirtualFileSystem::new();
958        vfs.add_file("main.beancount", r#"include "level1.beancount""#);
959        vfs.add_file(
960            "level1.beancount",
961            r#"
962include "level2.beancount"
9632024-01-01 open Assets:Level1 USD
964"#,
965        );
966        vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
967
968        let result = Loader::new()
969            .with_filesystem(Box::new(vfs))
970            .load(Path::new("main.beancount"))
971            .unwrap();
972
973        // Should have 2 open directives from nested includes
974        assert_eq!(result.directives.len(), 2);
975        assert!(result.errors.is_empty());
976    }
977
978    #[test]
979    fn test_virtual_filesystem_missing_include() {
980        let mut vfs = VirtualFileSystem::new();
981        vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
982
983        let result = Loader::new()
984            .with_filesystem(Box::new(vfs))
985            .load(Path::new("main.beancount"))
986            .unwrap();
987
988        // Should have an error for missing file
989        assert!(!result.errors.is_empty());
990        let error_msg = result.errors[0].to_string();
991        assert!(error_msg.contains("not found") || error_msg.contains("Io"));
992    }
993
994    #[test]
995    fn test_virtual_filesystem_glob_include() {
996        let mut vfs = VirtualFileSystem::new();
997        vfs.add_file(
998            "main.beancount",
999            r#"
1000include "transactions/*.beancount"
1001
10022024-01-01 open Assets:Bank USD
1003"#,
1004        );
1005        vfs.add_file(
1006            "transactions/2024.beancount",
1007            r#"
10082024-01-01 open Expenses:Food USD
1009
10102024-06-15 * "Groceries"
1011  Expenses:Food  50.00 USD
1012  Assets:Bank   -50.00 USD
1013"#,
1014        );
1015        vfs.add_file(
1016            "transactions/2025.beancount",
1017            r#"
10182025-01-01 open Expenses:Rent USD
1019
10202025-02-01 * "Rent"
1021  Expenses:Rent  1000.00 USD
1022  Assets:Bank   -1000.00 USD
1023"#,
1024        );
1025        // This file should NOT be matched by the glob
1026        vfs.add_file(
1027            "other/ignored.beancount",
1028            "2024-01-01 open Expenses:Other USD",
1029        );
1030
1031        let result = Loader::new()
1032            .with_filesystem(Box::new(vfs))
1033            .load(Path::new("main.beancount"))
1034            .unwrap();
1035
1036        // Should have: 1 open from main + 2 opens from transactions + 2 txns
1037        let opens = result
1038            .directives
1039            .iter()
1040            .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1041            .count();
1042        assert_eq!(
1043            opens, 3,
1044            "expected 3 open directives (1 main + 2 transactions)"
1045        );
1046
1047        let txns = result
1048            .directives
1049            .iter()
1050            .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1051            .count();
1052        assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
1053
1054        assert!(
1055            result.errors.is_empty(),
1056            "expected no errors, got: {:?}",
1057            result.errors
1058        );
1059    }
1060
1061    #[test]
1062    fn test_virtual_filesystem_glob_dot_slash_prefix() {
1063        let mut vfs = VirtualFileSystem::new();
1064        vfs.add_file(
1065            "main.beancount",
1066            r#"
1067include "./transactions/*.beancount"
1068
10692024-01-01 open Assets:Bank USD
1070"#,
1071        );
1072        vfs.add_file(
1073            "transactions/2024.beancount",
1074            r#"
10752024-01-01 open Expenses:Food USD
1076
10772024-06-15 * "Groceries"
1078  Expenses:Food  50.00 USD
1079  Assets:Bank   -50.00 USD
1080"#,
1081        );
1082        vfs.add_file(
1083            "transactions/2025.beancount",
1084            r#"
10852025-01-01 open Expenses:Rent USD
1086
10872025-02-01 * "Rent"
1088  Expenses:Rent  1000.00 USD
1089  Assets:Bank   -1000.00 USD
1090"#,
1091        );
1092
1093        let result = Loader::new()
1094            .with_filesystem(Box::new(vfs))
1095            .load(Path::new("main.beancount"))
1096            .unwrap();
1097
1098        // Should have: 1 open from main + 2 opens from transactions + 2 txns
1099        let opens = result
1100            .directives
1101            .iter()
1102            .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1103            .count();
1104        assert_eq!(
1105            opens, 3,
1106            "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1107        );
1108
1109        let txns = result
1110            .directives
1111            .iter()
1112            .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1113            .count();
1114        assert_eq!(
1115            txns, 2,
1116            "expected 2 transactions from glob-matched files despite ./ prefix"
1117        );
1118
1119        assert!(
1120            result.errors.is_empty(),
1121            "expected no errors, got: {:?}",
1122            result.errors
1123        );
1124    }
1125
1126    #[test]
1127    fn test_virtual_filesystem_glob_no_match() {
1128        let mut vfs = VirtualFileSystem::new();
1129        vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1130
1131        let result = Loader::new()
1132            .with_filesystem(Box::new(vfs))
1133            .load(Path::new("main.beancount"))
1134            .unwrap();
1135
1136        // Should have a GlobNoMatch error
1137        let has_glob_error = result
1138            .errors
1139            .iter()
1140            .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1141        assert!(
1142            has_glob_error,
1143            "expected GlobNoMatch error, got: {:?}",
1144            result.errors
1145        );
1146    }
1147
1148    /// Regression: with path security under a `VirtualFileSystem`, the pre-glob
1149    /// traversal guard used the disk `normalize_path` while the per-file guard
1150    /// used `self.fs.normalize` — different namespaces. So a LEGITIMATE glob
1151    /// include within the root got its prefix disk-normalized to a real-CWD path
1152    /// that didn't `starts_with` the VFS root, and was falsely rejected as a
1153    /// path traversal. Both guards now normalize through the injected filesystem.
1154    #[test]
1155    fn test_vfs_glob_include_within_root_not_flagged_as_traversal() {
1156        let mut vfs = VirtualFileSystem::new();
1157        vfs.add_file("ledger/main.beancount", r#"include "sub/*.beancount""#);
1158        vfs.add_file(
1159            "ledger/sub/a.beancount",
1160            "2024-01-01 open Assets:Cash USD\n",
1161        );
1162
1163        let result = Loader::new()
1164            .with_filesystem(Box::new(vfs))
1165            .with_root_dir(PathBuf::from("ledger")) // enables path security
1166            .load(Path::new("ledger/main.beancount"))
1167            .unwrap();
1168
1169        assert!(
1170            !result
1171                .errors
1172                .iter()
1173                .any(|e| matches!(e, LoadError::PathTraversal { .. })),
1174            "legit in-root VFS glob include wrongly flagged as traversal: {:?}",
1175            result.errors
1176        );
1177        // The included file actually loaded (its `open` is present).
1178        assert!(
1179            !result.directives.is_empty(),
1180            "the in-root include should have loaded; errors: {:?}",
1181            result.errors
1182        );
1183    }
1184
1185    /// Regression test for #1071: a fresh multi-file parse must produce
1186    /// deduplicated `InternedStr` values, so two `Posting`s referencing
1187    /// the same account from different files share one `Arc<str>`.
1188    /// Pre-fix the per-file `StringInterner` kept the two `Arc`s
1189    /// distinct and `Arc::ptr_eq` fell through to byte comparison.
1190    #[test]
1191    fn test_fresh_parse_deduplicates_internedstr_across_files() {
1192        let mut vfs = VirtualFileSystem::new();
1193        vfs.add_file(
1194            "main.beancount",
1195            r#"
11962024-01-01 open Assets:Bank USD
1197include "transactions.beancount"
1198"#,
1199        );
1200        vfs.add_file(
1201            "transactions.beancount",
1202            r#"
12032024-01-15 * "Coffee"
1204  Assets:Bank   -5.00 USD
1205  Expenses:Coffee  5.00 USD
1206
12072024-01-16 open Expenses:Coffee
1208"#,
1209        );
1210
1211        let result = Loader::new()
1212            .with_filesystem(Box::new(vfs))
1213            .load(Path::new("main.beancount"))
1214            .unwrap();
1215
1216        // Collect every `Assets:Bank` `Account` (one from `open`, one
1217        // from the posting). They originate in different files, so
1218        // pre-fix they had distinct `Arc<str>` allocations.
1219        let bank_accounts: Vec<&rustledger_core::Account> = result
1220            .directives
1221            .iter()
1222            .filter_map(|s| match &s.value {
1223                rustledger_core::Directive::Open(o) if o.account.as_str() == "Assets:Bank" => {
1224                    Some(&o.account)
1225                }
1226                rustledger_core::Directive::Transaction(t) => t
1227                    .postings
1228                    .iter()
1229                    .find(|p| p.account.as_str() == "Assets:Bank")
1230                    .map(|p| &p.account),
1231                _ => None,
1232            })
1233            .collect();
1234
1235        assert_eq!(
1236            bank_accounts.len(),
1237            2,
1238            "expected one Open and one posting for Assets:Bank"
1239        );
1240        assert!(
1241            bank_accounts[0]
1242                .as_interned()
1243                .ptr_eq(bank_accounts[1].as_interned()),
1244            "Assets:Bank from cross-file open/posting must share the same Arc<str> \
1245             after Loader::load runs reintern_directives"
1246        );
1247    }
1248
1249    /// Companion to the previous test — covers the Transaction-level
1250    /// `InternedStr` fields (payee, narration, tags, links) that the
1251    /// pre-Copilot version of `reintern_directive` silently skipped
1252    /// (Copilot review on PR #1081). Two transactions in different
1253    /// files share the same payee + tag; after `Loader::load` they
1254    /// must share one `Arc<str>` per string.
1255    #[test]
1256    fn test_fresh_parse_deduplicates_transaction_fields_across_files() {
1257        let mut vfs = VirtualFileSystem::new();
1258        vfs.add_file(
1259            "main.beancount",
1260            r#"
12612024-01-01 open Assets:Bank USD
12622024-01-01 open Expenses:Coffee
1263
12642024-01-15 * "Cafe Bench" "Latte" #morning
1265  Assets:Bank   -5.00 USD
1266  Expenses:Coffee  5.00 USD
1267
1268include "more.beancount"
1269"#,
1270        );
1271        vfs.add_file(
1272            "more.beancount",
1273            r#"
12742024-01-16 * "Cafe Bench" "Espresso" #morning
1275  Assets:Bank   -3.00 USD
1276  Expenses:Coffee  3.00 USD
1277"#,
1278        );
1279
1280        let result = Loader::new()
1281            .with_filesystem(Box::new(vfs))
1282            .load(Path::new("main.beancount"))
1283            .unwrap();
1284
1285        let txns: Vec<&rustledger_core::Transaction> = result
1286            .directives
1287            .iter()
1288            .filter_map(|s| match &s.value {
1289                rustledger_core::Directive::Transaction(t) => Some(t),
1290                _ => None,
1291            })
1292            .collect();
1293
1294        assert_eq!(txns.len(), 2, "expected the two transactions");
1295        let p1 = txns[0].payee.as_ref().expect("first txn has payee");
1296        let p2 = txns[1].payee.as_ref().expect("second txn has payee");
1297        assert!(
1298            p1.ptr_eq(p2),
1299            "Identical payee \"Cafe Bench\" across files must share one Arc<str>"
1300        );
1301
1302        assert!(!txns[0].tags.is_empty() && !txns[1].tags.is_empty());
1303        assert!(
1304            txns[0].tags[0].ptr_eq(&txns[1].tags[0]),
1305            "Identical tag #morning across files must share one Arc<str>"
1306        );
1307    }
1308
1309    /// Regression test responding to Copilot review on PR #1174: the
1310    /// dedup pass must walk every interned payload type inside
1311    /// `Metadata` maps — `MetaValue::{Account, Currency, Tag, Link,
1312    /// Amount.currency}` — at both the transaction level and the
1313    /// posting level. Before the meta walk was added, cross-file
1314    /// metadata values held distinct `Arc<str>` allocations even when
1315    /// they referenced identical strings.
1316    ///
1317    /// One multi-file fixture exercises all five variants in a single
1318    /// load to keep the test focused on the dedup invariant rather
1319    /// than the parse machinery.
1320    #[test]
1321    fn test_fresh_parse_deduplicates_metavalue_across_files() {
1322        use rustledger_core::MetaValue;
1323
1324        let mut vfs = VirtualFileSystem::new();
1325        vfs.add_file(
1326            "main.beancount",
1327            r#"
13282024-01-01 open Assets:Bank USD
13292024-01-01 open Expenses:Coffee
1330
13312024-01-15 * "Latte"
1332  counterparty_account: Assets:Bank
1333  preferred_currency: USD
1334  category_tag: #coffee
1335  receipt_link: ^receipt-2024
1336  fee_amount: 0.50 USD
1337  Assets:Bank   -5.00 USD
1338    settled_with: Assets:Bank
1339  Expenses:Coffee  5.00 USD
1340
1341include "more.beancount"
1342"#,
1343        );
1344        vfs.add_file(
1345            "more.beancount",
1346            r#"
13472024-01-16 * "Espresso"
1348  counterparty_account: Assets:Bank
1349  preferred_currency: USD
1350  category_tag: #coffee
1351  receipt_link: ^receipt-2024
1352  fee_amount: 0.50 USD
1353  Assets:Bank   -3.00 USD
1354    settled_with: Assets:Bank
1355  Expenses:Coffee  3.00 USD
1356"#,
1357        );
1358
1359        let result = Loader::new()
1360            .with_filesystem(Box::new(vfs))
1361            .load(Path::new("main.beancount"))
1362            .unwrap();
1363
1364        let txns: Vec<&rustledger_core::Transaction> = result
1365            .directives
1366            .iter()
1367            .filter_map(|s| match &s.value {
1368                rustledger_core::Directive::Transaction(t) => Some(t),
1369                _ => None,
1370            })
1371            .collect();
1372        assert_eq!(txns.len(), 2);
1373
1374        // --- Transaction-level meta: all four typed variants + Amount.currency ---
1375
1376        let MetaValue::Account(a1) = &txns[0].meta["counterparty_account"] else {
1377            panic!("expected MetaValue::Account");
1378        };
1379        let MetaValue::Account(a2) = &txns[1].meta["counterparty_account"] else {
1380            panic!("expected MetaValue::Account");
1381        };
1382        assert!(
1383            a1.ptr_eq(a2),
1384            "MetaValue::Account in cross-file meta must share Arc<str>"
1385        );
1386
1387        let MetaValue::Currency(c1) = &txns[0].meta["preferred_currency"] else {
1388            panic!("expected MetaValue::Currency");
1389        };
1390        let MetaValue::Currency(c2) = &txns[1].meta["preferred_currency"] else {
1391            panic!("expected MetaValue::Currency");
1392        };
1393        assert!(
1394            c1.ptr_eq(c2),
1395            "MetaValue::Currency in cross-file meta must share Arc<str>"
1396        );
1397
1398        let MetaValue::Tag(t1) = &txns[0].meta["category_tag"] else {
1399            panic!("expected MetaValue::Tag");
1400        };
1401        let MetaValue::Tag(t2) = &txns[1].meta["category_tag"] else {
1402            panic!("expected MetaValue::Tag");
1403        };
1404        assert!(
1405            t1.ptr_eq(t2),
1406            "MetaValue::Tag in cross-file meta must share Arc<str>"
1407        );
1408
1409        let MetaValue::Link(l1) = &txns[0].meta["receipt_link"] else {
1410            panic!("expected MetaValue::Link");
1411        };
1412        let MetaValue::Link(l2) = &txns[1].meta["receipt_link"] else {
1413            panic!("expected MetaValue::Link");
1414        };
1415        assert!(
1416            l1.ptr_eq(l2),
1417            "MetaValue::Link in cross-file meta must share Arc<str>"
1418        );
1419
1420        let MetaValue::Amount(am1) = &txns[0].meta["fee_amount"] else {
1421            panic!("expected MetaValue::Amount");
1422        };
1423        let MetaValue::Amount(am2) = &txns[1].meta["fee_amount"] else {
1424            panic!("expected MetaValue::Amount");
1425        };
1426        assert!(
1427            am1.currency.ptr_eq(&am2.currency),
1428            "MetaValue::Amount.currency in cross-file meta must share Arc<str>"
1429        );
1430
1431        // --- Posting-level meta: the per-posting `intern_meta` call ---
1432
1433        let first_posting_0 = &txns[0].postings[0].value;
1434        let first_posting_1 = &txns[1].postings[0].value;
1435        let MetaValue::Account(p1) = &first_posting_0.meta["settled_with"] else {
1436            panic!("expected MetaValue::Account in posting meta");
1437        };
1438        let MetaValue::Account(p2) = &first_posting_1.meta["settled_with"] else {
1439            panic!("expected MetaValue::Account in posting meta");
1440        };
1441        assert!(
1442            p1.ptr_eq(p2),
1443            "Posting-level MetaValue::Account in cross-file meta must share Arc<str> \
1444             (verifies the per-posting `intern_meta` call, not just the directive-level one)"
1445        );
1446    }
1447}