use crate::LoadError;
use std::collections::HashMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::Arc;
pub trait FileSystem: Send + Sync + std::fmt::Debug {
fn read(&self, path: &Path) -> Result<Arc<str>, LoadError>;
fn exists(&self, path: &Path) -> bool;
fn is_encrypted(&self, path: &Path) -> bool;
fn normalize(&self, path: &Path) -> PathBuf;
fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
let _ = pattern;
Err("glob is not supported by this filesystem".to_string())
}
}
#[derive(Debug, Default, Clone)]
pub struct DiskFileSystem;
impl FileSystem for DiskFileSystem {
fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
let bytes = fs::read(path).map_err(|e| LoadError::Io {
path: path.to_path_buf(),
source: e,
})?;
let content = match String::from_utf8(bytes) {
Ok(s) => s,
Err(e) => String::from_utf8_lossy(e.as_bytes()).into_owned(),
};
Ok(content.into())
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn is_encrypted(&self, path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some("gpg") => true,
Some("asc") => {
if let Ok(content) = fs::read_to_string(path) {
let check_len = 1024.min(content.len());
content[..check_len].contains("-----BEGIN PGP MESSAGE-----")
} else {
false
}
}
_ => false,
}
}
fn normalize(&self, path: &Path) -> PathBuf {
if let Ok(canonical) = path.canonicalize() {
return canonical;
}
if path.is_absolute() {
path.to_path_buf()
} else if let Ok(cwd) = std::env::current_dir() {
let mut result = cwd;
for component in path.components() {
match component {
std::path::Component::ParentDir => {
result.pop();
}
std::path::Component::Normal(s) => {
result.push(s);
}
std::path::Component::CurDir => {}
std::path::Component::RootDir => {
result = PathBuf::from("/");
}
std::path::Component::Prefix(p) => {
result = PathBuf::from(p.as_os_str());
}
}
}
result
} else {
path.to_path_buf()
}
}
fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
let entries = glob::glob(pattern).map_err(|e| e.to_string())?;
let mut matched: Vec<PathBuf> = entries.filter_map(Result::ok).collect();
matched.sort();
Ok(matched)
}
}
#[derive(Debug, Default, Clone)]
pub struct VirtualFileSystem {
files: HashMap<PathBuf, Arc<str>>,
}
impl VirtualFileSystem {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_file(&mut self, path: impl AsRef<Path>, content: impl Into<String>) {
let normalized = normalize_vfs_path(path.as_ref());
self.files.insert(normalized, content.into().into());
}
pub fn add_files(
&mut self,
files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
) {
for (path, content) in files {
self.add_file(path, content);
}
}
#[must_use]
pub fn from_files(
files: impl IntoIterator<Item = (impl AsRef<Path>, impl Into<String>)>,
) -> Self {
let mut vfs = Self::new();
vfs.add_files(files);
vfs
}
#[must_use]
pub fn len(&self) -> usize {
self.files.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
}
impl FileSystem for VirtualFileSystem {
fn read(&self, path: &Path) -> Result<Arc<str>, LoadError> {
let normalized = normalize_vfs_path(path);
self.files
.get(&normalized)
.cloned()
.ok_or_else(|| LoadError::Io {
path: path.to_path_buf(),
source: std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("file not found in virtual filesystem: {}", path.display()),
),
})
}
fn exists(&self, path: &Path) -> bool {
let normalized = normalize_vfs_path(path);
self.files.contains_key(&normalized)
}
fn is_encrypted(&self, _path: &Path) -> bool {
false
}
fn normalize(&self, path: &Path) -> PathBuf {
normalize_vfs_path(path)
}
fn glob(&self, pattern: &str) -> Result<Vec<PathBuf>, String> {
let normalized = pattern.replace('\\', "/");
let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
let glob_pattern = glob::Pattern::new(normalized).map_err(|e| e.to_string())?;
let mut matched: Vec<PathBuf> = self
.files
.keys()
.filter(|path| glob_pattern.matches_path(path))
.cloned()
.collect();
matched.sort();
Ok(matched)
}
}
fn normalize_vfs_path(path: &Path) -> PathBuf {
let path_str = path.to_string_lossy();
let normalized = path_str.replace('\\', "/");
let normalized = normalized.strip_prefix("./").unwrap_or(&normalized);
let mut components = Vec::new();
for part in normalized.split('/') {
match part {
"" | "." => {}
".." => {
if !components.is_empty() && components.last() != Some(&"..") {
components.pop();
} else {
components.push("..");
}
}
_ => components.push(part),
}
}
if components.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(components.join("/"))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_normalize_vfs_path() {
assert_eq!(
normalize_vfs_path(Path::new("foo/bar")),
PathBuf::from("foo/bar")
);
assert_eq!(
normalize_vfs_path(Path::new("./foo/bar")),
PathBuf::from("foo/bar")
);
assert_eq!(
normalize_vfs_path(Path::new("foo/../bar")),
PathBuf::from("bar")
);
assert_eq!(
normalize_vfs_path(Path::new("foo/./bar")),
PathBuf::from("foo/bar")
);
assert_eq!(
normalize_vfs_path(Path::new("foo\\bar")),
PathBuf::from("foo/bar")
);
}
#[test]
fn test_virtual_filesystem_basic() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file("test.beancount", "2024-01-01 open Assets:Bank USD");
assert!(vfs.exists(Path::new("test.beancount")));
assert!(!vfs.exists(Path::new("nonexistent.beancount")));
let content = vfs.read(Path::new("test.beancount")).unwrap();
assert_eq!(&*content, "2024-01-01 open Assets:Bank USD");
}
#[test]
fn test_virtual_filesystem_path_normalization() {
let mut vfs = VirtualFileSystem::new();
vfs.add_file("foo/bar.beancount", "content");
assert!(vfs.exists(Path::new("foo/bar.beancount")));
assert!(vfs.exists(Path::new("./foo/bar.beancount")));
let content = vfs.read(Path::new("./foo/bar.beancount")).unwrap();
assert_eq!(&*content, "content");
}
#[test]
fn test_virtual_filesystem_not_encrypted() {
let vfs = VirtualFileSystem::new();
assert!(!vfs.is_encrypted(Path::new("test.gpg")));
assert!(!vfs.is_encrypted(Path::new("test.asc")));
}
#[test]
fn test_virtual_filesystem_from_files() {
let vfs = VirtualFileSystem::from_files([
("a.beancount", "content a"),
("b.beancount", "content b"),
]);
assert_eq!(vfs.len(), 2);
assert!(vfs.exists(Path::new("a.beancount")));
assert!(vfs.exists(Path::new("b.beancount")));
}
}