Skip to main content

rustledger_loader/
vfs.rs

1//! Virtual filesystem abstraction for platform-agnostic file loading.
2//!
3//! This module provides a trait for abstracting file system operations,
4//! enabling the loader to work with both real filesystems and in-memory
5//! file maps (useful for WASM environments).
6
7use crate::LoadError;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13/// Abstract file system interface for file loading.
14///
15/// This trait allows the loader to work with different file system backends:
16/// - [`DiskFileSystem`]: Reads from the actual filesystem (default)
17/// - [`VirtualFileSystem`]: Reads from an in-memory file map (for WASM)
18pub trait FileSystem: Send + Sync + std::fmt::Debug {
19    /// Read file content at the given path.
20    ///
21    /// # Errors
22    ///
23    /// Returns [`LoadError::Io`] if the file cannot be read.
24    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError>;
25
26    /// Check if a file exists at the given path.
27    fn exists(&self, path: &Path) -> bool;
28
29    /// Check if a path is a GPG-encrypted file.
30    ///
31    /// For virtual filesystems, this always returns false since
32    /// encrypted files should be decrypted before being added.
33    fn is_encrypted(&self, path: &Path) -> bool;
34
35    /// Normalize a path for this filesystem.
36    ///
37    /// For disk filesystems, this makes paths absolute.
38    /// For virtual filesystems, this just cleans up the path.
39    fn normalize(&self, path: &Path) -> PathBuf;
40
41    /// Expand a glob pattern and return matching paths.
42    ///
43    /// # Errors
44    ///
45    /// Returns an error string if the pattern is invalid.
46    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
47        let _ = pattern;
48        Err("glob is not supported by this filesystem".to_string())
49    }
50}
51
52/// Default filesystem that reads from disk.
53///
54/// This is the standard implementation used by the CLI and other
55/// filesystem-based tools.
56#[derive(Debug, Default, Clone)]
57pub struct DiskFileSystem;
58
59impl FileSystem for DiskFileSystem {
60    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
61        let bytes = fs::read(path).map_err(|e| LoadError::Io {
62            path: path.to_path_buf(),
63            source: e,
64        })?;
65
66        // Try zero-copy conversion first (common case), fall back to lossy
67        let content = match String::from_utf8(bytes) {
68            Ok(s) => s,
69            Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
70        };
71
72        Ok(content.into())
73    }
74
75    fn exists(&self, path: &Path) -> bool {
76        path.exists()
77    }
78
79    fn is_encrypted(&self, path: &Path) -> bool {
80        match path.extension().and_then(|e| e.to_str()) {
81            Some("gpg") => true,
82            Some("asc") => {
83                // Check for PGP header in first 1024 bytes
84                if let Ok(content) = fs::read_to_string(path) {
85                    let check_len = 1024.min(content.len());
86                    content[..check_len].contains("-----BEGIN PGP MESSAGE-----")
87                } else {
88                    false
89                }
90            }
91            _ => false,
92        }
93    }
94
95    fn normalize(&self, path: &Path) -> PathBuf {
96        // Try canonicalize first (works on most platforms, resolves symlinks)
97        if let Ok(canonical) = path.canonicalize() {
98            return canonical;
99        }
100
101        // Fallback: make absolute without resolving symlinks (WASI-compatible)
102        if path.is_absolute() {
103            path.to_path_buf()
104        } else if let Ok(cwd) = std::env::current_dir() {
105            // Join with current directory and clean up the path
106            let mut result = cwd;
107            for component in path.components() {
108                match component {
109                    std::path::Component::ParentDir => {
110                        result.pop();
111                    }
112                    std::path::Component::Normal(s) => {
113                        result.push(s);
114                    }
115                    std::path::Component::CurDir => {}
116                    std::path::Component::RootDir => {
117                        result = PathBuf::from("/");
118                    }
119                    std::path::Component::Prefix(p) => {
120                        result = PathBuf::from(p.as_os_str());
121                    }
122                }
123            }
124            result
125        } else {
126            // Last resort: just return the path as-is
127            path.to_path_buf()
128        }
129    }
130
131    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
132        let entries = glob::glob(pattern).map_err(|e| e.to_string())?;
133        // Skip entries that error (e.g., permission denied) rather than
134        // failing the entire glob. The loader will catch missing/unreadable
135        // files later when it tries to read them.
136        let mut matched: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
137        matched.sort();
138        Ok(matched)
139    }
140}
141
142/// In-memory virtual filesystem for WASM and testing.
143///
144/// This implementation stores files in a `HashMap`, allowing the loader
145/// to resolve includes without actual filesystem access. This is essential
146/// for WASM environments where filesystem access is not available.
147///
148/// # Example
149///
150/// ```
151/// use rustledger_loader::VirtualFileSystem;
152/// use std::path::PathBuf;
153///
154/// let mut vfs = VirtualFileSystem::new();
155/// vfs.add_file("main.beancount", "include \"accounts.beancount\"");
156/// vfs.add_file("accounts.beancount", "2024-01-01 open Assets:Bank USD");
157/// ```
158#[derive(Debug, Default, Clone)]
159pub struct VirtualFileSystem {
160    files: HashMap<PathBuf, Arc<str>>,
161}
162
163impl VirtualFileSystem {
164    /// Create a new empty virtual filesystem.
165    #[must_use]
166    pub fn new() -> Self {
167        Self::default()
168    }
169
170    /// Add a file to the virtual filesystem.
171    ///
172    /// The path is normalized to handle different path separators
173    /// and relative paths consistently.
174    pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) {
175        let normalized = normalize_vfs_path(path.as_ref());
176        self.files.insert(normalized, content.into().into());
177    }
178
179    /// Add multiple files from a map.
180    ///
181    /// This is a convenience method for adding many files at once.
182    pub fn add_files(
183        &mut self,
184        files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
185    ) {
186        for (path, content) in files {
187            self.add_file(path, content);
188        }
189    }
190
191    /// Create a virtual filesystem from a map of files.
192    #[must_use]
193    pub fn from_files(
194        files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
195    ) -> Self {
196        let mut vfs = Self::new();
197        vfs.add_files(files);
198        vfs
199    }
200
201    /// Get the number of files in the virtual filesystem.
202    #[must_use]
203    pub fn len(&self) -> usize {
204        self.files.len()
205    }
206
207    /// Check if the virtual filesystem is empty.
208    #[must_use]
209    pub fn is_empty(&self) -> bool {
210        self.files.is_empty()
211    }
212}
213
214impl FileSystem for VirtualFileSystem {
215    fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
216        let normalized = normalize_vfs_path(path);
217
218        self.files
219            .get(&normalized)
220            .cloned()
221            .ok_or_else(|| LoadError::Io {
222                path: path.to_path_buf(),
223                source: std::io::Error::new(
224                    std::io::ErrorKind::NotFound,
225                    format!("file not found in virtual filesystem: {}", path.display()),
226                ),
227            })
228    }
229
230    fn exists(&self, path: &Path) -> bool {
231        let normalized = normalize_vfs_path(path);
232        self.files.contains_key(&normalized)
233    }
234
235    fn is_encrypted(&self, _path: &Path) -> bool {
236        // Virtual filesystem doesn't support encrypted files
237        // Users should decrypt before adding to VFS
238        false
239    }
240
241    fn normalize(&self, path: &Path) -> PathBuf {
242        // For virtual filesystem, just clean up the path without making it absolute
243        normalize_vfs_path(path)
244    }
245
246    fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
247        // Normalize the pattern the same way stored keys are normalized,
248        // so that backslashes or leading "./" in the pattern still match.
249        let normalized = pattern.replace('\\', "/");
250        let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
251        let glob_pattern = glob::Pattern::new(normalized).map_err(|e| e.to_string())?;
252        let mut matched: Vec<PathBuf> = self
253            .files
254            .keys()
255            .filter(|path| glob_pattern.matches_path(path))
256            .cloned()
257            .collect();
258        matched.sort();
259        Ok(matched)
260    }
261}
262
263/// Normalize a path for virtual filesystem storage and lookup.
264///
265/// This handles:
266/// - Converting backslashes to forward slashes
267/// - Removing leading `./`
268/// - Simplifying `..` components where possible
269fn normalize_vfs_path(path: &Path) -> PathBuf {
270    let path_str = path.to_string_lossy();
271
272    // Convert backslashes to forward slashes
273    let normalized = path_str.replace('\\', "/");
274
275    // Remove leading ./
276    let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
277
278    // Build normalized path
279    let mut components = Vec::new();
280    for part in normalized.split('/') {
281        match part {
282            "" | "." => {}
283            ".." => {
284                // Only pop if we have non-root components
285                if !components.is_empty() && components.last() != Some(&"..") {
286                    components.pop();
287                } else {
288                    components.push("..");
289                }
290            }
291            _ => components.push(part),
292        }
293    }
294
295    if components.is_empty() {
296        PathBuf::from(".")
297    } else {
298        PathBuf::from(components.join("/"))
299    }
300}
301
302#[cfg(test)]
303mod tests {
304    use super::*;
305
306    #[test]
307    fn test_normalize_vfs_path() {
308        assert_eq!(
309            normalize_vfs_path(Path::new("foo/bar")),
310            PathBuf::from("foo/bar")
311        );
312        assert_eq!(
313            normalize_vfs_path(Path::new("./foo/bar")),
314            PathBuf::from("foo/bar")
315        );
316        assert_eq!(
317            normalize_vfs_path(Path::new("foo/../bar")),
318            PathBuf::from("bar")
319        );
320        assert_eq!(
321            normalize_vfs_path(Path::new("foo/./bar")),
322            PathBuf::from("foo/bar")
323        );
324        assert_eq!(
325            normalize_vfs_path(Path::new("foo\\bar")),
326            PathBuf::from("foo/bar")
327        );
328    }
329
330    #[test]
331    fn test_virtual_filesystem_basic() {
332        let mut vfs = VirtualFileSystem::new();
333        vfs.add_file("test.beancount", "2024-01-01 open Assets:Bank USD");
334
335        assert!(vfs.exists(Path::new("test.beancount")));
336        assert!(!vfs.exists(Path::new("nonexistent.beancount")));
337
338        let content = vfs.read(Path::new("test.beancount")).unwrap();
339        assert_eq!(&*content, "2024-01-01 open Assets:Bank USD");
340    }
341
342    #[test]
343    fn test_virtual_filesystem_path_normalization() {
344        let mut vfs = VirtualFileSystem::new();
345        vfs.add_file("foo/bar.beancount", "content");
346
347        // Should find with normalized path
348        assert!(vfs.exists(Path::new("foo/bar.beancount")));
349        assert!(vfs.exists(Path::new("./foo/bar.beancount")));
350
351        // Content should be accessible
352        let content = vfs.read(Path::new("./foo/bar.beancount")).unwrap();
353        assert_eq!(&*content, "content");
354    }
355
356    #[test]
357    fn test_virtual_filesystem_not_encrypted() {
358        let vfs = VirtualFileSystem::new();
359
360        // Virtual filesystem never reports files as encrypted
361        assert!(!vfs.is_encrypted(Path::new("test.gpg")));
362        assert!(!vfs.is_encrypted(Path::new("test.asc")));
363    }
364
365    #[test]
366    fn test_virtual_filesystem_from_files() {
367        let vfs = VirtualFileSystem::from_files([
368            ("a.beancount", "content a"),
369            ("b.beancount", "content b"),
370        ]);
371
372        assert_eq!(vfs.len(), 2);
373        assert!(vfs.exists(Path::new("a.beancount")));
374        assert!(vfs.exists(Path::new("b.beancount")));
375    }
376}