adrscope/infrastructure/
fs.rs1use std::path::{Path, PathBuf};
7
8use crate::error::{Error, Result};
9
10pub trait FileSystem: Send + Sync {
12 fn read_to_string(&self, path: &Path) -> Result<String>;
14
15 fn write(&self, path: &Path, contents: &str) -> Result<()>;
17
18 fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>>;
20
21 fn exists(&self, path: &Path) -> bool;
23
24 fn create_dir_all(&self, path: &Path) -> Result<()>;
26}
27
28#[derive(Debug, Clone, Default)]
30pub struct RealFileSystem;
31
32impl RealFileSystem {
33 #[must_use]
35 pub const fn new() -> Self {
36 Self
37 }
38}
39
40impl FileSystem for RealFileSystem {
41 fn read_to_string(&self, path: &Path) -> Result<String> {
42 std::fs::read_to_string(path).map_err(|source| Error::FileRead {
43 path: path.to_path_buf(),
44 source,
45 })
46 }
47
48 fn write(&self, path: &Path, contents: &str) -> Result<()> {
49 if let Some(parent) = path.parent() {
51 if !parent.exists() {
52 std::fs::create_dir_all(parent).map_err(|source| Error::FileWrite {
53 path: path.to_path_buf(),
54 source,
55 })?;
56 }
57 }
58
59 std::fs::write(path, contents).map_err(|source| Error::FileWrite {
60 path: path.to_path_buf(),
61 source,
62 })
63 }
64
65 fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
66 let full_pattern = base.join(pattern);
67 let pattern_str = full_pattern.to_string_lossy();
68
69 let entries: Vec<PathBuf> = glob::glob(&pattern_str)
70 .map_err(|e| Error::GlobPattern(e.to_string()))?
71 .filter_map(std::result::Result::ok)
72 .collect();
73
74 Ok(entries)
75 }
76
77 fn exists(&self, path: &Path) -> bool {
78 path.exists()
79 }
80
81 fn create_dir_all(&self, path: &Path) -> Result<()> {
82 std::fs::create_dir_all(path).map_err(|source| Error::FileWrite {
83 path: path.to_path_buf(),
84 source,
85 })
86 }
87}
88
89#[cfg(any(test, feature = "testing"))]
91#[allow(clippy::expect_used)]
92pub mod test_support {
93 use super::*;
94 use std::collections::HashMap;
95 use std::sync::{Arc, RwLock};
96
97 #[derive(Debug, Clone, Default)]
99 pub struct InMemoryFileSystem {
100 files: Arc<RwLock<HashMap<PathBuf, String>>>,
101 }
102
103 impl InMemoryFileSystem {
104 pub fn new() -> Self {
106 Self::default()
107 }
108
109 pub fn add_file(&self, path: impl AsRef<Path>, content: impl Into<String>) {
111 let mut files = self.files.write().expect("lock poisoned");
112 files.insert(path.as_ref().to_path_buf(), content.into());
113 }
114
115 pub fn files(&self) -> HashMap<PathBuf, String> {
117 self.files.read().expect("lock poisoned").clone()
118 }
119 }
120
121 impl FileSystem for InMemoryFileSystem {
122 fn read_to_string(&self, path: &Path) -> Result<String> {
123 let files = self.files.read().expect("lock poisoned");
124 files.get(path).cloned().ok_or_else(|| Error::FileRead {
125 path: path.to_path_buf(),
126 source: std::io::Error::new(std::io::ErrorKind::NotFound, "file not found"),
127 })
128 }
129
130 fn write(&self, path: &Path, contents: &str) -> Result<()> {
131 let mut files = self.files.write().expect("lock poisoned");
132 files.insert(path.to_path_buf(), contents.to_string());
133 Ok(())
134 }
135
136 fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
137 let files = self.files.read().expect("lock poisoned");
138
139 let is_recursive = pattern.starts_with("**/");
142 let suffix = if is_recursive {
143 &pattern[3..] } else {
145 pattern
146 };
147
148 let paths: Vec<PathBuf> = files
149 .keys()
150 .filter(|path| {
151 if is_recursive {
152 path.starts_with(base)
154 && path
155 .file_name()
156 .and_then(|n| n.to_str())
157 .is_some_and(|n| matches_simple_pattern(n, suffix))
158 } else {
159 path.parent() == Some(base)
161 && path
162 .file_name()
163 .and_then(|n| n.to_str())
164 .is_some_and(|n| matches_simple_pattern(n, pattern))
165 }
166 })
167 .cloned()
168 .collect();
169
170 Ok(paths)
171 }
172
173 fn exists(&self, path: &Path) -> bool {
174 let files = self.files.read().expect("lock poisoned");
175 files.contains_key(path)
176 }
177
178 fn create_dir_all(&self, _path: &Path) -> Result<()> {
179 Ok(())
181 }
182 }
183
184 fn matches_simple_pattern(name: &str, pattern: &str) -> bool {
186 if pattern == "*" {
187 true
188 } else if let Some(suffix) = pattern.strip_prefix("*.") {
189 name.ends_with(&format!(".{suffix}"))
190 } else {
191 name == pattern
192 }
193 }
194
195 #[cfg(test)]
196 mod tests {
197 use super::*;
198
199 #[test]
200 fn test_in_memory_fs_read_write() {
201 let fs = InMemoryFileSystem::new();
202 let path = PathBuf::from("/test/file.txt");
203
204 fs.add_file(&path, "hello world");
205 let content = fs.read_to_string(&path).expect("should read");
206 assert_eq!(content, "hello world");
207 }
208
209 #[test]
210 fn test_in_memory_fs_glob() {
211 let fs = InMemoryFileSystem::new();
212 fs.add_file("/docs/adr/adr_0001.md", "content1");
213 fs.add_file("/docs/adr/adr_0002.md", "content2");
214 fs.add_file("/docs/adr/readme.txt", "readme");
215
216 let matches = fs
217 .glob(Path::new("/docs/adr"), "*.md")
218 .expect("should glob");
219
220 assert_eq!(matches.len(), 2);
221 assert!(matches.iter().all(|p| p.extension() == Some("md".as_ref())));
222 }
223
224 #[test]
225 fn test_in_memory_fs_read_nonexistent() {
226 let fs = InMemoryFileSystem::new();
227 let result = fs.read_to_string(Path::new("/nonexistent"));
228 assert!(result.is_err());
229 }
230 }
231}
232
233#[cfg(test)]
234mod tests {
235 use super::*;
236 use tempfile::TempDir;
237
238 #[test]
239 fn test_real_fs_read_write() {
240 let temp = TempDir::new().expect("should create temp dir");
241 let path = temp.path().join("test.txt");
242
243 let fs = RealFileSystem::new();
244
245 fs.write(&path, "hello world").expect("should write");
246 let content = fs.read_to_string(&path).expect("should read");
247
248 assert_eq!(content, "hello world");
249 }
250
251 #[test]
252 fn test_real_fs_creates_parent_dirs() {
253 let temp = TempDir::new().expect("should create temp dir");
254 let path = temp.path().join("nested/dirs/test.txt");
255
256 let fs = RealFileSystem::new();
257 fs.write(&path, "content").expect("should write");
258
259 assert!(path.exists());
260 }
261
262 #[test]
263 fn test_real_fs_glob() {
264 let temp = TempDir::new().expect("should create temp dir");
265 let fs = RealFileSystem::new();
266
267 fs.write(&temp.path().join("adr_0001.md"), "content1")
268 .expect("write 1");
269 fs.write(&temp.path().join("adr_0002.md"), "content2")
270 .expect("write 2");
271 fs.write(&temp.path().join("readme.txt"), "readme")
272 .expect("write 3");
273
274 let matches = fs.glob(temp.path(), "*.md").expect("should glob");
275
276 assert_eq!(matches.len(), 2);
277 }
278
279 #[test]
280 fn test_real_fs_exists() {
281 let temp = TempDir::new().expect("should create temp dir");
282 let fs = RealFileSystem::new();
283 let path = temp.path().join("exists.txt");
284
285 assert!(!fs.exists(&path));
286
287 fs.write(&path, "content").expect("should write");
288
289 assert!(fs.exists(&path));
290 }
291}