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
31#[cfg(feature = "cache")]
32pub mod cache;
33mod options;
34#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
35mod process;
36mod source_map;
37mod vfs;
38
39#[cfg(feature = "cache")]
40pub use cache::{
41    CacheEntry, CachedOptions, CachedPlugin, invalidate_cache, load_cache_entry,
42    reintern_directives, reintern_plain_directives, save_cache_entry,
43};
44pub use options::Options;
45pub use source_map::{SourceFile, SourceMap};
46pub use vfs::{DiskFileSystem, FileSystem, VirtualFileSystem};
47
48// Re-export processing API when features are enabled
49#[cfg(feature = "plugins")]
50pub use process::run_plugins;
51#[cfg(any(feature = "booking", feature = "plugins", feature = "validation"))]
52pub use process::{
53    ErrorLocation, ErrorSeverity, Ledger, LedgerError, LoadOptions, ProcessError, load, load_raw,
54    process,
55};
56
57use rustledger_core::{Directive, DisplayContext};
58use rustledger_parser::{ParseError, Span, Spanned};
59use std::collections::HashSet;
60use std::path::{Path, PathBuf};
61use std::process::Command;
62use thiserror::Error;
63
64/// Try to canonicalize a path, falling back to making it absolute if canonicalize
65/// is not supported (e.g., on WASI).
66///
67/// This function:
68/// 1. First tries `fs::canonicalize()` which resolves symlinks and returns absolute path
69/// 2. If that fails (e.g., WASI doesn't support it), tries to make an absolute path manually
70/// 3. As a last resort, returns the original path
71fn normalize_path(path: &Path) -> PathBuf {
72    // Try canonicalize first (works on most platforms, resolves symlinks)
73    if let Ok(canonical) = path.canonicalize() {
74        return canonical;
75    }
76
77    // Fallback: make absolute without resolving symlinks (WASI-compatible)
78    if path.is_absolute() {
79        path.to_path_buf()
80    } else if let Ok(cwd) = std::env::current_dir() {
81        // Join with current directory and clean up the path
82        let mut result = cwd;
83        for component in path.components() {
84            match component {
85                std::path::Component::ParentDir => {
86                    result.pop();
87                }
88                std::path::Component::Normal(s) => {
89                    result.push(s);
90                }
91                std::path::Component::CurDir => {}
92                std::path::Component::RootDir => {
93                    result = PathBuf::from("/");
94                }
95                std::path::Component::Prefix(p) => {
96                    result = PathBuf::from(p.as_os_str());
97                }
98            }
99        }
100        result
101    } else {
102        // Last resort: just return the path as-is
103        path.to_path_buf()
104    }
105}
106
107/// Errors that can occur during loading.
108#[derive(Debug, Error)]
109pub enum LoadError {
110    /// IO error reading a file.
111    #[error("failed to read file {path}: {source}")]
112    Io {
113        /// The path that failed to read.
114        path: PathBuf,
115        /// The underlying IO error.
116        #[source]
117        source: std::io::Error,
118    },
119
120    /// Include cycle detected.
121    ///
122    /// The Display string intentionally begins with `Duplicate filename
123    /// parsed:` to match Python beancount's wording for the same
124    /// condition. The pta-standards `include-cycle-detection`
125    /// conformance test asserts on the substring `"Duplicate filename"`,
126    /// so this wording is load-bearing (#765). The full cycle path is
127    /// preserved in a trailing parenthetical for debuggability.
128    #[error(
129        "Duplicate filename parsed: \"{}\" (include cycle: {})",
130        .cycle.last().map_or("", String::as_str),
131        .cycle.join(" -> ")
132    )]
133    IncludeCycle {
134        /// The cycle of file paths. The last element is the
135        /// re-encountered filename (equal to one of the earlier
136        /// entries), and it's the one quoted in the `"Duplicate
137        /// filename parsed:"` prefix.
138        cycle: Vec<String>,
139    },
140
141    /// Parse errors occurred.
142    #[error("parse errors in {path}")]
143    ParseErrors {
144        /// The file with parse errors.
145        path: PathBuf,
146        /// The parse errors.
147        errors: Vec<ParseError>,
148    },
149
150    /// Path traversal attempt detected.
151    #[error("path traversal not allowed: {include_path} escapes base directory {base_dir}")]
152    PathTraversal {
153        /// The include path that attempted traversal.
154        include_path: String,
155        /// The base directory.
156        base_dir: PathBuf,
157    },
158
159    /// GPG decryption failed.
160    #[error("failed to decrypt {path}: {message}")]
161    Decryption {
162        /// The encrypted file path.
163        path: PathBuf,
164        /// Error message from GPG.
165        message: String,
166    },
167
168    /// Glob pattern did not match any files.
169    #[error("include pattern \"{pattern}\" does not match any files")]
170    GlobNoMatch {
171        /// The glob pattern that matched nothing.
172        pattern: String,
173    },
174
175    /// Glob pattern expansion failed.
176    #[error("failed to expand include pattern \"{pattern}\": {message}")]
177    GlobError {
178        /// The glob pattern that failed.
179        pattern: String,
180        /// The error message.
181        message: String,
182    },
183}
184
185/// Result of loading a beancount file.
186#[derive(Debug)]
187pub struct LoadResult {
188    /// All directives from all files, in order.
189    pub directives: Vec<Spanned<Directive>>,
190    /// Parsed options.
191    pub options: Options,
192    /// Plugins to load.
193    pub plugins: Vec<Plugin>,
194    /// Source map for error reporting.
195    pub source_map: SourceMap,
196    /// All errors encountered during loading.
197    pub errors: Vec<LoadError>,
198    /// Display context for formatting numbers (tracks precision per currency).
199    pub display_context: DisplayContext,
200}
201
202/// A plugin directive.
203#[derive(Debug, Clone)]
204pub struct Plugin {
205    /// Plugin module name (with any `python:` prefix stripped).
206    pub name: String,
207    /// Optional configuration string.
208    pub config: Option<String>,
209    /// Source location.
210    pub span: Span,
211    /// File this plugin was declared in.
212    pub file_id: usize,
213    /// Whether the `python:` prefix was used to force Python execution.
214    pub force_python: bool,
215}
216
217/// Decrypt a GPG-encrypted file using the system `gpg` command.
218///
219/// This uses `gpg --batch --decrypt` which will use the user's
220/// GPG keyring and gpg-agent for passphrase handling.
221fn decrypt_gpg_file(path: &Path) -> Result<String, LoadError> {
222    let output = Command::new("gpg")
223        .args(["--batch", "--decrypt"])
224        .arg(path)
225        .output()
226        .map_err(|e| LoadError::Decryption {
227            path: path.to_path_buf(),
228            message: format!("failed to run gpg: {e}"),
229        })?;
230
231    if !output.status.success() {
232        return Err(LoadError::Decryption {
233            path: path.to_path_buf(),
234            message: String::from_utf8_lossy(&output.stderr).trim().to_string(),
235        });
236    }
237
238    String::from_utf8(output.stdout).map_err(|e| LoadError::Decryption {
239        path: path.to_path_buf(),
240        message: format!("decrypted content is not valid UTF-8: {e}"),
241    })
242}
243
244/// Beancount file loader.
245#[derive(Debug)]
246pub struct Loader {
247    /// Files that have been loaded (for cycle detection).
248    loaded_files: HashSet<PathBuf>,
249    /// Stack for cycle detection during loading (maintains order for error messages).
250    include_stack: Vec<PathBuf>,
251    /// Set for O(1) cycle detection (mirrors `include_stack`).
252    include_stack_set: HashSet<PathBuf>,
253    /// Root directory for path traversal protection.
254    /// If set, includes must resolve to paths within this directory.
255    root_dir: Option<PathBuf>,
256    /// Whether to enforce path traversal protection.
257    enforce_path_security: bool,
258    /// Filesystem abstraction for reading files.
259    fs: Box<dyn FileSystem>,
260}
261
262impl Default for Loader {
263    fn default() -> Self {
264        Self {
265            loaded_files: HashSet::new(),
266            include_stack: Vec::new(),
267            include_stack_set: HashSet::new(),
268            root_dir: None,
269            enforce_path_security: false,
270            fs: Box::new(DiskFileSystem),
271        }
272    }
273}
274
275impl Loader {
276    /// Create a new loader.
277    #[must_use]
278    pub fn new() -> Self {
279        Self::default()
280    }
281
282    /// Enable path traversal protection.
283    ///
284    /// When enabled, include directives cannot escape the root directory
285    /// of the main beancount file. This prevents malicious ledger files
286    /// from accessing sensitive files outside the ledger directory.
287    ///
288    /// # Example
289    ///
290    /// ```ignore
291    /// let result = Loader::new()
292    ///     .with_path_security(true)
293    ///     .load(Path::new("ledger.beancount"))?;
294    /// ```
295    #[must_use]
296    pub const fn with_path_security(mut self, enabled: bool) -> Self {
297        self.enforce_path_security = enabled;
298        self
299    }
300
301    /// Set a custom root directory for path security.
302    ///
303    /// By default, the root directory is the parent directory of the main file.
304    /// This method allows overriding that to a custom directory.
305    #[must_use]
306    pub fn with_root_dir(mut self, root: PathBuf) -> Self {
307        self.root_dir = Some(root);
308        self.enforce_path_security = true;
309        self
310    }
311
312    /// Set a custom filesystem for file loading.
313    ///
314    /// This allows using a virtual filesystem (e.g., for WASM) instead of
315    /// the default disk filesystem.
316    ///
317    /// # Example
318    ///
319    /// ```
320    /// use rustledger_loader::{Loader, VirtualFileSystem};
321    ///
322    /// let mut vfs = VirtualFileSystem::new();
323    /// vfs.add_file("main.beancount", "2024-01-01 open Assets:Bank USD");
324    ///
325    /// let loader = Loader::new().with_filesystem(Box::new(vfs));
326    /// ```
327    #[must_use]
328    pub fn with_filesystem(mut self, fs: Box<dyn FileSystem>) -> Self {
329        self.fs = fs;
330        self
331    }
332
333    /// Load a beancount file and all its includes.
334    ///
335    /// Uses parallel file parsing when multiple files are discovered via
336    /// include directives. The root file is parsed first to resolve the
337    /// include tree, then all included files are read and parsed in
338    /// parallel using rayon.
339    ///
340    /// # Errors
341    ///
342    /// Returns [`LoadError`] in the following cases:
343    ///
344    /// - [`LoadError::Io`] - Failed to read the file or an included file
345    /// - [`LoadError::IncludeCycle`] - Circular include detected
346    ///
347    /// Note: Parse errors and path traversal errors are collected in
348    /// [`LoadResult::errors`] rather than returned directly, allowing
349    /// partial results to be returned.
350    pub fn load(&mut self, path: &Path) -> Result<LoadResult, LoadError> {
351        let mut directives = Vec::new();
352        let mut options = Options::default();
353        let mut plugins = Vec::new();
354        let mut source_map = SourceMap::new();
355        let mut errors = Vec::new();
356
357        // Get normalized path (uses filesystem-specific normalization)
358        let canonical = self.fs.normalize(path);
359
360        // Set root directory for path security if enabled but not explicitly set
361        if self.enforce_path_security && self.root_dir.is_none() {
362            self.root_dir = canonical.parent().map(Path::to_path_buf);
363        }
364
365        // Phase 1: Parse the root file to discover includes.
366        // The root file is typically small (just includes + options).
367        self.load_recursive(
368            &canonical,
369            None,
370            &mut directives,
371            &mut options,
372            &mut plugins,
373            &mut source_map,
374            &mut errors,
375        )?;
376
377        // Build display context from directives and options
378        let display_context = build_display_context(&directives, &options);
379
380        Ok(LoadResult {
381            directives,
382            options,
383            plugins,
384            source_map,
385            errors,
386            display_context,
387        })
388    }
389
390    #[allow(clippy::too_many_arguments)]
391    fn load_recursive(
392        &mut self,
393        path: &Path,
394        pre_parsed: Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>,
395        directives: &mut Vec<Spanned<Directive>>,
396        options: &mut Options,
397        plugins: &mut Vec<Plugin>,
398        source_map: &mut SourceMap,
399        errors: &mut Vec<LoadError>,
400    ) -> Result<(), LoadError> {
401        // Allocate path once for reuse
402        let path_buf = path.to_path_buf();
403
404        // Check for cycles using O(1) HashSet lookup
405        if self.include_stack_set.contains(&path_buf) {
406            // `collect::<Vec<_>>()` on a chain of two `ExactSizeIterator`s
407            // preallocates the exact capacity via `size_hint`, so an
408            // explicit `Vec::with_capacity(...)` + `extend` + `push` is
409            // equivalent and noisier. This is the cycle-error cold path
410            // anyway — readability wins over micro-optimization.
411            let cycle: Vec<String> = self
412                .include_stack
413                .iter()
414                .map(|p| p.display().to_string())
415                .chain(std::iter::once(path.display().to_string()))
416                .collect();
417            return Err(LoadError::IncludeCycle { cycle });
418        }
419
420        // Check if already loaded
421        if self.loaded_files.contains(&path_buf) {
422            return Ok(());
423        }
424
425        // Use pre-parsed data if available (from parallel loading path),
426        // otherwise read and parse the file.
427        let (source, result) = if let Some(pre) = pre_parsed {
428            pre
429        } else {
430            let src: std::sync::Arc<str> = if self.fs.is_encrypted(path) {
431                decrypt_gpg_file(path)?.into()
432            } else {
433                self.fs.read(path)?
434            };
435            let parsed = rustledger_parser::parse(&src);
436            (src, parsed)
437        };
438
439        // Add to source map (Arc::clone is cheap - just increments refcount)
440        let file_id = source_map.add_file(path_buf.clone(), std::sync::Arc::clone(&source));
441
442        // Mark as loading (update both stack and set)
443        self.include_stack_set.insert(path_buf.clone());
444        self.include_stack.push(path_buf.clone());
445        self.loaded_files.insert(path_buf);
446
447        // Collect parse errors
448        if !result.errors.is_empty() {
449            errors.push(LoadError::ParseErrors {
450                path: path.to_path_buf(),
451                errors: result.errors,
452            });
453        }
454
455        // Process options
456        for (key, value, _span) in result.options {
457            options.set(&key, &value);
458        }
459
460        // Process plugins
461        for (name, config, span) in result.plugins {
462            // Check for "python:" prefix to force Python execution
463            let (actual_name, force_python) = if let Some(stripped) = name.strip_prefix("python:") {
464                (stripped.to_string(), true)
465            } else {
466                (name, false)
467            };
468            plugins.push(Plugin {
469                name: actual_name,
470                config,
471                span,
472                file_id,
473                force_python,
474            });
475        }
476
477        // Process includes (with glob pattern support)
478        let base_dir = path.parent().unwrap_or(Path::new("."));
479        for (include_path, _span) in &result.includes {
480            // Check if the include path contains glob metacharacters
481            // (check on include_path, not full_path, to avoid false positives from directory names)
482            let has_glob = include_path.contains('*')
483                || include_path.contains('?')
484                || include_path.contains('[');
485
486            let full_path = base_dir.join(include_path);
487
488            // Path traversal protection: check BEFORE glob expansion to avoid
489            // enumerating files outside the allowed root directory
490            if self.enforce_path_security
491                && let Some(ref root) = self.root_dir
492            {
493                // For glob patterns, extract and check the non-glob prefix
494                let path_to_check = if has_glob {
495                    // Find where the first glob metacharacter is
496                    let glob_start = include_path
497                        .find(['*', '?', '['])
498                        .unwrap_or(include_path.len());
499                    // Get the directory prefix before the glob
500                    let prefix = &include_path[..glob_start];
501                    let prefix_path = if let Some(last_sep) = prefix.rfind('/') {
502                        base_dir.join(&include_path[..=last_sep])
503                    } else {
504                        base_dir.to_path_buf()
505                    };
506                    normalize_path(&prefix_path)
507                } else {
508                    normalize_path(&full_path)
509                };
510
511                if !path_to_check.starts_with(root) {
512                    errors.push(LoadError::PathTraversal {
513                        include_path: include_path.clone(),
514                        base_dir: root.clone(),
515                    });
516                    continue;
517                }
518            }
519
520            let full_path_str = full_path.to_string_lossy();
521
522            // Expand glob patterns or use literal path
523            let paths_to_load: Vec<PathBuf> = if has_glob {
524                match self.fs.glob(&full_path_str) {
525                    Ok(matched) => matched,
526                    Err(e) => {
527                        errors.push(LoadError::GlobError {
528                            pattern: include_path.clone(),
529                            message: e,
530                        });
531                        continue;
532                    }
533                }
534            } else {
535                vec![full_path.clone()]
536            };
537
538            // Check if glob matched nothing
539            if has_glob && paths_to_load.is_empty() {
540                errors.push(LoadError::GlobNoMatch {
541                    pattern: include_path.clone(),
542                });
543                continue;
544            }
545
546            // Normalize and security-check all matched paths first.
547            let mut valid_paths = Vec::with_capacity(paths_to_load.len());
548            for matched_path in paths_to_load {
549                let canonical = self.fs.normalize(&matched_path);
550
551                // Security check: glob could match files outside root via symlinks
552                if self.enforce_path_security
553                    && let Some(ref root) = self.root_dir
554                    && !canonical.starts_with(root)
555                {
556                    errors.push(LoadError::PathTraversal {
557                        include_path: matched_path.to_string_lossy().into_owned(),
558                        base_dir: root.clone(),
559                    });
560                    continue;
561                }
562
563                valid_paths.push(canonical);
564            }
565
566            // Parallel optimization: when loading multiple sibling includes
567            // from disk, read and parse them in parallel. The expensive work
568            // (I/O + tokenize + parse) runs on rayon's thread pool while the
569            // main thread coordinates the include tree walk.
570            //
571            // Each file is read and parsed independently. Results are then
572            // merged sequentially to preserve include order and process any
573            // nested includes via recursive calls.
574            if valid_paths.len() > 1 && self.fs.supports_parallel_read() {
575                use rayon::prelude::*;
576
577                // Read + parse non-encrypted files in parallel, preserving
578                // original include order. Each entry becomes either
579                // Some((source, parsed)) for successful reads, or None for
580                // encrypted/failed files (which fall back to sequential).
581                //
582                // We keep the original index to merge results in order,
583                // ensuring option/directive precedence matches the declared
584                // include sequence.
585                let fs = &*self.fs;
586                let pre_parsed: Vec<Option<(std::sync::Arc<str>, rustledger_parser::ParseResult)>> =
587                    valid_paths
588                        .par_iter()
589                        .map(|p| {
590                            // Skip encrypted files — they need sequential GPG decryption
591                            if fs.is_encrypted(p) {
592                                return None;
593                            }
594                            // Read through the FileSystem trait so all I/O goes
595                            // through one code path (UTF-8 handling, error types, etc.)
596                            let source = fs.read(p).ok()?;
597                            let parsed = rustledger_parser::parse(&source);
598                            Some((source, parsed))
599                        })
600                        .collect();
601
602                // Merge in original include order. Files that were
603                // pre-parsed pass their data to load_recursive; files
604                // that weren't (encrypted or I/O error) are loaded
605                // sequentially as a fallback.
606                for (canonical, pre) in valid_paths.iter().zip(pre_parsed) {
607                    if let Err(e) = self.load_recursive(
608                        canonical, pre, directives, options, plugins, source_map, errors,
609                    ) {
610                        errors.push(e);
611                    }
612                }
613            } else {
614                // Sequential fallback: single file or VFS.
615                for canonical in valid_paths {
616                    if let Err(e) = self.load_recursive(
617                        &canonical, None, directives, options, plugins, source_map, errors,
618                    ) {
619                        errors.push(e);
620                    }
621                }
622            }
623        }
624
625        // Add directives from this file, setting the file_id
626        directives.extend(
627            result
628                .directives
629                .into_iter()
630                .map(|d| d.with_file_id(file_id)),
631        );
632
633        // Pop from stack and set
634        if let Some(popped) = self.include_stack.pop() {
635            self.include_stack_set.remove(&popped);
636        }
637
638        Ok(())
639    }
640}
641
642/// Build a display context from loaded directives and options.
643///
644/// This scans all directives for amounts and tracks the maximum precision seen
645/// for each currency. Fixed precisions from `option "display_precision"` override
646/// the inferred values.
647fn build_display_context(directives: &[Spanned<Directive>], options: &Options) -> DisplayContext {
648    let mut ctx = DisplayContext::new();
649
650    // Set render_commas from options
651    ctx.set_render_commas(options.render_commas);
652
653    // Scan directives for amounts to infer precision
654    for spanned in directives {
655        match &spanned.value {
656            Directive::Transaction(txn) => {
657                for posting in &txn.postings {
658                    // Units (IncompleteAmount)
659                    if let Some(ref units) = posting.units
660                        && let (Some(number), Some(currency)) = (units.number(), units.currency())
661                    {
662                        ctx.update(number, currency);
663                    }
664                    // Cost (CostSpec)
665                    if let Some(ref cost) = posting.cost
666                        && let (Some(number), Some(currency)) =
667                            (cost.number_per.or(cost.number_total), &cost.currency)
668                    {
669                        ctx.update(number, currency.as_str());
670                    }
671                    // Price annotations excluded — like Price directives, they
672                    // can have high-precision computed exchange rates that would
673                    // inflate the display precision of the target currency.
674                }
675            }
676            Directive::Balance(bal) => {
677                ctx.update(bal.amount.number, bal.amount.currency.as_str());
678                if let Some(tol) = bal.tolerance {
679                    ctx.update(tol, bal.amount.currency.as_str());
680                }
681            }
682            Directive::Price(_) => {
683                // Price amounts are excluded from display precision tracking.
684                // Price directives can have very high precision (e.g., computed
685                // exchange rates) which would inflate the display precision of
686                // the target currency for all other amounts.
687            }
688            Directive::Pad(_)
689            | Directive::Open(_)
690            | Directive::Close(_)
691            | Directive::Commodity(_)
692            | Directive::Event(_)
693            | Directive::Query(_)
694            | Directive::Note(_)
695            | Directive::Document(_)
696            | Directive::Custom(_) => {}
697        }
698    }
699
700    // Apply fixed precisions from options (these override inferred values)
701    for (currency, precision) in &options.display_precision {
702        ctx.set_fixed_precision(currency, *precision);
703    }
704
705    ctx
706}
707
708/// Load a beancount file without processing.
709///
710/// This is a convenience function that creates a loader and loads a single file.
711/// For fully processed results (booking, plugins, validation), use the
712/// [`load`] function with [`LoadOptions`] instead.
713#[cfg(not(any(feature = "booking", feature = "plugins", feature = "validation")))]
714pub fn load(path: &Path) -> Result<LoadResult, LoadError> {
715    Loader::new().load(path)
716}
717
718#[cfg(test)]
719mod tests {
720    use super::*;
721    use std::io::Write;
722    use tempfile::NamedTempFile;
723
724    #[test]
725    fn test_is_encrypted_file_gpg_extension() {
726        let fs = DiskFileSystem;
727        let path = Path::new("test.beancount.gpg");
728        assert!(fs.is_encrypted(path));
729    }
730
731    #[test]
732    fn test_is_encrypted_file_plain_beancount() {
733        let fs = DiskFileSystem;
734        let path = Path::new("test.beancount");
735        assert!(!fs.is_encrypted(path));
736    }
737
738    #[test]
739    fn test_is_encrypted_file_asc_with_pgp_header() {
740        let fs = DiskFileSystem;
741        let mut file = NamedTempFile::with_suffix(".asc").unwrap();
742        writeln!(file, "-----BEGIN PGP MESSAGE-----").unwrap();
743        writeln!(file, "some encrypted content").unwrap();
744        writeln!(file, "-----END PGP MESSAGE-----").unwrap();
745        file.flush().unwrap();
746
747        assert!(fs.is_encrypted(file.path()));
748    }
749
750    #[test]
751    fn test_is_encrypted_file_asc_without_pgp_header() {
752        let fs = DiskFileSystem;
753        let mut file = NamedTempFile::with_suffix(".asc").unwrap();
754        writeln!(file, "This is just a plain text file").unwrap();
755        writeln!(file, "with .asc extension but no PGP content").unwrap();
756        file.flush().unwrap();
757
758        assert!(!fs.is_encrypted(file.path()));
759    }
760
761    #[test]
762    fn test_decrypt_gpg_file_missing_gpg() {
763        // Create a fake .gpg file
764        let mut file = NamedTempFile::with_suffix(".gpg").unwrap();
765        writeln!(file, "fake encrypted content").unwrap();
766        file.flush().unwrap();
767
768        // This will fail because the content isn't actually GPG-encrypted
769        // (or gpg isn't installed, or there's no matching key)
770        let result = decrypt_gpg_file(file.path());
771        assert!(result.is_err());
772
773        if let Err(LoadError::Decryption { path, message }) = result {
774            assert_eq!(path, file.path().to_path_buf());
775            assert!(!message.is_empty());
776        } else {
777            panic!("Expected Decryption error");
778        }
779    }
780
781    #[test]
782    fn test_plugin_force_python_prefix() {
783        let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
784        writeln!(file, r#"plugin "python:my_plugin""#).unwrap();
785        writeln!(file, r#"plugin "regular_plugin""#).unwrap();
786        file.flush().unwrap();
787
788        let result = Loader::new().load(file.path()).unwrap();
789
790        assert_eq!(result.plugins.len(), 2);
791
792        // First plugin should have force_python = true and name without prefix
793        assert_eq!(result.plugins[0].name, "my_plugin");
794        assert!(result.plugins[0].force_python);
795
796        // Second plugin should have force_python = false
797        assert_eq!(result.plugins[1].name, "regular_plugin");
798        assert!(!result.plugins[1].force_python);
799    }
800
801    #[test]
802    fn test_plugin_force_python_with_config() {
803        let mut file = NamedTempFile::with_suffix(".beancount").unwrap();
804        writeln!(file, r#"plugin "python:my_plugin" "config_value""#).unwrap();
805        file.flush().unwrap();
806
807        let result = Loader::new().load(file.path()).unwrap();
808
809        assert_eq!(result.plugins.len(), 1);
810        assert_eq!(result.plugins[0].name, "my_plugin");
811        assert!(result.plugins[0].force_python);
812        assert_eq!(result.plugins[0].config, Some("config_value".to_string()));
813    }
814
815    #[test]
816    fn test_virtual_filesystem_include_resolution() {
817        // Create a virtual filesystem with multiple files
818        let mut vfs = VirtualFileSystem::new();
819        vfs.add_file(
820            "main.beancount",
821            r#"
822include "accounts.beancount"
823
8242024-01-15 * "Coffee"
825  Expenses:Food  5.00 USD
826  Assets:Bank   -5.00 USD
827"#,
828        );
829        vfs.add_file(
830            "accounts.beancount",
831            r"
8322024-01-01 open Assets:Bank USD
8332024-01-01 open Expenses:Food USD
834",
835        );
836
837        // Load with virtual filesystem
838        let result = Loader::new()
839            .with_filesystem(Box::new(vfs))
840            .load(Path::new("main.beancount"))
841            .unwrap();
842
843        // Should have 3 directives: 2 opens + 1 transaction
844        assert_eq!(result.directives.len(), 3);
845        assert!(result.errors.is_empty());
846
847        // Verify directive types
848        let directive_types: Vec<_> = result
849            .directives
850            .iter()
851            .map(|d| match &d.value {
852                rustledger_core::Directive::Open(_) => "open",
853                rustledger_core::Directive::Transaction(_) => "txn",
854                _ => "other",
855            })
856            .collect();
857        assert_eq!(directive_types, vec!["open", "open", "txn"]);
858    }
859
860    #[test]
861    fn test_virtual_filesystem_nested_includes() {
862        // Test deeply nested includes
863        let mut vfs = VirtualFileSystem::new();
864        vfs.add_file("main.beancount", r#"include "level1.beancount""#);
865        vfs.add_file(
866            "level1.beancount",
867            r#"
868include "level2.beancount"
8692024-01-01 open Assets:Level1 USD
870"#,
871        );
872        vfs.add_file("level2.beancount", "2024-01-01 open Assets:Level2 USD");
873
874        let result = Loader::new()
875            .with_filesystem(Box::new(vfs))
876            .load(Path::new("main.beancount"))
877            .unwrap();
878
879        // Should have 2 open directives from nested includes
880        assert_eq!(result.directives.len(), 2);
881        assert!(result.errors.is_empty());
882    }
883
884    #[test]
885    fn test_virtual_filesystem_missing_include() {
886        let mut vfs = VirtualFileSystem::new();
887        vfs.add_file("main.beancount", r#"include "nonexistent.beancount""#);
888
889        let result = Loader::new()
890            .with_filesystem(Box::new(vfs))
891            .load(Path::new("main.beancount"))
892            .unwrap();
893
894        // Should have an error for missing file
895        assert!(!result.errors.is_empty());
896        let error_msg = result.errors[0].to_string();
897        assert!(error_msg.contains("not found") || error_msg.contains("Io"));
898    }
899
900    #[test]
901    fn test_virtual_filesystem_glob_include() {
902        let mut vfs = VirtualFileSystem::new();
903        vfs.add_file(
904            "main.beancount",
905            r#"
906include "transactions/*.beancount"
907
9082024-01-01 open Assets:Bank USD
909"#,
910        );
911        vfs.add_file(
912            "transactions/2024.beancount",
913            r#"
9142024-01-01 open Expenses:Food USD
915
9162024-06-15 * "Groceries"
917  Expenses:Food  50.00 USD
918  Assets:Bank   -50.00 USD
919"#,
920        );
921        vfs.add_file(
922            "transactions/2025.beancount",
923            r#"
9242025-01-01 open Expenses:Rent USD
925
9262025-02-01 * "Rent"
927  Expenses:Rent  1000.00 USD
928  Assets:Bank   -1000.00 USD
929"#,
930        );
931        // This file should NOT be matched by the glob
932        vfs.add_file(
933            "other/ignored.beancount",
934            "2024-01-01 open Expenses:Other USD",
935        );
936
937        let result = Loader::new()
938            .with_filesystem(Box::new(vfs))
939            .load(Path::new("main.beancount"))
940            .unwrap();
941
942        // Should have: 1 open from main + 2 opens from transactions + 2 txns
943        let opens = result
944            .directives
945            .iter()
946            .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
947            .count();
948        assert_eq!(
949            opens, 3,
950            "expected 3 open directives (1 main + 2 transactions)"
951        );
952
953        let txns = result
954            .directives
955            .iter()
956            .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
957            .count();
958        assert_eq!(txns, 2, "expected 2 transactions from glob-matched files");
959
960        assert!(
961            result.errors.is_empty(),
962            "expected no errors, got: {:?}",
963            result.errors
964        );
965    }
966
967    #[test]
968    fn test_virtual_filesystem_glob_dot_slash_prefix() {
969        let mut vfs = VirtualFileSystem::new();
970        vfs.add_file(
971            "main.beancount",
972            r#"
973include "./transactions/*.beancount"
974
9752024-01-01 open Assets:Bank USD
976"#,
977        );
978        vfs.add_file(
979            "transactions/2024.beancount",
980            r#"
9812024-01-01 open Expenses:Food USD
982
9832024-06-15 * "Groceries"
984  Expenses:Food  50.00 USD
985  Assets:Bank   -50.00 USD
986"#,
987        );
988        vfs.add_file(
989            "transactions/2025.beancount",
990            r#"
9912025-01-01 open Expenses:Rent USD
992
9932025-02-01 * "Rent"
994  Expenses:Rent  1000.00 USD
995  Assets:Bank   -1000.00 USD
996"#,
997        );
998
999        let result = Loader::new()
1000            .with_filesystem(Box::new(vfs))
1001            .load(Path::new("main.beancount"))
1002            .unwrap();
1003
1004        // Should have: 1 open from main + 2 opens from transactions + 2 txns
1005        let opens = result
1006            .directives
1007            .iter()
1008            .filter(|d| matches!(d.value, rustledger_core::Directive::Open(_)))
1009            .count();
1010        assert_eq!(
1011            opens, 3,
1012            "expected 3 open directives (1 main + 2 transactions), ./ prefix should be normalized"
1013        );
1014
1015        let txns = result
1016            .directives
1017            .iter()
1018            .filter(|d| matches!(d.value, rustledger_core::Directive::Transaction(_)))
1019            .count();
1020        assert_eq!(
1021            txns, 2,
1022            "expected 2 transactions from glob-matched files despite ./ prefix"
1023        );
1024
1025        assert!(
1026            result.errors.is_empty(),
1027            "expected no errors, got: {:?}",
1028            result.errors
1029        );
1030    }
1031
1032    #[test]
1033    fn test_virtual_filesystem_glob_no_match() {
1034        let mut vfs = VirtualFileSystem::new();
1035        vfs.add_file("main.beancount", r#"include "nonexistent/*.beancount""#);
1036
1037        let result = Loader::new()
1038            .with_filesystem(Box::new(vfs))
1039            .load(Path::new("main.beancount"))
1040            .unwrap();
1041
1042        // Should have a GlobNoMatch error
1043        let has_glob_error = result
1044            .errors
1045            .iter()
1046            .any(|e| matches!(e, LoadError::GlobNoMatch { .. }));
1047        assert!(
1048            has_glob_error,
1049            "expected GlobNoMatch error, got: {:?}",
1050            result.errors
1051        );
1052    }
1053}