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