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