use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub trait FileSystem: Send + Sync {
fn read_to_string(&self, path: &Path) -> Result<String>;
fn write(&self, path: &Path, contents: &str) -> Result<()>;
fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>>;
fn exists(&self, path: &Path) -> bool;
fn create_dir_all(&self, path: &Path) -> Result<()>;
}
#[derive(Debug, Clone, Default)]
pub struct RealFileSystem;
impl RealFileSystem {
#[must_use]
pub const fn new() -> Self {
Self
}
}
impl FileSystem for RealFileSystem {
fn read_to_string(&self, path: &Path) -> Result<String> {
std::fs::read_to_string(path).map_err(|source| Error::FileRead {
path: path.to_path_buf(),
source,
})
}
fn write(&self, path: &Path, contents: &str) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.exists() {
std::fs::create_dir_all(parent).map_err(|source| Error::FileWrite {
path: path.to_path_buf(),
source,
})?;
}
}
std::fs::write(path, contents).map_err(|source| Error::FileWrite {
path: path.to_path_buf(),
source,
})
}
fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
let full_pattern = base.join(pattern);
let pattern_str = full_pattern.to_string_lossy();
let entries: Vec<PathBuf> = glob::glob(&pattern_str)
.map_err(|e| Error::GlobPattern(e.to_string()))?
.filter_map(std::result::Result::ok)
.collect();
Ok(entries)
}
fn exists(&self, path: &Path) -> bool {
path.exists()
}
fn create_dir_all(&self, path: &Path) -> Result<()> {
std::fs::create_dir_all(path).map_err(|source| Error::FileWrite {
path: path.to_path_buf(),
source,
})
}
}
#[cfg(any(test, feature = "testing"))]
#[allow(clippy::expect_used)]
pub mod test_support {
use super::*;
use std::collections::HashMap;
use std::sync::{Arc, RwLock};
#[derive(Debug, Clone, Default)]
pub struct InMemoryFileSystem {
files: Arc<RwLock<HashMap<PathBuf, String>>>,
}
impl InMemoryFileSystem {
pub fn new() -> Self {
Self::default()
}
pub fn add_file(&self, path: impl AsRef<Path>, content: impl Into<String>) {
let mut files = self.files.write().expect("lock poisoned");
files.insert(path.as_ref().to_path_buf(), content.into());
}
pub fn files(&self) -> HashMap<PathBuf, String> {
self.files.read().expect("lock poisoned").clone()
}
}
impl FileSystem for InMemoryFileSystem {
fn read_to_string(&self, path: &Path) -> Result<String> {
let files = self.files.read().expect("lock poisoned");
files.get(path).cloned().ok_or_else(|| Error::FileRead {
path: path.to_path_buf(),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
})
}
fn write(&self, path: &Path, contents: &str) -> Result<()> {
let mut files = self.files.write().expect("lock poisoned");
files.insert(path.to_path_buf(), contents.to_string());
Ok(())
}
fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
let files = self.files.read().expect("lock poisoned");
let is_recursive = pattern.starts_with("**/");
let suffix = if is_recursive {
&pattern[3..] } else {
pattern
};
let paths: Vec<PathBuf> = files
.keys()
.filter(|path| {
if is_recursive {
path.starts_with(base)
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| matches_simple_pattern(n, suffix))
} else {
path.parent() == Some(base)
&& path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| matches_simple_pattern(n, pattern))
}
})
.cloned()
.collect();
Ok(paths)
}
fn exists(&self, path: &Path) -> bool {
let files = self.files.read().expect("lock poisoned");
files.contains_key(path)
}
fn create_dir_all(&self, _path: &Path) -> Result<()> {
Ok(())
}
}
fn matches_simple_pattern(name: &str, pattern: &str) -> bool {
if pattern == "*" {
true
} else if let Some(suffix) = pattern.strip_prefix("*.") {
name.ends_with(&format!(".{suffix}"))
} else {
name == pattern
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_in_memory_fs_read_write() {
let fs = InMemoryFileSystem::new();
let path = PathBuf::from("/test/file.txt");
fs.add_file(&path, "hello world");
let content = fs.read_to_string(&path).expect("should read");
assert_eq!(content, "hello world");
}
#[test]
fn test_in_memory_fs_glob() {
let fs = InMemoryFileSystem::new();
fs.add_file("/docs/adr/adr_0001.md", "content1");
fs.add_file("/docs/adr/adr_0002.md", "content2");
fs.add_file("/docs/adr/readme.txt", "readme");
let matches = fs
.glob(Path::new("/docs/adr"), "*.md")
.expect("should glob");
assert_eq!(matches.len(), 2);
assert!(matches.iter().all(|p| p.extension() == Some("md".as_ref())));
}
#[test]
fn test_in_memory_fs_read_nonexistent() {
let fs = InMemoryFileSystem::new();
let result = fs.read_to_string(Path::new("/nonexistent"));
assert!(result.is_err());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_real_fs_read_write() {
let temp = TempDir::new().expect("should create temp dir");
let path = temp.path().join("test.txt");
let fs = RealFileSystem::new();
fs.write(&path, "hello world").expect("should write");
let content = fs.read_to_string(&path).expect("should read");
assert_eq!(content, "hello world");
}
#[test]
fn test_real_fs_creates_parent_dirs() {
let temp = TempDir::new().expect("should create temp dir");
let path = temp.path().join("nested/dirs/test.txt");
let fs = RealFileSystem::new();
fs.write(&path, "content").expect("should write");
assert!(path.exists());
}
#[test]
fn test_real_fs_glob() {
let temp = TempDir::new().expect("should create temp dir");
let fs = RealFileSystem::new();
fs.write(&temp.path().join("adr_0001.md"), "content1")
.expect("write 1");
fs.write(&temp.path().join("adr_0002.md"), "content2")
.expect("write 2");
fs.write(&temp.path().join("readme.txt"), "readme")
.expect("write 3");
let matches = fs.glob(temp.path(), "*.md").expect("should glob");
assert_eq!(matches.len(), 2);
}
#[test]
fn test_real_fs_exists() {
let temp = TempDir::new().expect("should create temp dir");
let fs = RealFileSystem::new();
let path = temp.path().join("exists.txt");
assert!(!fs.exists(&path));
fs.write(&path, "content").expect("should write");
assert!(fs.exists(&path));
}
}