adrscope/infrastructure/
fs.rs

1//! Filesystem abstraction for testability.
2//!
3//! This module provides a trait for filesystem operations, allowing tests
4//! to mock the filesystem without touching real files.
5
6use std::path::{Path, PathBuf};
7
8use crate::error::{Error, Result};
9
10/// Abstraction over filesystem operations for testability.
11pub trait FileSystem: Send + Sync {
12    /// Reads the contents of a file as a UTF-8 string.
13    fn read_to_string(&self, path: &Path) -> Result<String>;
14
15    /// Writes string contents to a file, creating parent directories as needed.
16    fn write(&self, path: &Path, contents: &str) -> Result<()>;
17
18    /// Lists all files matching a glob pattern in a directory.
19    fn glob(&self, base: &Path, pattern: &str) -> Result<Vec<PathBuf>>;
20
21    /// Checks if a path exists.
22    fn exists(&self, path: &Path) -> bool;
23
24    /// Creates a directory and all parent directories.
25    fn create_dir_all(&self, path: &Path) -> Result<()>;
26}
27
28/// Production filesystem implementation using `std::fs`.
29#[derive(Debug, Clone, Default)]
30pub struct RealFileSystem;
31
32impl RealFileSystem {
33    /// Creates a new real filesystem instance.
34    #[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        // Create parent directories if they don't exist
50        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/// In-memory filesystem for testing.
90#[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    /// In-memory filesystem for testing without touching real files.
98    #[derive(Debug, Clone, Default)]
99    pub struct InMemoryFileSystem {
100        files: Arc<RwLock<HashMap<PathBuf, String>>>,
101    }
102
103    impl InMemoryFileSystem {
104        /// Creates a new empty in-memory filesystem.
105        pub fn new() -> Self {
106            Self::default()
107        }
108
109        /// Adds a file with the given content.
110        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        /// Returns all files in the filesystem.
116        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            // Simple pattern matching for testing
140            // Supports "*.md" and "**/*.md"
141            let is_recursive = pattern.starts_with("**/");
142            let suffix = if is_recursive {
143                &pattern[3..] // Remove "**/"
144            } else {
145                pattern
146            };
147
148            let paths: Vec<PathBuf> = files
149                .keys()
150                .filter(|path| {
151                    if is_recursive {
152                        // Match any file under base with the suffix
153                        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                        // Match files directly in base
160                        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            // No-op for in-memory filesystem
180            Ok(())
181        }
182    }
183
184    /// Simple glob pattern matching for testing.
185    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}