1use crate::LoadError;
8use std::collections::HashMap;
9use std::fs;
10use std::path::{Path, PathBuf};
11use std::sync::Arc;
12
13pub trait FileSystem: Send + Sync + std::fmt::Debug {
19 fn read(&self, path: &Path) -> Result<Arc<str>, LoadError>;
25
26 fn exists(&self, path: &Path) -> bool;
28
29 fn is_encrypted(&self, path: &Path) -> bool;
34
35 fn normalize(&self, path: &Path) -> PathBuf;
40
41 fn supports_parallel_read(&self) -> bool {
47 false
48 }
49
50 fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
56 let _ = pattern;
57 Err("glob is not supported by this filesystem".to_string())
58 }
59
60 fn decrypt(&self, path: &Path) -> Result<Arc<str>, LoadError> {
73 crate::decrypt_gpg_file(path).map(Arc::from)
74 }
75}
76
77#[derive(Debug, Default, Clone)]
82pub struct DiskFileSystem;
83
84impl FileSystem for DiskFileSystem {
85 fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
86 let bytes = fs::read(path).map_err(|e| LoadError::Io {
87 path: path.to_path_buf(),
88 source: e,
89 })?;
90
91 let content = match String::from_utf8(bytes) {
93 Ok(s) => s,
94 Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
95 };
96
97 Ok(content.into())
98 }
99
100 fn exists(&self, path: &Path) -> bool {
101 path.exists()
102 }
103
104 fn is_encrypted(&self, path: &Path) -> bool {
105 match path.extension().and_then(|e| e.to_str()) {
106 Some("gpg") => true,
107 Some("asc") => {
108 use std::io::Read;
111 let Ok(file) = std::fs::File::open(path) else {
112 return false;
113 };
114 let mut buf = [0u8; 1024];
115 let n = file.take(1024).read(&mut buf).unwrap_or(0);
116 let header = String::from_utf8_lossy(&buf[..n]);
117 header.contains("-----BEGIN PGP MESSAGE-----")
118 }
119 _ => false,
120 }
121 }
122
123 fn normalize(&self, path: &Path) -> PathBuf {
124 if let Ok(canonical) = path.canonicalize() {
126 return canonical;
127 }
128
129 if path.is_absolute() {
131 path.to_path_buf()
132 } else if let Ok(cwd) = std::env::current_dir() {
133 let mut result = cwd;
135 for component in path.components() {
136 match component {
137 std::path::Component::ParentDir => {
138 result.pop();
139 }
140 std::path::Component::Normal(s) => {
141 result.push(s);
142 }
143 std::path::Component::CurDir => {}
144 std::path::Component::RootDir => {
145 result = PathBuf::from("/");
146 }
147 std::path::Component::Prefix(p) => {
148 result = PathBuf::from(p.as_os_str());
149 }
150 }
151 }
152 result
153 } else {
154 path.to_path_buf()
156 }
157 }
158
159 fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
160 let entries = glob::glob(pattern).map_err(|e| e.to_string())?;
161 let mut matched: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
165 matched.sort();
166 Ok(matched)
167 }
168
169 fn supports_parallel_read(&self) -> bool {
170 true
171 }
172}
173
174#[derive(Debug, Default, Clone)]
191pub struct VirtualFileSystem {
192 files: HashMap<PathBuf, Arc<str>>,
193}
194
195impl VirtualFileSystem {
196 #[must_use]
198 pub fn new() -> Self {
199 Self::default()
200 }
201
202 pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) {
207 let normalized = normalize_vfs_path(path.as_ref());
208 self.files.insert(normalized, content.into().into());
209 }
210
211 pub fn add_files(
215 &mut self,
216 files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
217 ) {
218 for (path, content) in files {
219 self.add_file(path, content);
220 }
221 }
222
223 #[must_use]
225 pub fn from_files(
226 files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
227 ) -> Self {
228 let mut vfs = Self::new();
229 vfs.add_files(files);
230 vfs
231 }
232
233 #[must_use]
235 pub fn len(&self) -> usize {
236 self.files.len()
237 }
238
239 #[must_use]
241 pub fn is_empty(&self) -> bool {
242 self.files.is_empty()
243 }
244}
245
246impl FileSystem for VirtualFileSystem {
247 fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
248 let normalized = normalize_vfs_path(path);
249
250 self.files
251 .get(&normalized)
252 .cloned()
253 .ok_or_else(|| LoadError::Io {
254 path: path.to_path_buf(),
255 source: std::io::Error::new(
256 std::io::ErrorKind::NotFound,
257 format!("file not found in virtual filesystem: {}", path.display()),
258 ),
259 })
260 }
261
262 fn exists(&self, path: &Path) -> bool {
263 let normalized = normalize_vfs_path(path);
264 self.files.contains_key(&normalized)
265 }
266
267 fn is_encrypted(&self, _path: &Path) -> bool {
268 false
271 }
272
273 fn normalize(&self, path: &Path) -> PathBuf {
274 normalize_vfs_path(path)
276 }
277
278 fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
279 let normalized = pattern.replace('\\', "/");
282 let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
283 let glob_pattern = glob::Pattern::new(normalized).map_err(|e| e.to_string())?;
284 let mut matched: Vec<PathBuf> = self
285 .files
286 .keys()
287 .filter(|path| glob_pattern.matches_path(path))
288 .cloned()
289 .collect();
290 matched.sort();
291 Ok(matched)
292 }
293}
294
295fn normalize_vfs_path(path: &Path) -> PathBuf {
302 let path_str = path.to_string_lossy();
303
304 let normalized = path_str.replace('\\', "/");
306
307 let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
309
310 let mut components = Vec::new();
312 for part in normalized.split('/') {
313 match part {
314 "" | "." => {}
315 ".." => {
316 if !components.is_empty() && components.last() != Some(&"..") {
318 components.pop();
319 } else {
320 components.push("..");
321 }
322 }
323 _ => components.push(part),
324 }
325 }
326
327 if components.is_empty() {
328 PathBuf::from(".")
329 } else {
330 PathBuf::from(components.join("/"))
331 }
332}
333
334#[cfg(test)]
335mod tests {
336 use super::*;
337
338 #[test]
339 fn test_normalize_vfs_path() {
340 assert_eq!(
341 normalize_vfs_path(Path::new("foo/bar")),
342 PathBuf::from("foo/bar")
343 );
344 assert_eq!(
345 normalize_vfs_path(Path::new("./foo/bar")),
346 PathBuf::from("foo/bar")
347 );
348 assert_eq!(
349 normalize_vfs_path(Path::new("foo/../bar")),
350 PathBuf::from("bar")
351 );
352 assert_eq!(
353 normalize_vfs_path(Path::new("foo/./bar")),
354 PathBuf::from("foo/bar")
355 );
356 assert_eq!(
357 normalize_vfs_path(Path::new("foo\\bar")),
358 PathBuf::from("foo/bar")
359 );
360 }
361
362 #[test]
363 fn test_virtual_filesystem_basic() {
364 let mut vfs = VirtualFileSystem::new();
365 vfs.add_file("test.beancount", "2024-01-01 open Assets:Bank USD");
366
367 assert!(vfs.exists(Path::new("test.beancount")));
368 assert!(!vfs.exists(Path::new("nonexistent.beancount")));
369
370 let content = vfs.read(Path::new("test.beancount")).unwrap();
371 assert_eq!(&*content, "2024-01-01 open Assets:Bank USD");
372 }
373
374 #[test]
375 fn test_virtual_filesystem_path_normalization() {
376 let mut vfs = VirtualFileSystem::new();
377 vfs.add_file("foo/bar.beancount", "content");
378
379 assert!(vfs.exists(Path::new("foo/bar.beancount")));
381 assert!(vfs.exists(Path::new("./foo/bar.beancount")));
382
383 let content = vfs.read(Path::new("./foo/bar.beancount")).unwrap();
385 assert_eq!(&*content, "content");
386 }
387
388 #[test]
389 fn test_virtual_filesystem_not_encrypted() {
390 let vfs = VirtualFileSystem::new();
391
392 assert!(!vfs.is_encrypted(Path::new("test.gpg")));
394 assert!(!vfs.is_encrypted(Path::new("test.asc")));
395 }
396
397 #[test]
398 fn test_virtual_filesystem_from_files() {
399 let vfs = VirtualFileSystem::from_files([
400 ("a.beancount", "content a"),
401 ("b.beancount", "content b"),
402 ]);
403
404 assert_eq!(vfs.len(), 2);
405 assert!(vfs.exists(Path::new("a.beancount")));
406 assert!(vfs.exists(Path::new("b.beancount")));
407 }
408}