Skip to main content

agnix_core/
fs.rs

1//! FileSystem abstraction for testability
2//!
3//! This module provides a `FileSystem` trait that abstracts file system operations,
4//! enabling validators to be unit tested with `MockFileSystem` instead of requiring
5//! real temp files.
6//!
7//! ## Usage
8//!
9//! For production code, use `RealFileSystem` which delegates to `std::fs` and
10//! the safe file reading utilities in `file_utils`.
11//!
12//! For tests, use `MockFileSystem` which provides an in-memory HashMap-based
13//! storage with `RwLock` for thread safety.
14//!
15//! ## Example
16//!
17//! ```rust,ignore
18//! use agnix_core::fs::{FileSystem, MockFileSystem, RealFileSystem};
19//! use std::path::Path;
20//!
21//! // In production code
22//! let fs = RealFileSystem;
23//! assert!(fs.exists(Path::new("Cargo.toml")));
24//!
25//! // In tests
26//! let mock_fs = MockFileSystem::new();
27//! mock_fs.add_file("/test/file.txt", "content");
28//! assert!(mock_fs.exists(Path::new("/test/file.txt")));
29//! ```
30
31use crate::diagnostics::{LintError, LintResult};
32use std::collections::HashMap;
33use std::fs::Metadata;
34use std::io;
35use std::path::{Path, PathBuf};
36use std::sync::RwLock;
37
38/// Metadata information returned by the FileSystem trait.
39///
40/// This provides a subset of `std::fs::Metadata` that can be mocked.
41#[derive(Debug, Clone)]
42pub struct FileMetadata {
43    /// Whether this is a regular file
44    pub is_file: bool,
45    /// Whether this is a directory
46    pub is_dir: bool,
47    /// Whether this is a symlink
48    pub is_symlink: bool,
49    /// File size in bytes
50    pub len: u64,
51}
52
53impl FileMetadata {
54    /// Create metadata for a regular file
55    pub fn file(len: u64) -> Self {
56        Self {
57            is_file: true,
58            is_dir: false,
59            is_symlink: false,
60            len,
61        }
62    }
63
64    /// Create metadata for a directory
65    pub fn directory() -> Self {
66        Self {
67            is_file: false,
68            is_dir: true,
69            is_symlink: false,
70            len: 0,
71        }
72    }
73
74    /// Create metadata for a symlink
75    pub fn symlink() -> Self {
76        Self {
77            is_file: false,
78            is_dir: false,
79            is_symlink: true,
80            len: 0,
81        }
82    }
83}
84
85impl From<&Metadata> for FileMetadata {
86    fn from(meta: &Metadata) -> Self {
87        Self {
88            is_file: meta.is_file(),
89            is_dir: meta.is_dir(),
90            is_symlink: meta.file_type().is_symlink(),
91            len: meta.len(),
92        }
93    }
94}
95
96/// Directory entry returned by `read_dir`.
97#[derive(Debug, Clone)]
98pub struct DirEntry {
99    /// Path to this entry
100    pub path: PathBuf,
101    /// Metadata for this entry
102    pub metadata: FileMetadata,
103}
104
105/// Trait for abstracting file system operations.
106///
107/// This trait must be `Send + Sync` to support rayon parallel validation.
108/// It also requires `Debug` for use in config structs that derive Debug.
109pub trait FileSystem: Send + Sync + std::fmt::Debug {
110    /// Check if a path exists
111    fn exists(&self, path: &Path) -> bool;
112
113    /// Check if a path is a file
114    fn is_file(&self, path: &Path) -> bool;
115
116    /// Check if a path is a directory
117    fn is_dir(&self, path: &Path) -> bool;
118
119    /// Check if a path is a symlink
120    fn is_symlink(&self, path: &Path) -> bool;
121
122    /// Get metadata for a path (follows symlinks)
123    fn metadata(&self, path: &Path) -> io::Result<FileMetadata>;
124
125    /// Get metadata for a path without following symlinks
126    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata>;
127
128    /// Read file contents to string (with security checks)
129    fn read_to_string(&self, path: &Path) -> LintResult<String>;
130
131    /// Write content to file (with security checks)
132    fn write(&self, path: &Path, content: &str) -> LintResult<()>;
133
134    /// Canonicalize a path
135    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf>;
136
137    /// Read directory contents
138    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>>;
139}
140
141/// Real file system implementation that delegates to `std::fs` and `file_utils`.
142#[derive(Debug, Clone, Copy, Default)]
143pub struct RealFileSystem;
144
145impl FileSystem for RealFileSystem {
146    fn exists(&self, path: &Path) -> bool {
147        path.exists()
148    }
149
150    fn is_file(&self, path: &Path) -> bool {
151        path.is_file()
152    }
153
154    fn is_dir(&self, path: &Path) -> bool {
155        path.is_dir()
156    }
157
158    fn is_symlink(&self, path: &Path) -> bool {
159        path.is_symlink()
160    }
161
162    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
163        std::fs::metadata(path).map(|m| FileMetadata::from(&m))
164    }
165
166    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
167        std::fs::symlink_metadata(path).map(|m| FileMetadata::from(&m))
168    }
169
170    fn read_to_string(&self, path: &Path) -> LintResult<String> {
171        crate::file_utils::safe_read_file(path)
172    }
173
174    fn write(&self, path: &Path, content: &str) -> LintResult<()> {
175        crate::file_utils::safe_write_file(path, content)
176    }
177
178    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
179        std::fs::canonicalize(path)
180    }
181
182    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
183        Ok(std::fs::read_dir(path)?
184            .filter_map(|entry_res| {
185                // Skip entries that fail to read (permission denied, etc.)
186                // This matches the previous AS-015 behavior of tolerating bad entries
187                let entry = entry_res.ok()?;
188                let path = entry.path();
189                // Use symlink_metadata to avoid following symlinks
190                // Skip entries where metadata fails (transient errors)
191                let metadata = std::fs::symlink_metadata(&path).ok()?;
192                Some(DirEntry {
193                    path,
194                    metadata: FileMetadata::from(&metadata),
195                })
196            })
197            .collect())
198    }
199}
200
201/// Mock entry type for the in-memory file system.
202#[derive(Debug, Clone)]
203enum MockEntry {
204    File { content: String },
205    Directory,
206    Symlink { target: PathBuf },
207}
208
209/// Mock file system for testing.
210///
211/// Provides an in-memory HashMap-based storage with `RwLock` for thread safety.
212/// This enables unit testing validators without requiring real temp files.
213#[derive(Debug, Default)]
214pub struct MockFileSystem {
215    entries: RwLock<HashMap<PathBuf, MockEntry>>,
216}
217
218impl MockFileSystem {
219    /// Create a new empty mock file system
220    pub fn new() -> Self {
221        Self {
222            entries: RwLock::new(HashMap::new()),
223        }
224    }
225
226    /// Add a file with the given content
227    pub fn add_file(&self, path: impl AsRef<Path>, content: impl Into<String>) {
228        let path = normalize_mock_path(path.as_ref());
229        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
230        entries.insert(
231            path,
232            MockEntry::File {
233                content: content.into(),
234            },
235        );
236    }
237
238    /// Add a directory
239    pub fn add_dir(&self, path: impl AsRef<Path>) {
240        let path = normalize_mock_path(path.as_ref());
241        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
242        entries.insert(path, MockEntry::Directory);
243    }
244
245    /// Add a symlink pointing to target
246    pub fn add_symlink(&self, path: impl AsRef<Path>, target: impl AsRef<Path>) {
247        let path = normalize_mock_path(path.as_ref());
248        let target = normalize_mock_path(target.as_ref());
249        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
250        entries.insert(path, MockEntry::Symlink { target });
251    }
252
253    /// Remove an entry
254    pub fn remove(&self, path: impl AsRef<Path>) {
255        let path = normalize_mock_path(path.as_ref());
256        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
257        entries.remove(&path);
258    }
259
260    /// Clear all entries
261    pub fn clear(&self) {
262        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
263        entries.clear();
264    }
265
266    fn get_entry(&self, path: &Path) -> Option<MockEntry> {
267        let path = normalize_mock_path(path);
268        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
269        entries.get(&path).cloned()
270    }
271
272    fn resolve_symlink(&self, path: &Path) -> Option<PathBuf> {
273        let path = normalize_mock_path(path);
274        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
275        match entries.get(&path) {
276            Some(MockEntry::Symlink { target }) => Some(target.clone()),
277            _ => None,
278        }
279    }
280
281    /// Maximum depth for symlink resolution to prevent infinite loops
282    const MAX_SYMLINK_DEPTH: u32 = 40;
283
284    /// Internal helper for metadata with depth tracking
285    fn metadata_with_depth(&self, path: &Path, depth: u32) -> io::Result<FileMetadata> {
286        if depth > Self::MAX_SYMLINK_DEPTH {
287            return Err(io::Error::other("too many levels of symbolic links"));
288        }
289
290        // Follow symlinks - use an enum to handle the result outside the lock
291        enum MetaResult {
292            Found(FileMetadata),
293            FollowSymlink(PathBuf),
294        }
295
296        let path = normalize_mock_path(path);
297
298        let result: io::Result<MetaResult> = {
299            let entries = self.entries.read().expect("MockFileSystem lock poisoned");
300            match entries.get(&path) {
301                None => Err(io::Error::new(
302                    io::ErrorKind::NotFound,
303                    format!("path not found: {}", path.display()),
304                )),
305                Some(MockEntry::File { content }) => {
306                    Ok(MetaResult::Found(FileMetadata::file(content.len() as u64)))
307                }
308                Some(MockEntry::Directory) => Ok(MetaResult::Found(FileMetadata::directory())),
309                Some(MockEntry::Symlink { target }) => {
310                    Ok(MetaResult::FollowSymlink(target.clone()))
311                }
312            }
313        };
314
315        match result? {
316            MetaResult::Found(meta) => Ok(meta),
317            MetaResult::FollowSymlink(target) => self.metadata_with_depth(&target, depth + 1),
318        }
319    }
320
321    /// Internal helper for canonicalize with depth tracking
322    fn canonicalize_with_depth(&self, path: &Path, depth: u32) -> io::Result<PathBuf> {
323        if depth > Self::MAX_SYMLINK_DEPTH {
324            return Err(io::Error::other("too many levels of symbolic links"));
325        }
326
327        let path_normalized = normalize_mock_path(path);
328
329        if !self.exists(&path_normalized) {
330            return Err(io::Error::new(
331                io::ErrorKind::NotFound,
332                format!("path not found: {}", path.display()),
333            ));
334        }
335
336        // Follow symlinks if present
337        if let Some(target) = self.resolve_symlink(&path_normalized) {
338            self.canonicalize_with_depth(&target, depth + 1)
339        } else {
340            Ok(path_normalized)
341        }
342    }
343}
344
345/// Normalize a path for mock file system storage.
346/// Converts backslashes to forward slashes for cross-platform consistency.
347fn normalize_mock_path(path: &Path) -> PathBuf {
348    let path_str = path.to_string_lossy();
349    PathBuf::from(path_str.replace('\\', "/"))
350}
351
352impl FileSystem for MockFileSystem {
353    fn exists(&self, path: &Path) -> bool {
354        self.get_entry(path).is_some()
355    }
356
357    fn is_file(&self, path: &Path) -> bool {
358        matches!(self.get_entry(path), Some(MockEntry::File { .. }))
359    }
360
361    fn is_dir(&self, path: &Path) -> bool {
362        matches!(self.get_entry(path), Some(MockEntry::Directory))
363    }
364
365    fn is_symlink(&self, path: &Path) -> bool {
366        matches!(self.get_entry(path), Some(MockEntry::Symlink { .. }))
367    }
368
369    fn metadata(&self, path: &Path) -> io::Result<FileMetadata> {
370        self.metadata_with_depth(path, 0)
371    }
372
373    fn symlink_metadata(&self, path: &Path) -> io::Result<FileMetadata> {
374        // Don't follow symlinks
375        let entry = self.get_entry(path).ok_or_else(|| {
376            io::Error::new(
377                io::ErrorKind::NotFound,
378                format!("path not found: {}", path.display()),
379            )
380        })?;
381
382        match entry {
383            MockEntry::File { content } => Ok(FileMetadata::file(content.len() as u64)),
384            MockEntry::Directory => Ok(FileMetadata::directory()),
385            MockEntry::Symlink { .. } => Ok(FileMetadata::symlink()),
386        }
387    }
388
389    fn read_to_string(&self, path: &Path) -> LintResult<String> {
390        let path_normalized = normalize_mock_path(path);
391        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
392
393        let entry = entries
394            .get(&path_normalized)
395            .ok_or_else(|| LintError::FileRead {
396                path: path.to_path_buf(),
397                source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
398            })?;
399
400        match entry {
401            MockEntry::File { content } => Ok(content.clone()),
402            MockEntry::Directory => Err(LintError::FileNotRegular {
403                path: path.to_path_buf(),
404            }),
405            MockEntry::Symlink { .. } => Err(LintError::FileSymlink {
406                path: path.to_path_buf(),
407            }),
408        }
409    }
410
411    fn write(&self, path: &Path, content: &str) -> LintResult<()> {
412        let path_normalized = normalize_mock_path(path);
413        let mut entries = self.entries.write().expect("MockFileSystem lock poisoned");
414
415        // Check if path exists and is valid for writing
416        match entries.get(&path_normalized) {
417            Some(MockEntry::File { .. }) => {
418                // Overwrite existing file
419                entries.insert(
420                    path_normalized,
421                    MockEntry::File {
422                        content: content.to_string(),
423                    },
424                );
425                Ok(())
426            }
427            Some(MockEntry::Directory) => Err(LintError::FileNotRegular {
428                path: path.to_path_buf(),
429            }),
430            Some(MockEntry::Symlink { .. }) => Err(LintError::FileSymlink {
431                path: path.to_path_buf(),
432            }),
433            None => {
434                // File doesn't exist - error like safe_write_file
435                Err(LintError::FileWrite {
436                    path: path.to_path_buf(),
437                    source: io::Error::new(io::ErrorKind::NotFound, "file not found"),
438                })
439            }
440        }
441    }
442
443    fn canonicalize(&self, path: &Path) -> io::Result<PathBuf> {
444        self.canonicalize_with_depth(path, 0)
445    }
446
447    fn read_dir(&self, path: &Path) -> io::Result<Vec<DirEntry>> {
448        let path_normalized = normalize_mock_path(path);
449
450        // Check if it's a directory
451        match self.get_entry(&path_normalized) {
452            Some(MockEntry::Directory) => {}
453            Some(_) => {
454                return Err(io::Error::new(
455                    io::ErrorKind::NotADirectory,
456                    "not a directory",
457                ));
458            }
459            None => {
460                return Err(io::Error::new(
461                    io::ErrorKind::NotFound,
462                    "directory not found",
463                ));
464            }
465        }
466
467        let entries = self.entries.read().expect("MockFileSystem lock poisoned");
468        let mut result = Vec::new();
469
470        // Normalize the path string for prefix matching
471        let prefix = if path_normalized.to_string_lossy().ends_with('/') {
472            path_normalized.to_string_lossy().to_string()
473        } else {
474            format!("{}/", path_normalized.display())
475        };
476
477        for (entry_path, entry) in entries.iter() {
478            let entry_str = entry_path.to_string_lossy();
479
480            // Check if this entry is a direct child of the directory
481            if let Some(rest) = entry_str.strip_prefix(&prefix) {
482                // Only include direct children (no further slashes)
483                if !rest.contains('/') && !rest.is_empty() {
484                    let metadata = match entry {
485                        MockEntry::File { content } => FileMetadata::file(content.len() as u64),
486                        MockEntry::Directory => FileMetadata::directory(),
487                        MockEntry::Symlink { .. } => FileMetadata::symlink(),
488                    };
489                    result.push(DirEntry {
490                        path: entry_path.clone(),
491                        metadata,
492                    });
493                }
494            }
495        }
496
497        Ok(result)
498    }
499}
500
501#[cfg(test)]
502mod tests {
503    use super::*;
504
505    // ===== RealFileSystem tests =====
506
507    #[test]
508    fn test_real_fs_exists() {
509        let fs = RealFileSystem;
510        // Cargo.toml should exist in the project root
511        assert!(fs.exists(Path::new("Cargo.toml")));
512        assert!(!fs.exists(Path::new("nonexistent_file_xyz.txt")));
513    }
514
515    #[test]
516    fn test_real_fs_is_file() {
517        let fs = RealFileSystem;
518        assert!(fs.is_file(Path::new("Cargo.toml")));
519        assert!(!fs.is_file(Path::new("src")));
520    }
521
522    #[test]
523    fn test_real_fs_is_dir() {
524        let fs = RealFileSystem;
525        assert!(fs.is_dir(Path::new("src")));
526        assert!(!fs.is_dir(Path::new("Cargo.toml")));
527    }
528
529    #[test]
530    fn test_real_fs_read_to_string() {
531        let fs = RealFileSystem;
532        let content = fs.read_to_string(Path::new("Cargo.toml"));
533        assert!(content.is_ok());
534        assert!(content.unwrap().contains("[package]"));
535    }
536
537    #[test]
538    fn test_real_fs_read_nonexistent() {
539        let fs = RealFileSystem;
540        let result = fs.read_to_string(Path::new("nonexistent_file_xyz.txt"));
541        assert!(result.is_err());
542    }
543
544    // ===== MockFileSystem tests =====
545
546    #[test]
547    fn test_mock_fs_add_and_exists() {
548        let fs = MockFileSystem::new();
549        assert!(!fs.exists(Path::new("/test/file.txt")));
550
551        fs.add_file("/test/file.txt", "content");
552        assert!(fs.exists(Path::new("/test/file.txt")));
553    }
554
555    #[test]
556    fn test_mock_fs_is_file() {
557        let fs = MockFileSystem::new();
558        fs.add_file("/test/file.txt", "content");
559        fs.add_dir("/test/dir");
560
561        assert!(fs.is_file(Path::new("/test/file.txt")));
562        assert!(!fs.is_file(Path::new("/test/dir")));
563    }
564
565    #[test]
566    fn test_mock_fs_is_dir() {
567        let fs = MockFileSystem::new();
568        fs.add_file("/test/file.txt", "content");
569        fs.add_dir("/test/dir");
570
571        assert!(!fs.is_dir(Path::new("/test/file.txt")));
572        assert!(fs.is_dir(Path::new("/test/dir")));
573    }
574
575    #[test]
576    fn test_mock_fs_is_symlink() {
577        let fs = MockFileSystem::new();
578        fs.add_file("/test/file.txt", "content");
579        fs.add_symlink("/test/link.txt", "/test/file.txt");
580
581        assert!(!fs.is_symlink(Path::new("/test/file.txt")));
582        assert!(fs.is_symlink(Path::new("/test/link.txt")));
583    }
584
585    #[test]
586    fn test_mock_fs_read_to_string() {
587        let fs = MockFileSystem::new();
588        fs.add_file("/test/file.txt", "hello world");
589
590        let content = fs.read_to_string(Path::new("/test/file.txt"));
591        assert!(content.is_ok());
592        assert_eq!(content.unwrap(), "hello world");
593    }
594
595    #[test]
596    fn test_mock_fs_read_nonexistent() {
597        let fs = MockFileSystem::new();
598        let result = fs.read_to_string(Path::new("/test/file.txt"));
599        assert!(result.is_err());
600    }
601
602    #[test]
603    fn test_mock_fs_read_directory_fails() {
604        let fs = MockFileSystem::new();
605        fs.add_dir("/test/dir");
606
607        let result = fs.read_to_string(Path::new("/test/dir"));
608        assert!(matches!(result, Err(LintError::FileNotRegular { .. })));
609    }
610
611    #[test]
612    fn test_mock_fs_read_symlink_fails() {
613        let fs = MockFileSystem::new();
614        fs.add_file("/test/file.txt", "content");
615        fs.add_symlink("/test/link.txt", "/test/file.txt");
616
617        let result = fs.read_to_string(Path::new("/test/link.txt"));
618        assert!(matches!(result, Err(LintError::FileSymlink { .. })));
619    }
620
621    #[test]
622    fn test_mock_fs_write() {
623        let fs = MockFileSystem::new();
624        fs.add_file("/test/file.txt", "original");
625
626        let result = fs.write(Path::new("/test/file.txt"), "updated");
627        assert!(result.is_ok());
628
629        let content = fs.read_to_string(Path::new("/test/file.txt")).unwrap();
630        assert_eq!(content, "updated");
631    }
632
633    #[test]
634    fn test_mock_fs_write_nonexistent_fails() {
635        let fs = MockFileSystem::new();
636
637        let result = fs.write(Path::new("/test/file.txt"), "content");
638        assert!(matches!(result, Err(LintError::FileWrite { .. })));
639    }
640
641    #[test]
642    fn test_mock_fs_metadata_file() {
643        let fs = MockFileSystem::new();
644        fs.add_file("/test/file.txt", "12345");
645
646        let meta = fs.metadata(Path::new("/test/file.txt")).unwrap();
647        assert!(meta.is_file);
648        assert!(!meta.is_dir);
649        assert!(!meta.is_symlink);
650        assert_eq!(meta.len, 5);
651    }
652
653    #[test]
654    fn test_mock_fs_metadata_directory() {
655        let fs = MockFileSystem::new();
656        fs.add_dir("/test/dir");
657
658        let meta = fs.metadata(Path::new("/test/dir")).unwrap();
659        assert!(!meta.is_file);
660        assert!(meta.is_dir);
661        assert!(!meta.is_symlink);
662    }
663
664    #[test]
665    fn test_mock_fs_symlink_metadata() {
666        let fs = MockFileSystem::new();
667        fs.add_file("/test/file.txt", "content");
668        fs.add_symlink("/test/link.txt", "/test/file.txt");
669
670        // symlink_metadata should not follow symlinks
671        let meta = fs.symlink_metadata(Path::new("/test/link.txt")).unwrap();
672        assert!(meta.is_symlink);
673
674        // metadata should follow symlinks
675        let meta = fs.metadata(Path::new("/test/link.txt")).unwrap();
676        assert!(meta.is_file);
677        assert!(!meta.is_symlink);
678    }
679
680    #[test]
681    fn test_mock_fs_read_dir() {
682        let fs = MockFileSystem::new();
683        fs.add_dir("/test");
684        fs.add_file("/test/file1.txt", "content1");
685        fs.add_file("/test/file2.txt", "content2");
686        fs.add_dir("/test/subdir");
687
688        let entries = fs.read_dir(Path::new("/test")).unwrap();
689        assert_eq!(entries.len(), 3);
690
691        let names: Vec<_> = entries
692            .iter()
693            .map(|e| e.path.file_name().unwrap().to_string_lossy().to_string())
694            .collect();
695        assert!(names.contains(&"file1.txt".to_string()));
696        assert!(names.contains(&"file2.txt".to_string()));
697        assert!(names.contains(&"subdir".to_string()));
698    }
699
700    #[test]
701    fn test_mock_fs_read_dir_nonexistent() {
702        let fs = MockFileSystem::new();
703        let result = fs.read_dir(Path::new("/nonexistent"));
704        assert!(result.is_err());
705    }
706
707    #[test]
708    fn test_mock_fs_read_dir_not_directory() {
709        let fs = MockFileSystem::new();
710        fs.add_file("/test/file.txt", "content");
711
712        let result = fs.read_dir(Path::new("/test/file.txt"));
713        assert!(result.is_err());
714    }
715
716    #[test]
717    fn test_mock_fs_canonicalize() {
718        let fs = MockFileSystem::new();
719        fs.add_file("/test/file.txt", "content");
720
721        let canonical = fs.canonicalize(Path::new("/test/file.txt")).unwrap();
722        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
723    }
724
725    #[test]
726    fn test_mock_fs_canonicalize_follows_symlink() {
727        let fs = MockFileSystem::new();
728        fs.add_file("/test/file.txt", "content");
729        fs.add_symlink("/test/link.txt", "/test/file.txt");
730
731        let canonical = fs.canonicalize(Path::new("/test/link.txt")).unwrap();
732        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
733    }
734
735    #[test]
736    fn test_mock_fs_clear() {
737        let fs = MockFileSystem::new();
738        fs.add_file("/test/file.txt", "content");
739        assert!(fs.exists(Path::new("/test/file.txt")));
740
741        fs.clear();
742        assert!(!fs.exists(Path::new("/test/file.txt")));
743    }
744
745    #[test]
746    fn test_mock_fs_remove() {
747        let fs = MockFileSystem::new();
748        fs.add_file("/test/file.txt", "content");
749        assert!(fs.exists(Path::new("/test/file.txt")));
750
751        fs.remove("/test/file.txt");
752        assert!(!fs.exists(Path::new("/test/file.txt")));
753    }
754
755    #[test]
756    fn test_mock_fs_windows_path_normalization() {
757        let fs = MockFileSystem::new();
758        fs.add_file("C:/test/file.txt", "content");
759
760        // Should work with either path separator
761        assert!(fs.exists(Path::new("C:/test/file.txt")));
762        assert!(fs.exists(Path::new("C:\\test\\file.txt")));
763    }
764
765    #[test]
766    fn test_mock_fs_thread_safety() {
767        use std::sync::Arc;
768        use std::thread;
769
770        let fs = Arc::new(MockFileSystem::new());
771        let mut handles = vec![];
772
773        // Spawn multiple threads that read and write
774        for i in 0..10 {
775            let fs_clone = Arc::clone(&fs);
776            let handle = thread::spawn(move || {
777                let path = format!("/test/file{}.txt", i);
778                fs_clone.add_file(&path, format!("content{}", i));
779                assert!(fs_clone.exists(Path::new(&path)));
780                let content = fs_clone.read_to_string(Path::new(&path)).unwrap();
781                assert_eq!(content, format!("content{}", i));
782            });
783            handles.push(handle);
784        }
785
786        for handle in handles {
787            handle.join().unwrap();
788        }
789
790        // Verify all files exist
791        for i in 0..10 {
792            let path = format!("/test/file{}.txt", i);
793            assert!(fs.exists(Path::new(&path)));
794        }
795    }
796
797    #[test]
798    fn test_mock_fs_circular_symlink_metadata() {
799        let fs = MockFileSystem::new();
800        // Create circular symlinks: a -> b -> a
801        fs.add_symlink("/test/a", "/test/b");
802        fs.add_symlink("/test/b", "/test/a");
803
804        // metadata() follows symlinks and should detect the cycle
805        let result = fs.metadata(Path::new("/test/a"));
806        assert!(result.is_err());
807        assert!(result
808            .unwrap_err()
809            .to_string()
810            .contains("too many levels of symbolic links"));
811    }
812
813    #[test]
814    fn test_mock_fs_circular_symlink_canonicalize() {
815        let fs = MockFileSystem::new();
816        // Create circular symlinks: a -> b -> a
817        fs.add_symlink("/test/a", "/test/b");
818        fs.add_symlink("/test/b", "/test/a");
819
820        // canonicalize() follows symlinks and should detect the cycle
821        let result = fs.canonicalize(Path::new("/test/a"));
822        assert!(result.is_err());
823        assert!(result
824            .unwrap_err()
825            .to_string()
826            .contains("too many levels of symbolic links"));
827    }
828
829    #[test]
830    fn test_mock_fs_chained_symlinks() {
831        let fs = MockFileSystem::new();
832        // Create chain: link1 -> link2 -> link3 -> file
833        fs.add_file("/test/file.txt", "content");
834        fs.add_symlink("/test/link3", "/test/file.txt");
835        fs.add_symlink("/test/link2", "/test/link3");
836        fs.add_symlink("/test/link1", "/test/link2");
837
838        // metadata() should follow the chain and return file metadata
839        let meta = fs.metadata(Path::new("/test/link1")).unwrap();
840        assert!(meta.is_file);
841        assert_eq!(meta.len, 7); // "content".len()
842
843        // canonicalize() should return the final target
844        let canonical = fs.canonicalize(Path::new("/test/link1")).unwrap();
845        assert_eq!(canonical, PathBuf::from("/test/file.txt"));
846    }
847}