Skip to main content

termesh_filesystem/
real.rs

1//! The `std::fs`-backed [`FileSystemService`]. The one place in the workspace that is
2//! allowed to touch `std::fs` — everything else goes through the trait (ARCHITECTURE.md §7.4).
3
4use std::io;
5use std::path::{Path, PathBuf};
6
7use crate::service::{sort_entries, DirEntryInfo, EntryKind, FileSystemService, FsError, FsResult};
8
9/// Reads and writes the actual filesystem. Stateless, so it is cheap to share across
10/// the worker thread and any future services that need it.
11#[derive(Debug, Clone, Copy, Default)]
12pub struct RealFileSystem;
13
14impl RealFileSystem {
15    pub const fn new() -> Self {
16        Self
17    }
18}
19
20/// Translate an `io::Error` into our vocabulary, attaching the path it concerns.
21fn map_err(path: &Path, e: io::Error) -> FsError {
22    match e.kind() {
23        io::ErrorKind::NotFound => FsError::NotFound(path.to_path_buf()),
24        io::ErrorKind::PermissionDenied => FsError::PermissionDenied(path.to_path_buf()),
25        io::ErrorKind::AlreadyExists => FsError::AlreadyExists(path.to_path_buf()),
26        _ => FsError::Other { path: path.to_path_buf(), message: e.to_string() },
27    }
28}
29
30impl FileSystemService for RealFileSystem {
31    fn read_dir(&self, path: &Path) -> FsResult<Vec<DirEntryInfo>> {
32        let mut out = Vec::new();
33        for entry in std::fs::read_dir(path).map_err(|e| map_err(path, e))? {
34            // A single unreadable entry must not sink the whole listing — skip it and
35            // keep its siblings (ADR-0005 §6).
36            let Ok(entry) = entry else { continue };
37            let entry_path = entry.path();
38
39            // `file_type()` on the DirEntry does not follow symlinks, which is what we
40            // want: a symlinked directory is shown but not traversed through.
41            let kind = match entry.file_type() {
42                Ok(ft) if ft.is_symlink() => EntryKind::Symlink,
43                Ok(ft) if ft.is_dir() => EntryKind::Dir,
44                Ok(_) => EntryKind::File,
45                Err(_) => continue,
46            };
47
48            out.push(DirEntryInfo { name: entry.file_name(), path: entry_path, kind });
49        }
50        sort_entries(&mut out);
51        Ok(out)
52    }
53
54    fn read_file(&self, path: &Path) -> FsResult<Vec<u8>> {
55        std::fs::read(path).map_err(|e| map_err(path, e))
56    }
57
58    fn create_file(&self, path: &Path) -> FsResult<()> {
59        // create_new: never truncate an existing file out from under someone.
60        std::fs::OpenOptions::new()
61            .write(true)
62            .create_new(true)
63            .open(path)
64            .map(|_| ())
65            .map_err(|e| map_err(path, e))
66    }
67
68    fn write_file(&self, path: &Path, contents: &[u8]) -> FsResult<()> {
69        std::fs::write(path, contents).map_err(|e| map_err(path, e))
70    }
71
72    fn create_dir(&self, path: &Path) -> FsResult<()> {
73        std::fs::create_dir_all(path).map_err(|e| map_err(path, e))
74    }
75
76    fn rename(&self, from: &Path, to: &Path) -> FsResult<()> {
77        std::fs::rename(from, to).map_err(|e| map_err(from, e))
78    }
79
80    fn remove_file(&self, path: &Path) -> FsResult<()> {
81        std::fs::remove_file(path).map_err(|e| map_err(path, e))
82    }
83
84    fn remove_dir_all(&self, path: &Path) -> FsResult<()> {
85        std::fs::remove_dir_all(path).map_err(|e| map_err(path, e))
86    }
87
88    fn canonicalize(&self, path: &Path) -> FsResult<PathBuf> {
89        std::fs::canonicalize(path).map_err(|e| map_err(path, e))
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    /// A scratch directory under the OS temp dir, removed on drop.
98    struct TempDir(PathBuf);
99
100    impl TempDir {
101        fn new(tag: &str) -> Self {
102            // Nanosecond stamp + tag keeps parallel test threads from colliding without
103            // pulling in a tempfile dependency for four tests.
104            let stamp = std::time::SystemTime::now()
105                .duration_since(std::time::UNIX_EPOCH)
106                .unwrap()
107                .as_nanos();
108            let p = std::env::temp_dir().join(format!("termesh-fs-{tag}-{stamp}"));
109            std::fs::create_dir_all(&p).unwrap();
110            Self(p)
111        }
112        fn path(&self) -> &Path {
113            &self.0
114        }
115    }
116
117    impl Drop for TempDir {
118        fn drop(&mut self) {
119            let _ = std::fs::remove_dir_all(&self.0);
120        }
121    }
122
123    #[test]
124    fn read_dir_lists_one_level_sorted_dirs_first() {
125        let tmp = TempDir::new("readdir");
126        let fs = RealFileSystem::new();
127        fs.create_dir(&tmp.path().join("src/nested")).unwrap();
128        fs.create_file(&tmp.path().join("Cargo.toml")).unwrap();
129        fs.create_file(&tmp.path().join("README.md")).unwrap();
130
131        let entries = fs.read_dir(tmp.path()).unwrap();
132        let names: Vec<_> = entries.iter().map(|e| e.name.to_string_lossy().into_owned()).collect();
133        assert_eq!(names, ["src", "Cargo.toml", "README.md"], "one level only, dirs first");
134        assert_eq!(entries[0].kind, EntryKind::Dir);
135    }
136
137    #[test]
138    fn create_file_refuses_to_clobber() {
139        let tmp = TempDir::new("clobber");
140        let fs = RealFileSystem::new();
141        let f = tmp.path().join("a.txt");
142        fs.create_file(&f).unwrap();
143        std::fs::write(&f, b"precious").unwrap();
144
145        assert_eq!(fs.create_file(&f), Err(FsError::AlreadyExists(f.clone())));
146        assert_eq!(fs.read_file(&f).unwrap(), b"precious", "existing content is untouched");
147    }
148
149    #[test]
150    fn missing_paths_report_not_found() {
151        let tmp = TempDir::new("missing");
152        let fs = RealFileSystem::new();
153        let missing = tmp.path().join("nope");
154        assert_eq!(fs.read_dir(&missing), Err(FsError::NotFound(missing.clone())));
155        assert_eq!(fs.read_file(&missing), Err(FsError::NotFound(missing)));
156    }
157
158    #[test]
159    fn rename_and_remove_round_trip() {
160        let tmp = TempDir::new("rename");
161        let fs = RealFileSystem::new();
162        let (a, b) = (tmp.path().join("a.txt"), tmp.path().join("b.txt"));
163        fs.create_file(&a).unwrap();
164        fs.rename(&a, &b).unwrap();
165
166        let names: Vec<_> =
167            fs.read_dir(tmp.path()).unwrap().iter().map(|e| e.name.clone()).collect();
168        assert_eq!(names, ["b.txt"]);
169
170        fs.remove_file(&b).unwrap();
171        assert!(fs.read_dir(tmp.path()).unwrap().is_empty());
172    }
173
174    #[test]
175    fn symlinks_are_reported_as_symlinks_not_followed() {
176        // Unix-only: creating symlinks on Windows needs elevated privileges.
177        #[cfg(unix)]
178        {
179            let tmp = TempDir::new("symlink");
180            let fs = RealFileSystem::new();
181            fs.create_dir(&tmp.path().join("target")).unwrap();
182            std::os::unix::fs::symlink(tmp.path().join("target"), tmp.path().join("link")).unwrap();
183
184            let entries = fs.read_dir(tmp.path()).unwrap();
185            let link = entries.iter().find(|e| e.name == "link").unwrap();
186            assert_eq!(link.kind, EntryKind::Symlink, "must not resolve to Dir");
187        }
188    }
189}