Skip to main content

tokmd_io_port/
lib.rs

1//! # tokmd-io-port
2//!
3//! **Tier 0 (Contract)**
4//!
5//! I/O port traits for host-abstracted file access.
6//! Enables WASM targets by replacing real fs with in-memory backends.
7//!
8//! ## What belongs here
9//! * The `ReadFs` trait and its implementations
10//! * `HostFs` – delegates to `std::fs`
11//! * `MemFs` – in-memory store for tests and WASM
12//!
13//! ## What does NOT belong here
14//! * Directory traversal / walking (use tokmd-walk)
15//! * Content scanning (use tokmd-scan)
16
17use std::collections::BTreeMap;
18use std::path::{Path, PathBuf};
19
20// ---------------------------------------------------------------------------
21// Trait
22// ---------------------------------------------------------------------------
23
24/// Read-only filesystem port.
25pub trait ReadFs {
26    type Error: std::error::Error;
27
28    fn read_to_string(&self, path: &Path) -> Result<String, Self::Error>;
29    fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, Self::Error>;
30    fn exists(&self, path: &Path) -> bool;
31    fn is_dir(&self, path: &Path) -> bool;
32    fn is_file(&self, path: &Path) -> bool;
33}
34
35// ---------------------------------------------------------------------------
36// HostFs – default std::fs implementation
37// ---------------------------------------------------------------------------
38
39/// Default host filesystem implementation.
40#[derive(Debug, Clone, Copy)]
41pub struct HostFs;
42
43impl ReadFs for HostFs {
44    type Error = std::io::Error;
45
46    fn read_to_string(&self, path: &Path) -> Result<String, Self::Error> {
47        std::fs::read_to_string(path)
48    }
49
50    fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, Self::Error> {
51        std::fs::read(path)
52    }
53
54    fn exists(&self, path: &Path) -> bool {
55        path.exists()
56    }
57
58    fn is_dir(&self, path: &Path) -> bool {
59        path.is_dir()
60    }
61
62    fn is_file(&self, path: &Path) -> bool {
63        path.is_file()
64    }
65}
66
67// ---------------------------------------------------------------------------
68// MemFs – in-memory filesystem for testing and WASM
69// ---------------------------------------------------------------------------
70
71/// In-memory filesystem for testing and WASM.
72///
73/// Files are stored as byte vectors keyed by path. Directories are inferred
74/// from the set of stored file paths – any path that is a proper prefix of a
75/// stored file is considered a directory.
76#[derive(Debug, Clone, Default)]
77pub struct MemFs {
78    files: BTreeMap<PathBuf, Vec<u8>>,
79}
80
81/// Error type returned by [`MemFs`] operations.
82#[derive(Debug)]
83pub struct MemFsError {
84    kind: MemFsErrorKind,
85    path: PathBuf,
86}
87
88#[derive(Debug)]
89enum MemFsErrorKind {
90    NotFound,
91    InvalidUtf8,
92}
93
94impl std::fmt::Display for MemFsError {
95    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
96        match self.kind {
97            MemFsErrorKind::NotFound => write!(f, "not found: {}", self.path.display()),
98            MemFsErrorKind::InvalidUtf8 => {
99                write!(f, "invalid UTF-8 in: {}", self.path.display())
100            }
101        }
102    }
103}
104
105impl std::error::Error for MemFsError {}
106
107impl MemFs {
108    pub fn new() -> Self {
109        Self::default()
110    }
111
112    /// Insert a UTF-8 file.
113    pub fn add_file(&mut self, path: impl Into<PathBuf>, contents: impl Into<String>) {
114        self.files.insert(path.into(), contents.into().into_bytes());
115    }
116
117    /// Insert a binary file.
118    pub fn add_bytes(&mut self, path: impl Into<PathBuf>, bytes: impl Into<Vec<u8>>) {
119        self.files.insert(path.into(), bytes.into());
120    }
121
122    /// Iterate deterministic file paths stored in the virtual filesystem.
123    pub fn file_paths(&self) -> impl Iterator<Item = &Path> {
124        self.files.keys().map(PathBuf::as_path)
125    }
126
127    /// Return the size of a stored file in bytes.
128    pub fn file_size(&self, path: &Path) -> Result<u64, MemFsError> {
129        self.files
130            .get(path)
131            .map(|bytes| bytes.len() as u64)
132            .ok_or_else(|| self.not_found(path))
133    }
134
135    fn not_found(&self, path: &Path) -> MemFsError {
136        MemFsError {
137            kind: MemFsErrorKind::NotFound,
138            path: path.to_path_buf(),
139        }
140    }
141}
142
143impl ReadFs for MemFs {
144    type Error = MemFsError;
145
146    fn read_to_string(&self, path: &Path) -> Result<String, Self::Error> {
147        let bytes = self.files.get(path).ok_or_else(|| self.not_found(path))?;
148        String::from_utf8(bytes.clone()).map_err(|_| MemFsError {
149            kind: MemFsErrorKind::InvalidUtf8,
150            path: path.to_path_buf(),
151        })
152    }
153
154    fn read_bytes(&self, path: &Path) -> Result<Vec<u8>, Self::Error> {
155        self.files
156            .get(path)
157            .cloned()
158            .ok_or_else(|| self.not_found(path))
159    }
160
161    fn exists(&self, path: &Path) -> bool {
162        self.is_file(path) || self.is_dir(path)
163    }
164
165    fn is_dir(&self, path: &Path) -> bool {
166        // A path is a directory if any stored file has it as a proper prefix.
167        self.files.keys().any(|k| k.starts_with(path) && k != path)
168    }
169
170    fn is_file(&self, path: &Path) -> bool {
171        self.files.contains_key(path)
172    }
173}
174
175// ---------------------------------------------------------------------------
176// Tests
177// ---------------------------------------------------------------------------
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    // ---- HostFs tests ----
184
185    #[test]
186    fn host_fs_read_to_string() {
187        let dir = tempfile::tempdir().unwrap();
188        let file = dir.path().join("hello.txt");
189        std::fs::write(&file, "hello world").unwrap();
190
191        let fs = HostFs;
192        assert_eq!(fs.read_to_string(&file).unwrap(), "hello world");
193    }
194
195    #[test]
196    fn host_fs_read_bytes() {
197        let dir = tempfile::tempdir().unwrap();
198        let file = dir.path().join("data.bin");
199        std::fs::write(&file, b"\x00\x01\x02").unwrap();
200
201        let fs = HostFs;
202        assert_eq!(fs.read_bytes(&file).unwrap(), vec![0, 1, 2]);
203    }
204
205    #[test]
206    fn host_fs_exists() {
207        let dir = tempfile::tempdir().unwrap();
208        let file = dir.path().join("exists.txt");
209        std::fs::write(&file, "").unwrap();
210
211        let fs = HostFs;
212        assert!(fs.exists(&file));
213        assert!(!fs.exists(&dir.path().join("nope.txt")));
214    }
215
216    #[test]
217    fn host_fs_is_dir() {
218        let dir = tempfile::tempdir().unwrap();
219        let fs = HostFs;
220        assert!(fs.is_dir(dir.path()));
221        assert!(!fs.is_dir(&dir.path().join("nope")));
222    }
223
224    #[test]
225    fn host_fs_is_file() {
226        let dir = tempfile::tempdir().unwrap();
227        let file = dir.path().join("f.txt");
228        std::fs::write(&file, "x").unwrap();
229
230        let fs = HostFs;
231        assert!(fs.is_file(&file));
232        assert!(!fs.is_file(dir.path()));
233    }
234
235    #[test]
236    fn host_fs_read_missing_file_errors() {
237        let fs = HostFs;
238        let result = fs.read_to_string(Path::new("/definitely/not/here.txt"));
239        assert!(result.is_err());
240    }
241
242    // ---- MemFs tests ----
243
244    #[test]
245    fn mem_fs_read_to_string() {
246        let mut fs = MemFs::new();
247        fs.add_file(PathBuf::from("a.txt"), "contents");
248        assert_eq!(fs.read_to_string(Path::new("a.txt")).unwrap(), "contents");
249    }
250
251    #[test]
252    fn mem_fs_read_bytes() {
253        let mut fs = MemFs::new();
254        fs.add_bytes(PathBuf::from("b.bin"), vec![0xDE, 0xAD]);
255        assert_eq!(fs.read_bytes(Path::new("b.bin")).unwrap(), vec![0xDE, 0xAD]);
256    }
257
258    #[test]
259    fn mem_fs_not_found() {
260        let fs = MemFs::new();
261        let err = fs.read_to_string(Path::new("missing.txt")).unwrap_err();
262        assert!(err.to_string().contains("not found"));
263    }
264
265    #[test]
266    fn mem_fs_invalid_utf8() {
267        let mut fs = MemFs::new();
268        fs.add_bytes(PathBuf::from("bad.txt"), vec![0xFF, 0xFE]);
269        let err = fs.read_to_string(Path::new("bad.txt")).unwrap_err();
270        assert!(err.to_string().contains("invalid UTF-8"));
271    }
272
273    #[test]
274    fn mem_fs_exists() {
275        let mut fs = MemFs::new();
276        fs.add_file(PathBuf::from("src/lib.rs"), "fn main() {}");
277        assert!(fs.exists(Path::new("src/lib.rs")));
278        assert!(fs.exists(Path::new("src"))); // directory
279        assert!(!fs.exists(Path::new("nope")));
280    }
281
282    #[test]
283    fn mem_fs_is_dir() {
284        let mut fs = MemFs::new();
285        fs.add_file(PathBuf::from("src/lib.rs"), "");
286        assert!(fs.is_dir(Path::new("src")));
287        assert!(!fs.is_dir(Path::new("src/lib.rs"))); // file, not dir
288        assert!(!fs.is_dir(Path::new("other")));
289    }
290
291    #[test]
292    fn mem_fs_is_file() {
293        let mut fs = MemFs::new();
294        fs.add_file(PathBuf::from("src/lib.rs"), "");
295        assert!(fs.is_file(Path::new("src/lib.rs")));
296        assert!(!fs.is_file(Path::new("src")));
297    }
298
299    #[test]
300    fn mem_fs_default_is_empty() {
301        let fs = MemFs::default();
302        assert!(!fs.exists(Path::new("anything")));
303    }
304
305    #[test]
306    fn mem_fs_file_paths_are_sorted() {
307        let mut fs = MemFs::new();
308        fs.add_file(PathBuf::from("z/file.txt"), "z");
309        fs.add_file(PathBuf::from("a/file.txt"), "a");
310        fs.add_file(PathBuf::from("m/file.txt"), "m");
311
312        let paths: Vec<_> = fs
313            .file_paths()
314            .map(|path| path.to_string_lossy().into_owned())
315            .collect();
316
317        assert_eq!(paths, vec!["a/file.txt", "m/file.txt", "z/file.txt"]);
318    }
319
320    #[test]
321    fn mem_fs_file_size_reads_inserted_length() {
322        let mut fs = MemFs::new();
323        fs.add_bytes(PathBuf::from("blob.bin"), vec![1, 2, 3, 4, 5]);
324
325        assert_eq!(fs.file_size(Path::new("blob.bin")).unwrap(), 5);
326    }
327
328    #[test]
329    fn mem_fs_file_size_missing_errors() {
330        let fs = MemFs::new();
331        let err = fs.file_size(Path::new("missing.bin")).unwrap_err();
332        assert!(err.to_string().contains("not found"));
333    }
334}