Skip to main content

termesh_test_support/
fake_fs.rs

1//! An in-memory [`FileSystemService`] so explorer logic is testable without a disk.
2//!
3//! Required by CONTRIBUTING.md ("every new service ships with a fake") and load-bearing for
4//! CI: the `--dump-frame` snapshot must not depend on whatever happens to be on the
5//! filesystem of the machine rendering it (ADR-0005, Consequences).
6
7use std::collections::BTreeMap;
8use std::path::{Component, Path, PathBuf};
9use std::sync::Mutex;
10
11use termesh_filesystem::{
12    sort_entries, DirEntryInfo, EntryKind, FileSystemService, FsError, FsResult,
13};
14
15/// What lives at a path in the fake tree.
16#[derive(Debug, Clone, PartialEq, Eq)]
17enum Node {
18    Dir,
19    File(Vec<u8>),
20    /// Stores its target, but is never traversed — same contract as the real service.
21    Symlink(PathBuf),
22}
23
24/// An in-memory filesystem.
25///
26/// Paths are normalised (`.` and `..` resolved lexically) so callers can hand it either
27/// absolute or relative paths and get consistent answers. Interior mutability via
28/// `Mutex` keeps it usable behind the `&self` trait methods and `Send + Sync` for the
29/// worker thread.
30#[derive(Debug, Default)]
31pub struct FakeFileSystem {
32    inner: Mutex<Inner>,
33}
34
35#[derive(Debug, Default)]
36struct Inner {
37    nodes: BTreeMap<PathBuf, Node>,
38    /// Errors injected for specific paths, to exercise failure rendering.
39    failures: BTreeMap<PathBuf, FsError>,
40}
41
42impl FakeFileSystem {
43    pub fn new() -> Self {
44        let mut inner = Inner::default();
45        inner.nodes.insert(PathBuf::from("/"), Node::Dir);
46        Self { inner: Mutex::new(inner) }
47    }
48
49    /// Build a tree from a path list. Trailing `/` means directory; parents are implied.
50    ///
51    /// ```ignore
52    /// FakeFileSystem::with_paths(&["/proj/src/main.rs", "/proj/Cargo.toml", "/proj/target/"]);
53    /// ```
54    pub fn with_paths(paths: &[&str]) -> Self {
55        let fs = Self::new();
56        for p in paths {
57            if let Some(dir) = p.strip_suffix('/') {
58                fs.add_dir(dir);
59            } else {
60                fs.add_file(p, b"");
61            }
62        }
63        fs
64    }
65
66    /// Insert a directory and every missing ancestor.
67    pub fn add_dir(&self, path: impl AsRef<Path>) -> &Self {
68        let path = normalize(path.as_ref());
69        let mut inner = self.inner.lock().unwrap();
70        insert_ancestors(&mut inner.nodes, &path);
71        inner.nodes.insert(path, Node::Dir);
72        drop(inner);
73        self
74    }
75
76    /// Insert a file with contents, creating missing ancestor directories.
77    pub fn add_file(&self, path: impl AsRef<Path>, contents: &[u8]) -> &Self {
78        let path = normalize(path.as_ref());
79        let mut inner = self.inner.lock().unwrap();
80        insert_ancestors(&mut inner.nodes, &path);
81        inner.nodes.insert(path, Node::File(contents.to_vec()));
82        drop(inner);
83        self
84    }
85
86    /// Insert a symlink. It is listed but never traversed, matching the real service.
87    pub fn add_symlink(&self, path: impl AsRef<Path>, target: impl AsRef<Path>) -> &Self {
88        let path = normalize(path.as_ref());
89        let mut inner = self.inner.lock().unwrap();
90        insert_ancestors(&mut inner.nodes, &path);
91        inner.nodes.insert(path, Node::Symlink(target.as_ref().to_path_buf()));
92        drop(inner);
93        self
94    }
95
96    /// Make every operation on `path` fail with `error` — for exercising the explorer's
97    /// permission-denied and I/O-error rendering without needing a real unreadable dir.
98    pub fn fail(&self, path: impl AsRef<Path>, error: FsError) -> &Self {
99        let path = normalize(path.as_ref());
100        self.inner.lock().unwrap().failures.insert(path, error);
101        self
102    }
103
104    /// Every path currently in the tree, sorted. Useful for asserting after mutations.
105    pub fn paths(&self) -> Vec<PathBuf> {
106        self.inner.lock().unwrap().nodes.keys().cloned().collect()
107    }
108}
109
110impl Inner {
111    fn check_failure(&self, path: &Path) -> FsResult<()> {
112        match self.failures.get(path) {
113            Some(e) => Err(e.clone()),
114            None => Ok(()),
115        }
116    }
117}
118
119/// Resolve `.` and `..` lexically. Purely textual — we have no symlinks to chase.
120fn normalize(path: &Path) -> PathBuf {
121    let mut out = PathBuf::new();
122    for c in path.components() {
123        match c {
124            Component::ParentDir => {
125                out.pop();
126            }
127            Component::CurDir => {}
128            other => out.push(other.as_os_str()),
129        }
130    }
131    if out.as_os_str().is_empty() {
132        PathBuf::from("/")
133    } else {
134        out
135    }
136}
137
138fn insert_ancestors(nodes: &mut BTreeMap<PathBuf, Node>, path: &Path) {
139    let mut cur = PathBuf::new();
140    for c in path.components() {
141        cur.push(c.as_os_str());
142        if cur != path {
143            nodes.entry(cur.clone()).or_insert(Node::Dir);
144        }
145    }
146}
147
148fn kind_of(node: &Node) -> EntryKind {
149    match node {
150        Node::Dir => EntryKind::Dir,
151        Node::File(_) => EntryKind::File,
152        Node::Symlink(_) => EntryKind::Symlink,
153    }
154}
155
156impl FileSystemService for FakeFileSystem {
157    fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
158        let path = normalize(path);
159        let inner = self.inner.lock().unwrap();
160        inner.check_failure(&path)?;
161
162        match inner.nodes.get(&path) {
163            None => return Err(FsError::NotFound(path)),
164            Some(Node::Dir) => {}
165            Some(_) => return Err(FsError::NotADirectory(path)),
166        }
167
168        // Direct children only — the tree is lazy, so we never recurse here.
169        let mut out: Vec<DirEntryInfo> = inner
170            .nodes
171            .iter()
172            .filter(|(p, _)| p.parent() == Some(path.as_path()))
173            .map(|(p, node)| DirEntryInfo {
174                name: p.file_name().unwrap_or_default().to_os_string(),
175                path: p.clone(),
176                kind: kind_of(node),
177            })
178            .collect();
179        sort_entries(&mut out);
180        Ok(out)
181    }
182
183    fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
184        let path = normalize(path);
185        let inner = self.inner.lock().unwrap();
186        inner.check_failure(&path)?;
187        match inner.nodes.get(&path) {
188            Some(Node::File(bytes)) => Ok(bytes.clone()),
189            Some(_) => Err(FsError::Other { path, message: "not a regular file".to_string() }),
190            None => Err(FsError::NotFound(path)),
191        }
192    }
193
194    fn create_file(&self, path: &Path) -> FsResult<()> {
195        let path = normalize(path);
196        let mut inner = self.inner.lock().unwrap();
197        inner.check_failure(&path)?;
198        if inner.nodes.contains_key(&path) {
199            return Err(FsError::AlreadyExists(path));
200        }
201        insert_ancestors(&mut inner.nodes, &path);
202        inner.nodes.insert(path, Node::File(Vec::new()));
203        Ok(())
204    }
205
206    fn write_file(&self, path: &Path, contents: &[u8]) -> FsResult<()> {
207        let path = normalize(path);
208        let mut inner = self.inner.lock().unwrap();
209        inner.check_failure(&path)?;
210        insert_ancestors(&mut inner.nodes, &path);
211        inner.nodes.insert(path, Node::File(contents.to_vec()));
212        Ok(())
213    }
214
215    fn create_dir(&self, path: &Path) -> FsResult<()> {
216        let path = normalize(path);
217        let mut inner = self.inner.lock().unwrap();
218        inner.check_failure(&path)?;
219        insert_ancestors(&mut inner.nodes, &path);
220        inner.nodes.entry(path).or_insert(Node::Dir);
221        Ok(())
222    }
223
224    fn rename(&self, from: &Path, to: &Path) -> FsResult<()> {
225        let (from, to) = (normalize(from), normalize(to));
226        let mut inner = self.inner.lock().unwrap();
227        inner.check_failure(&from)?;
228        if !inner.nodes.contains_key(&from) {
229            return Err(FsError::NotFound(from));
230        }
231        if inner.nodes.contains_key(&to) {
232            return Err(FsError::AlreadyExists(to));
233        }
234        // Move the subtree: the node itself plus everything beneath it.
235        let moving: Vec<PathBuf> =
236            inner.nodes.keys().filter(|p| p.starts_with(&from)).cloned().collect();
237        for old in moving {
238            let node = inner.nodes.remove(&old).expect("key came from this map");
239            let suffix = old.strip_prefix(&from).expect("filtered by starts_with");
240            inner.nodes.insert(to.join(suffix), node);
241        }
242        insert_ancestors(&mut inner.nodes, &to);
243        Ok(())
244    }
245
246    fn remove_file(&self, path: &Path) -> FsResult<()> {
247        let path = normalize(path);
248        let mut inner = self.inner.lock().unwrap();
249        inner.check_failure(&path)?;
250        match inner.nodes.get(&path) {
251            Some(Node::Dir) => Err(FsError::Other { path, message: "is a directory".to_string() }),
252            Some(_) => {
253                inner.nodes.remove(&path);
254                Ok(())
255            }
256            None => Err(FsError::NotFound(path)),
257        }
258    }
259
260    fn remove_dir_all(&self, path: &Path) -> FsResult<()> {
261        let path = normalize(path);
262        let mut inner = self.inner.lock().unwrap();
263        inner.check_failure(&path)?;
264        if !inner.nodes.contains_key(&path) {
265            return Err(FsError::NotFound(path));
266        }
267        inner.nodes.retain(|p, _| !p.starts_with(&path));
268        Ok(())
269    }
270
271    fn canonicalize(&self, path: &Path) -> FsResult<PathBuf> {
272        let path = normalize(path);
273        let inner = self.inner.lock().unwrap();
274        inner.check_failure(&path)?;
275        if inner.nodes.contains_key(&path) {
276            Ok(path)
277        } else {
278            Err(FsError::NotFound(path))
279        }
280    }
281}
282
283#[cfg(test)]
284mod tests {
285    use super::*;
286
287    fn sample() -> FakeFileSystem {
288        FakeFileSystem::with_paths(&[
289            "/proj/src/main.rs",
290            "/proj/src/model.rs",
291            "/proj/Cargo.toml",
292            "/proj/target/debug/",
293        ])
294    }
295
296    fn names(fs: &FakeFileSystem, at: &str) -> Vec<String> {
297        fs.read_dir(Path::new(at))
298            .unwrap()
299            .iter()
300            .map(|e| e.name.to_string_lossy().into_owned())
301            .collect()
302    }
303
304    #[test]
305    fn read_dir_returns_direct_children_only_dirs_first() {
306        assert_eq!(names(&sample(), "/proj"), ["src", "target", "Cargo.toml"]);
307    }
308
309    #[test]
310    fn read_dir_does_not_recurse() {
311        // main.rs lives one level down and must not appear in /proj's listing.
312        assert!(!names(&sample(), "/proj").contains(&"main.rs".to_string()));
313        assert_eq!(names(&sample(), "/proj/src"), ["main.rs", "model.rs"]);
314    }
315
316    #[test]
317    fn ordering_matches_the_real_service_contract() {
318        let fs = FakeFileSystem::with_paths(&["/r/README.md", "/r/assets/", "/r/Cargo.toml"]);
319        assert_eq!(names(&fs, "/r"), ["assets", "Cargo.toml", "README.md"]);
320    }
321
322    #[test]
323    fn injected_failures_surface_on_read() {
324        let fs = sample();
325        let denied = PathBuf::from("/proj/src");
326        fs.fail(&denied, FsError::PermissionDenied(denied.clone()));
327        assert_eq!(fs.read_dir(&denied), Err(FsError::PermissionDenied(denied)));
328        // Siblings are unaffected.
329        assert!(fs.read_dir(Path::new("/proj")).is_ok());
330    }
331
332    #[test]
333    fn missing_and_wrong_kind_are_distinguishable() {
334        let fs = sample();
335        assert_eq!(
336            fs.read_dir(Path::new("/proj/nope")),
337            Err(FsError::NotFound(PathBuf::from("/proj/nope")))
338        );
339        assert_eq!(
340            fs.read_dir(Path::new("/proj/Cargo.toml")),
341            Err(FsError::NotADirectory(PathBuf::from("/proj/Cargo.toml")))
342        );
343    }
344
345    #[test]
346    fn create_file_refuses_to_clobber() {
347        let fs = sample();
348        fs.add_file("/proj/keep.txt", b"precious");
349        assert_eq!(
350            fs.create_file(Path::new("/proj/keep.txt")),
351            Err(FsError::AlreadyExists(PathBuf::from("/proj/keep.txt")))
352        );
353        assert_eq!(fs.read_file(Path::new("/proj/keep.txt")).unwrap(), b"precious");
354    }
355
356    #[test]
357    fn rename_moves_the_whole_subtree() {
358        let fs = sample();
359        fs.rename(Path::new("/proj/src"), Path::new("/proj/lib")).unwrap();
360        assert_eq!(names(&fs, "/proj/lib"), ["main.rs", "model.rs"]);
361        assert_eq!(
362            fs.read_dir(Path::new("/proj/src")),
363            Err(FsError::NotFound(PathBuf::from("/proj/src")))
364        );
365    }
366
367    #[test]
368    fn remove_dir_all_takes_descendants_but_not_siblings() {
369        let fs = sample();
370        fs.remove_dir_all(Path::new("/proj/src")).unwrap();
371        assert!(!fs.paths().iter().any(|p| p.starts_with("/proj/src")));
372        assert!(fs.paths().contains(&PathBuf::from("/proj/Cargo.toml")));
373    }
374
375    #[test]
376    fn remove_file_refuses_directories() {
377        let fs = sample();
378        assert!(fs.remove_file(Path::new("/proj/src")).is_err());
379        assert!(fs.read_dir(Path::new("/proj/src")).is_ok(), "directory survives");
380    }
381
382    #[test]
383    fn symlinks_are_listed_but_typed_as_symlinks() {
384        let fs = sample();
385        fs.add_symlink("/proj/link", "/proj/src");
386        let entries = fs.read_dir(Path::new("/proj")).unwrap();
387        let link = entries.iter().find(|e| e.name == "link").unwrap();
388        assert_eq!(link.kind, EntryKind::Symlink);
389    }
390
391    #[test]
392    fn paths_are_normalized_before_lookup() {
393        let fs = sample();
394        assert_eq!(names(&fs, "/proj/src/../src"), ["main.rs", "model.rs"]);
395        assert_eq!(names(&fs, "/proj/./src"), ["main.rs", "model.rs"]);
396    }
397}