Skip to main content

zenith_session/adapter/
fs.rs

1//! Filesystem adapter trait and implementations.
2//!
3//! [`Fs`] is the injectable boundary between zenith-session logic and the real
4//! operating system.  All library code that touches the filesystem receives an
5//! `&impl Fs` (or `&dyn Fs`) so that tests can substitute [`MemFs`] without
6//! touching disk.
7//!
8//! [`MemFs`] is the in-memory test adapter.  It lives in lib (not
9//! `#[cfg(test)]`) so that integration tests in later units can import it
10//! directly.  It is intentionally not feature-gated.
11
12use std::cell::RefCell;
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15
16use crate::error::SessionError;
17
18// ── Trait ─────────────────────────────────────────────────────────────────────
19
20/// Abstraction over filesystem operations used by zenith-session.
21///
22/// Implementations must satisfy the following contract:
23/// - All directory and file listings are returned **sorted** for deterministic
24///   iteration across platforms.
25/// - `write` must fail with an error when the parent directory does not exist
26///   (to faithfully model real OS behaviour so fakes don't silently
27///   false-pass tests that forget `create_dir_all`).
28pub trait Fs {
29    /// Create `path` and all missing ancestors, like `mkdir -p`.
30    fn create_dir_all(&self, path: &Path) -> Result<(), SessionError>;
31
32    /// Return `true` if `path` exists (file or directory).
33    fn exists(&self, path: &Path) -> bool;
34
35    /// Read the entire contents of the file at `path`.
36    fn read(&self, path: &Path) -> Result<Vec<u8>, SessionError>;
37
38    /// Write `data` to `path`, replacing any existing content.
39    ///
40    /// Returns an error if the parent directory does not exist.
41    fn write(&self, path: &Path, data: &[u8]) -> Result<(), SessionError>;
42
43    /// Append `data` to the file at `path`, creating it if absent. Like `write`,
44    /// it errors if the parent directory does not exist.
45    fn append(&self, path: &Path, data: &[u8]) -> Result<(), SessionError>;
46
47    /// List immediate children (files and directories) of `path`, sorted for
48    /// deterministic iteration.
49    fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, SessionError>;
50
51    /// Rename / move `from` to `to`.
52    fn rename(&self, from: &Path, to: &Path) -> Result<(), SessionError>;
53
54    /// Remove a file or directory tree at `path`.
55    ///
56    /// Tries `remove_file` first; on failure tries `remove_dir_all`.  This
57    /// keeps the API simple: callers do not need to know whether `path` is a
58    /// file or a directory.
59    fn remove(&self, path: &Path) -> Result<(), SessionError>;
60}
61
62// ── OsFs ──────────────────────────────────────────────────────────────────────
63
64/// Real filesystem adapter that delegates to `std::fs`.
65pub struct OsFs;
66
67impl Fs for OsFs {
68    fn create_dir_all(&self, path: &Path) -> Result<(), SessionError> {
69        std::fs::create_dir_all(path)?;
70        Ok(())
71    }
72
73    fn exists(&self, path: &Path) -> bool {
74        path.exists()
75    }
76
77    fn read(&self, path: &Path) -> Result<Vec<u8>, SessionError> {
78        let bytes = std::fs::read(path)?;
79        Ok(bytes)
80    }
81
82    fn write(&self, path: &Path, data: &[u8]) -> Result<(), SessionError> {
83        std::fs::write(path, data)?;
84        Ok(())
85    }
86
87    fn append(&self, path: &Path, data: &[u8]) -> Result<(), SessionError> {
88        use std::io::Write as _;
89        let mut f = std::fs::OpenOptions::new()
90            .create(true)
91            .append(true)
92            .open(path)?;
93        f.write_all(data)?;
94        Ok(())
95    }
96
97    fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, SessionError> {
98        let mut entries: Vec<PathBuf> = std::fs::read_dir(path)?
99            .map(|res| res.map(|e| e.path()))
100            .collect::<Result<Vec<_>, _>>()?;
101        entries.sort();
102        Ok(entries)
103    }
104
105    fn rename(&self, from: &Path, to: &Path) -> Result<(), SessionError> {
106        std::fs::rename(from, to)?;
107        Ok(())
108    }
109
110    fn remove(&self, path: &Path) -> Result<(), SessionError> {
111        match std::fs::remove_file(path) {
112            Ok(()) => Ok(()),
113            // Only fall through to a recursive directory removal when `path` is
114            // actually a directory. Any other failure (e.g. permission denied)
115            // is propagated as-is rather than masked by a misleading dir error.
116            Err(e) if e.kind() == std::io::ErrorKind::IsADirectory => {
117                std::fs::remove_dir_all(path)?;
118                Ok(())
119            }
120            Err(e) => Err(e.into()),
121        }
122    }
123}
124
125// ── MemFs ─────────────────────────────────────────────────────────────────────
126
127/// In-memory filesystem for tests.
128///
129/// Backed by a [`BTreeMap`] (files) and [`BTreeSet`] (directories) for
130/// deterministic, sorted output.  Interior mutability via [`RefCell`] lets
131/// callers hold `&MemFs` (shared reference) while still mutating state.
132///
133/// **Fidelity contract**: `write` returns an error when the parent directory
134/// has not been registered via `create_dir_all`, faithfully modelling real OS
135/// behaviour so tests cannot accidentally skip the parent-creation step.
136///
137/// This type is available in non-test builds so integration tests in later
138/// units can import it without feature flags.
139pub struct MemFs {
140    inner: RefCell<MemFsInner>,
141}
142
143struct MemFsInner {
144    files: BTreeMap<PathBuf, Vec<u8>>,
145    dirs: BTreeSet<PathBuf>,
146}
147
148impl MemFs {
149    pub fn new() -> Self {
150        Self {
151            inner: RefCell::new(MemFsInner {
152                files: BTreeMap::new(),
153                dirs: BTreeSet::new(),
154            }),
155        }
156    }
157}
158
159impl Default for MemFs {
160    fn default() -> Self {
161        Self::new()
162    }
163}
164
165impl Fs for MemFs {
166    fn create_dir_all(&self, path: &Path) -> Result<(), SessionError> {
167        let mut inner = self.inner.borrow_mut();
168        // Insert the path itself and every ancestor.
169        let mut current = path.to_path_buf();
170        loop {
171            let next = current
172                .parent()
173                .filter(|&p| p != current.as_path())
174                .map(|p| p.to_path_buf());
175            inner.dirs.insert(current);
176            match next {
177                Some(p) => current = p,
178                None => break,
179            }
180        }
181        Ok(())
182    }
183
184    fn exists(&self, path: &Path) -> bool {
185        let inner = self.inner.borrow();
186        inner.files.contains_key(path) || inner.dirs.contains(path)
187    }
188
189    fn read(&self, path: &Path) -> Result<Vec<u8>, SessionError> {
190        let inner = self.inner.borrow();
191        inner
192            .files
193            .get(path)
194            .cloned()
195            .ok_or_else(|| SessionError::new(format!("file not found: {}", path.display())))
196    }
197
198    fn write(&self, path: &Path, data: &[u8]) -> Result<(), SessionError> {
199        let mut inner = self.inner.borrow_mut();
200        // Enforce: parent must have been created first.
201        let parent = path
202            .parent()
203            .ok_or_else(|| SessionError::new("path has no parent directory"))?;
204        if !inner.dirs.contains(parent) {
205            return Err(SessionError::new(format!(
206                "parent directory does not exist: {}",
207                parent.display()
208            )));
209        }
210        inner.files.insert(path.to_path_buf(), data.to_vec());
211        Ok(())
212    }
213
214    fn append(&self, path: &Path, data: &[u8]) -> Result<(), SessionError> {
215        let mut inner = self.inner.borrow_mut();
216        let parent = path
217            .parent()
218            .ok_or_else(|| SessionError::new("path has no parent directory"))?;
219        if !inner.dirs.contains(parent) {
220            return Err(SessionError::new(format!(
221                "parent directory does not exist: {}",
222                parent.display()
223            )));
224        }
225        inner
226            .files
227            .entry(path.to_path_buf())
228            .or_default()
229            .extend_from_slice(data);
230        Ok(())
231    }
232
233    fn read_dir(&self, path: &Path) -> Result<Vec<PathBuf>, SessionError> {
234        let inner = self.inner.borrow();
235        if !inner.dirs.contains(path) {
236            return Err(SessionError::new(format!(
237                "directory not found: {}",
238                path.display()
239            )));
240        }
241        // Collect immediate children from both file and dir sets.
242        let mut children: BTreeSet<PathBuf> = BTreeSet::new();
243        for file_path in inner.files.keys() {
244            if file_path.parent() == Some(path) {
245                children.insert(file_path.clone());
246            }
247        }
248        for dir_path in inner.dirs.iter() {
249            if dir_path.parent() == Some(path) && dir_path != path {
250                children.insert(dir_path.clone());
251            }
252        }
253        // BTreeSet is already sorted.
254        Ok(children.into_iter().collect())
255    }
256
257    fn rename(&self, from: &Path, to: &Path) -> Result<(), SessionError> {
258        let mut inner = self.inner.borrow_mut();
259        let data = inner
260            .files
261            .remove(from)
262            .ok_or_else(|| SessionError::new(format!("file not found: {}", from.display())))?;
263        inner.files.insert(to.to_path_buf(), data);
264        Ok(())
265    }
266
267    fn remove(&self, path: &Path) -> Result<(), SessionError> {
268        let mut inner = self.inner.borrow_mut();
269        if inner.files.remove(path).is_some() {
270            return Ok(());
271        }
272        if inner.dirs.contains(path) {
273            // Remove all files and dirs under this subtree.
274            inner.files.retain(|k, _| !k.starts_with(path));
275            inner.dirs.retain(|k| !k.starts_with(path));
276            return Ok(());
277        }
278        Err(SessionError::new(format!(
279            "path not found: {}",
280            path.display()
281        )))
282    }
283}
284
285// ── Tests ─────────────────────────────────────────────────────────────────────
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290
291    // ── MemFs tests ───────────────────────────────────────────────────────────
292
293    #[test]
294    fn memfs_write_read_roundtrip() {
295        let fs = MemFs::new();
296        let dir = PathBuf::from("/data");
297        let file = dir.join("hello.txt");
298        fs.create_dir_all(&dir).unwrap();
299        fs.write(&file, b"hello world").unwrap();
300        assert_eq!(fs.read(&file).unwrap(), b"hello world");
301    }
302
303    #[test]
304    fn memfs_write_without_parent_errors() {
305        let fs = MemFs::new();
306        let file = PathBuf::from("/missing/dir/file.txt");
307        let result = fs.write(&file, b"data");
308        assert!(result.is_err(), "expected error when parent dir is absent");
309    }
310
311    #[test]
312    fn memfs_read_dir_sorted() {
313        let fs = MemFs::new();
314        let dir = PathBuf::from("/root");
315        fs.create_dir_all(&dir).unwrap();
316        // Write in reverse order to confirm sorting is by path, not insert order.
317        fs.write(&dir.join("c.txt"), b"c").unwrap();
318        fs.write(&dir.join("a.txt"), b"a").unwrap();
319        fs.write(&dir.join("b.txt"), b"b").unwrap();
320        let entries = fs.read_dir(&dir).unwrap();
321        assert_eq!(
322            entries,
323            vec![dir.join("a.txt"), dir.join("b.txt"), dir.join("c.txt")]
324        );
325    }
326
327    #[test]
328    fn memfs_read_dir_missing_errors() {
329        let fs = MemFs::new();
330        let result = fs.read_dir(Path::new("/nonexistent"));
331        assert!(result.is_err());
332    }
333
334    #[test]
335    fn memfs_rename_moves_file() {
336        let fs = MemFs::new();
337        let dir = PathBuf::from("/d");
338        fs.create_dir_all(&dir).unwrap();
339        let from = dir.join("old.txt");
340        let to = dir.join("new.txt");
341        fs.write(&from, b"payload").unwrap();
342        fs.rename(&from, &to).unwrap();
343        assert!(!fs.exists(&from));
344        assert!(fs.exists(&to));
345        assert_eq!(fs.read(&to).unwrap(), b"payload");
346    }
347
348    #[test]
349    fn memfs_rename_missing_errors() {
350        let fs = MemFs::new();
351        let result = fs.rename(Path::new("/a"), Path::new("/b"));
352        assert!(result.is_err());
353    }
354
355    #[test]
356    fn memfs_remove_file() {
357        let fs = MemFs::new();
358        let dir = PathBuf::from("/r");
359        fs.create_dir_all(&dir).unwrap();
360        let file = dir.join("f.txt");
361        fs.write(&file, b"x").unwrap();
362        fs.remove(&file).unwrap();
363        assert!(!fs.exists(&file));
364    }
365
366    #[test]
367    fn memfs_remove_dir_subtree() {
368        let fs = MemFs::new();
369        let parent = PathBuf::from("/p");
370        let child_dir = parent.join("sub");
371        fs.create_dir_all(&child_dir).unwrap();
372        fs.write(&child_dir.join("f.txt"), b"data").unwrap();
373        fs.remove(&parent).unwrap();
374        assert!(!fs.exists(&parent));
375        assert!(!fs.exists(&child_dir));
376        assert!(!fs.exists(&child_dir.join("f.txt")));
377    }
378
379    #[test]
380    fn memfs_append_creates_then_accumulates() {
381        let fs = MemFs::new();
382        let dir = PathBuf::from("/data");
383        let file = dir.join("journal.txt");
384        fs.create_dir_all(&dir).unwrap();
385        fs.append(&file, b"hello ").unwrap();
386        fs.append(&file, b"world").unwrap();
387        assert_eq!(fs.read(&file).unwrap(), b"hello world");
388    }
389
390    #[test]
391    fn memfs_append_without_parent_errors() {
392        let fs = MemFs::new();
393        let file = PathBuf::from("/missing/dir/journal.txt");
394        let result = fs.append(&file, b"data");
395        assert!(result.is_err(), "expected error when parent dir is absent");
396    }
397
398    #[test]
399    fn memfs_append_then_read_roundtrip() {
400        let fs = MemFs::new();
401        let dir = PathBuf::from("/logs");
402        let file = dir.join("log.txt");
403        fs.create_dir_all(&dir).unwrap();
404        fs.append(&file, b"line one\n").unwrap();
405        fs.append(&file, b"line two\n").unwrap();
406        assert_eq!(fs.read(&file).unwrap(), b"line one\nline two\n");
407    }
408
409    #[test]
410    fn memfs_exists_reflects_state() {
411        let fs = MemFs::new();
412        let dir = PathBuf::from("/e");
413        let file = dir.join("x.txt");
414        assert!(!fs.exists(&dir));
415        fs.create_dir_all(&dir).unwrap();
416        assert!(fs.exists(&dir));
417        assert!(!fs.exists(&file));
418        fs.write(&file, b"").unwrap();
419        assert!(fs.exists(&file));
420    }
421
422    // ── OsFs integration test (uses tempfile dev-dependency) ─────────────────
423
424    #[test]
425    fn osfs_write_read_read_dir_roundtrip() {
426        let tmp = tempfile::tempdir().expect("tempdir");
427        let fs = OsFs;
428        let dir = tmp.path().join("subdir");
429        fs.create_dir_all(&dir).unwrap();
430        let file_a = dir.join("a.txt");
431        let file_b = dir.join("b.txt");
432        fs.write(&file_a, b"aaa").unwrap();
433        fs.write(&file_b, b"bbb").unwrap();
434        assert_eq!(fs.read(&file_a).unwrap(), b"aaa");
435        assert_eq!(fs.read(&file_b).unwrap(), b"bbb");
436        let entries = fs.read_dir(&dir).unwrap();
437        assert_eq!(entries, vec![file_a.clone(), file_b.clone()]);
438    }
439}