xerv_core/testing/providers/
fs.rs

1//! Filesystem provider for abstracting file operations.
2//!
3//! Allows tests to use an in-memory filesystem while production code
4//! uses the real filesystem.
5
6use parking_lot::RwLock;
7use std::collections::HashMap;
8use std::io;
9use std::path::{Path, PathBuf};
10
11/// Provider trait for filesystem operations.
12pub trait FsProvider: Send + Sync {
13    /// Read file contents.
14    fn read(&self, path: &Path) -> io::Result<Vec<u8>>;
15
16    /// Write file contents.
17    fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()>;
18
19    /// Check if a path exists.
20    fn exists(&self, path: &Path) -> bool;
21
22    /// Check if a path is a file.
23    fn is_file(&self, path: &Path) -> bool;
24
25    /// Check if a path is a directory.
26    fn is_dir(&self, path: &Path) -> bool;
27
28    /// Create a directory and all parent directories.
29    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
30
31    /// Remove a file.
32    fn remove_file(&self, path: &Path) -> io::Result<()>;
33
34    /// Remove a directory and all its contents.
35    fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
36
37    /// List directory contents.
38    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>>;
39
40    /// Get file metadata.
41    fn metadata(&self, path: &Path) -> io::Result<FsMetadata>;
42
43    /// Check if this is a mock provider.
44    fn is_mock(&self) -> bool;
45}
46
47/// File metadata.
48#[derive(Debug, Clone)]
49pub struct FsMetadata {
50    /// Size of the file or directory in bytes.
51    pub size: u64,
52    /// Whether the path refers to a regular file.
53    pub is_file: bool,
54    /// Whether the path refers to a directory.
55    pub is_dir: bool,
56}
57
58/// Real filesystem provider.
59pub struct RealFs;
60
61impl RealFs {
62    /// Create a new real filesystem provider.
63    pub fn new() -> Self {
64        Self
65    }
66}
67
68impl Default for RealFs {
69    fn default() -> Self {
70        Self::new()
71    }
72}
73
74impl FsProvider for RealFs {
75    fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
76        std::fs::read(path)
77    }
78
79    fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
80        std::fs::write(path, contents)
81    }
82
83    fn exists(&self, path: &Path) -> bool {
84        path.exists()
85    }
86
87    fn is_file(&self, path: &Path) -> bool {
88        path.is_file()
89    }
90
91    fn is_dir(&self, path: &Path) -> bool {
92        path.is_dir()
93    }
94
95    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
96        std::fs::create_dir_all(path)
97    }
98
99    fn remove_file(&self, path: &Path) -> io::Result<()> {
100        std::fs::remove_file(path)
101    }
102
103    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
104        std::fs::remove_dir_all(path)
105    }
106
107    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
108        std::fs::read_dir(path)?
109            .map(|entry| entry.map(|e| e.path()))
110            .collect()
111    }
112
113    fn metadata(&self, path: &Path) -> io::Result<FsMetadata> {
114        let meta = std::fs::metadata(path)?;
115        Ok(FsMetadata {
116            size: meta.len(),
117            is_file: meta.is_file(),
118            is_dir: meta.is_dir(),
119        })
120    }
121
122    fn is_mock(&self) -> bool {
123        false
124    }
125}
126
127/// In-memory filesystem for testing.
128///
129/// # Example
130///
131/// ```
132/// use xerv_core::testing::{MockFs, FsProvider};
133/// use std::path::Path;
134///
135/// let fs = MockFs::new()
136///     .with_file("/config/app.yaml", b"name: test")
137///     .with_file("/data/input.json", b"{}");
138///
139/// assert!(fs.exists(Path::new("/config/app.yaml")));
140/// assert_eq!(fs.read(Path::new("/config/app.yaml")).unwrap(), b"name: test");
141/// ```
142pub struct MockFs {
143    files: RwLock<HashMap<PathBuf, Vec<u8>>>,
144    dirs: RwLock<HashMap<PathBuf, ()>>,
145}
146
147impl MockFs {
148    /// Create a new empty mock filesystem.
149    pub fn new() -> Self {
150        let mut dirs = HashMap::new();
151        // Root directory always exists
152        dirs.insert(PathBuf::from("/"), ());
153        Self {
154            files: RwLock::new(HashMap::new()),
155            dirs: RwLock::new(dirs),
156        }
157    }
158
159    /// Add a file to the mock filesystem.
160    pub fn with_file(self, path: impl AsRef<Path>, contents: impl AsRef<[u8]>) -> Self {
161        let path = path.as_ref().to_path_buf();
162
163        // Ensure parent directories exist
164        if let Some(parent) = path.parent() {
165            self.ensure_parent_dirs(parent);
166        }
167
168        self.files.write().insert(path, contents.as_ref().to_vec());
169        self
170    }
171
172    /// Add a file with string contents.
173    pub fn with_text_file(self, path: impl AsRef<Path>, contents: &str) -> Self {
174        self.with_file(path, contents.as_bytes())
175    }
176
177    /// Add a directory to the mock filesystem.
178    pub fn with_dir(self, path: impl AsRef<Path>) -> Self {
179        self.ensure_parent_dirs(path.as_ref());
180        self.dirs.write().insert(path.as_ref().to_path_buf(), ());
181        self
182    }
183
184    /// Ensure all parent directories exist.
185    fn ensure_parent_dirs(&self, path: &Path) {
186        let mut dirs = self.dirs.write();
187        let mut current = PathBuf::new();
188        for component in path.components() {
189            current.push(component);
190            dirs.entry(current.clone()).or_insert(());
191        }
192    }
193
194    /// Get all files in the mock filesystem.
195    pub fn all_files(&self) -> Vec<PathBuf> {
196        self.files.read().keys().cloned().collect()
197    }
198
199    /// Get all directories in the mock filesystem.
200    pub fn all_dirs(&self) -> Vec<PathBuf> {
201        self.dirs.read().keys().cloned().collect()
202    }
203
204    /// Clear the mock filesystem.
205    pub fn clear(&self) {
206        self.files.write().clear();
207        let mut dirs = self.dirs.write();
208        dirs.clear();
209        dirs.insert(PathBuf::from("/"), ());
210    }
211
212    /// Normalize a path (remove . and ..).
213    fn normalize_path(path: &Path) -> PathBuf {
214        let mut normalized = PathBuf::new();
215        for component in path.components() {
216            match component {
217                std::path::Component::ParentDir => {
218                    normalized.pop();
219                }
220                std::path::Component::CurDir => {}
221                _ => normalized.push(component),
222            }
223        }
224        normalized
225    }
226}
227
228impl Default for MockFs {
229    fn default() -> Self {
230        Self::new()
231    }
232}
233
234impl FsProvider for MockFs {
235    fn read(&self, path: &Path) -> io::Result<Vec<u8>> {
236        let path = Self::normalize_path(path);
237        self.files.read().get(&path).cloned().ok_or_else(|| {
238            io::Error::new(
239                io::ErrorKind::NotFound,
240                format!("File not found: {}", path.display()),
241            )
242        })
243    }
244
245    fn write(&self, path: &Path, contents: &[u8]) -> io::Result<()> {
246        let path = Self::normalize_path(path);
247
248        // Ensure parent directory exists
249        if let Some(parent) = path.parent() {
250            if !parent.as_os_str().is_empty() && !self.dirs.read().contains_key(parent) {
251                return Err(io::Error::new(
252                    io::ErrorKind::NotFound,
253                    format!("Parent directory not found: {}", parent.display()),
254                ));
255            }
256        }
257
258        self.files.write().insert(path, contents.to_vec());
259        Ok(())
260    }
261
262    fn exists(&self, path: &Path) -> bool {
263        let path = Self::normalize_path(path);
264        self.files.read().contains_key(&path) || self.dirs.read().contains_key(&path)
265    }
266
267    fn is_file(&self, path: &Path) -> bool {
268        let path = Self::normalize_path(path);
269        self.files.read().contains_key(&path)
270    }
271
272    fn is_dir(&self, path: &Path) -> bool {
273        let path = Self::normalize_path(path);
274        self.dirs.read().contains_key(&path)
275    }
276
277    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
278        self.ensure_parent_dirs(&Self::normalize_path(path));
279        Ok(())
280    }
281
282    fn remove_file(&self, path: &Path) -> io::Result<()> {
283        let path = Self::normalize_path(path);
284        self.files
285            .write()
286            .remove(&path)
287            .map(|_| ())
288            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "File not found"))
289    }
290
291    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
292        let path = Self::normalize_path(path);
293
294        // Remove all files under this directory
295        self.files.write().retain(|p, _| !p.starts_with(&path));
296
297        // Remove all directories under this directory
298        self.dirs.write().retain(|p, _| !p.starts_with(&path));
299
300        Ok(())
301    }
302
303    fn read_dir(&self, path: &Path) -> io::Result<Vec<PathBuf>> {
304        let path = Self::normalize_path(path);
305
306        if !self.dirs.read().contains_key(&path) {
307            return Err(io::Error::new(
308                io::ErrorKind::NotFound,
309                "Directory not found",
310            ));
311        }
312
313        let mut entries = Vec::new();
314
315        // Add files directly under this directory
316        for file_path in self.files.read().keys() {
317            if let Some(parent) = file_path.parent() {
318                if parent == path {
319                    entries.push(file_path.clone());
320                }
321            }
322        }
323
324        // Add directories directly under this directory
325        for dir_path in self.dirs.read().keys() {
326            if let Some(parent) = dir_path.parent() {
327                if parent == path && dir_path != &path {
328                    entries.push(dir_path.clone());
329                }
330            }
331        }
332
333        Ok(entries)
334    }
335
336    fn metadata(&self, path: &Path) -> io::Result<FsMetadata> {
337        let path = Self::normalize_path(path);
338
339        if let Some(contents) = self.files.read().get(&path) {
340            return Ok(FsMetadata {
341                size: contents.len() as u64,
342                is_file: true,
343                is_dir: false,
344            });
345        }
346
347        if self.dirs.read().contains_key(&path) {
348            return Ok(FsMetadata {
349                size: 0,
350                is_file: false,
351                is_dir: true,
352            });
353        }
354
355        Err(io::Error::new(io::ErrorKind::NotFound, "Path not found"))
356    }
357
358    fn is_mock(&self) -> bool {
359        true
360    }
361}
362
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    #[test]
368    fn mock_fs_basic_operations() {
369        let fs = MockFs::new()
370            .with_file("/test.txt", b"hello world")
371            .with_dir("/data");
372
373        assert!(fs.exists(Path::new("/test.txt")));
374        assert!(fs.is_file(Path::new("/test.txt")));
375        assert!(!fs.is_dir(Path::new("/test.txt")));
376
377        assert!(fs.exists(Path::new("/data")));
378        assert!(fs.is_dir(Path::new("/data")));
379        assert!(!fs.is_file(Path::new("/data")));
380
381        let contents = fs.read(Path::new("/test.txt")).unwrap();
382        assert_eq!(contents, b"hello world");
383    }
384
385    #[test]
386    fn mock_fs_write() {
387        let fs = MockFs::new().with_dir("/data");
388
389        fs.write(Path::new("/data/file.txt"), b"test content")
390            .unwrap();
391
392        assert!(fs.exists(Path::new("/data/file.txt")));
393        assert_eq!(
394            fs.read(Path::new("/data/file.txt")).unwrap(),
395            b"test content"
396        );
397    }
398
399    #[test]
400    fn mock_fs_remove() {
401        let fs = MockFs::new()
402            .with_file("/file.txt", b"test")
403            .with_file("/dir/nested.txt", b"nested")
404            .with_dir("/dir");
405
406        fs.remove_file(Path::new("/file.txt")).unwrap();
407        assert!(!fs.exists(Path::new("/file.txt")));
408
409        fs.remove_dir_all(Path::new("/dir")).unwrap();
410        assert!(!fs.exists(Path::new("/dir")));
411        assert!(!fs.exists(Path::new("/dir/nested.txt")));
412    }
413
414    #[test]
415    fn mock_fs_read_dir() {
416        let fs = MockFs::new()
417            .with_file("/dir/a.txt", b"a")
418            .with_file("/dir/b.txt", b"b")
419            .with_dir("/dir/subdir");
420
421        let entries = fs.read_dir(Path::new("/dir")).unwrap();
422        assert_eq!(entries.len(), 3);
423    }
424
425    #[test]
426    fn mock_fs_metadata() {
427        let fs = MockFs::new()
428            .with_file("/file.txt", b"12345")
429            .with_dir("/dir");
430
431        let file_meta = fs.metadata(Path::new("/file.txt")).unwrap();
432        assert_eq!(file_meta.size, 5);
433        assert!(file_meta.is_file);
434        assert!(!file_meta.is_dir);
435
436        let dir_meta = fs.metadata(Path::new("/dir")).unwrap();
437        assert!(dir_meta.is_dir);
438        assert!(!dir_meta.is_file);
439    }
440
441    #[test]
442    fn mock_fs_auto_creates_parent_dirs() {
443        let fs = MockFs::new().with_file("/a/b/c/file.txt", b"deep");
444
445        assert!(fs.is_dir(Path::new("/a")));
446        assert!(fs.is_dir(Path::new("/a/b")));
447        assert!(fs.is_dir(Path::new("/a/b/c")));
448        assert!(fs.is_file(Path::new("/a/b/c/file.txt")));
449    }
450}