Skip to main content

omgbase_sync/
fs.rs

1//! The filesystem seam (`spec/sync/README.md` §4.2): the walk over `.md`
2//! files — each directory's entries in **bytewise order of their names**,
3//! depth-first (§9: Node's `readdir` sorts through libuv, so a port whose
4//! listing is unsorted sorts) — `(mtime_ns, size)` stats and reads, behind a
5//! trait so the sweep's I/O stays out of the fixtures. A real implementation
6//! over `std::fs` and an in-memory one over a flat path map that walks the
7//! same way.
8
9use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use omgbase_store::{DocStore, FsDocStore};
13
14use crate::error::{Error, Result};
15
16/// The directory names the walk skips.
17pub const IGNORED_DIRS: [&str; 3] = [".omgbase", ".git", "node_modules"];
18
19/// Whether a directory entry name is one the walk skips.
20#[must_use]
21pub fn is_ignored_dir(name: &str) -> bool {
22    IGNORED_DIRS.contains(&name)
23}
24
25/// A file's cheap change token.
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct FileStat {
28    pub mtime_ns: i64,
29    pub size: i64,
30}
31
32impl FileStat {
33    /// The fs adapter's `revision` (`spec/sync` §5): `"<mtime_ns>:<size>"`.
34    #[must_use]
35    pub fn revision(&self) -> String {
36        format!("{}:{}", self.mtime_ns, self.size)
37    }
38}
39
40/// Where the fast path's bytes come from. Paths are repo-relative with `/`
41/// separators; every method takes the repo root.
42pub trait FileSystem {
43    /// §4.2: every regular file named `*.md` at any depth under `root`,
44    /// skipping [`IGNORED_DIRS`], depth-first with each directory's entries
45    /// in bytewise name order.
46    fn walk_markdown(&self, root: &Path) -> Result<Vec<String>>;
47    /// `(mtime_ns, size)` of `path` under `root`; `None` when absent.
48    fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>>;
49    /// The bytes at `path` as text; `None` when absent.
50    fn read(&self, root: &Path, path: &str) -> Result<Option<String>>;
51    /// Whether `path` exists under `root`.
52    fn exists(&self, root: &Path, path: &str) -> Result<bool> {
53        Ok(self.stat(root, path)?.is_some())
54    }
55    /// The write seam over the same tree (`spec/sync` §4.3 step 0, store
56    /// 14.0): the [`DocStore`] the sweep and recovery replay pending
57    /// materializations through. `None` for a filesystem that cannot write
58    /// (the in-memory fixture one), in which case step 0 is skipped — the
59    /// rows stay for a materializer that can.
60    fn doc_store(&self, _root: &Path) -> Option<Box<dyn DocStore>> {
61        None
62    }
63}
64
65/// The operating system's filesystem.
66#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
67pub struct RealFileSystem;
68
69fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
70    meta.modified()
71        .ok()
72        .and_then(|t| match t.duration_since(UNIX_EPOCH) {
73            Ok(d) => i64::try_from(d.as_nanos()).ok(),
74            Err(e) => i64::try_from(e.duration().as_nanos()).ok().map(|n| -n),
75        })
76        .unwrap_or(0)
77}
78
79fn walk_real(dir: &Path, root: &Path, out: &mut Vec<String>) -> Result<()> {
80    let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)
81        .map_err(|e| Error::io("cannot read directory", dir, e))?
82        .collect::<std::io::Result<_>>()
83        .map_err(|e| Error::io("cannot read directory entry in", dir, e))?;
84    // Bytewise by name, as libuv's `scandir` (`strcmp`) hands Node its listing.
85    entries.sort_by(|a, b| {
86        a.file_name()
87            .as_encoded_bytes()
88            .cmp(b.file_name().as_encoded_bytes())
89    });
90    for entry in entries {
91        let name = entry.file_name();
92        let name_str = name.to_string_lossy();
93        if is_ignored_dir(&name_str) {
94            continue;
95        }
96        let full = entry.path();
97        // `statSync(full).isDirectory()`: follows symlinks, like `metadata`.
98        let meta = std::fs::metadata(&full).map_err(|e| Error::io("cannot stat", &full, e))?;
99        if meta.is_dir() {
100            walk_real(&full, root, out)?;
101        } else if name_str.ends_with(".md") {
102            let rel = full.strip_prefix(root).unwrap_or(&full);
103            let parts: Vec<String> = rel
104                .components()
105                .map(|c| c.as_os_str().to_string_lossy().into_owned())
106                .collect();
107            out.push(parts.join("/"));
108        }
109    }
110    Ok(())
111}
112
113impl FileSystem for RealFileSystem {
114    fn walk_markdown(&self, root: &Path) -> Result<Vec<String>> {
115        let mut out = Vec::new();
116        walk_real(root, root, &mut out)?;
117        Ok(out)
118    }
119
120    fn doc_store(&self, root: &Path) -> Option<Box<dyn DocStore>> {
121        Some(Box::new(FsDocStore::new(root)))
122    }
123
124    fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>> {
125        let abs = root.join(path);
126        match std::fs::metadata(&abs) {
127            Ok(meta) => Ok(Some(FileStat {
128                mtime_ns: mtime_ns(&meta),
129                size: i64::try_from(meta.len()).unwrap_or(i64::MAX),
130            })),
131            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
132            Err(e) => Err(Error::io("cannot stat", abs, e)),
133        }
134    }
135
136    fn read(&self, root: &Path, path: &str) -> Result<Option<String>> {
137        let abs = root.join(path);
138        match std::fs::read(&abs) {
139            Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
140            Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
141            Err(e) => Err(Error::io("cannot read", abs, e)),
142        }
143    }
144}
145
146/// One in-memory file.
147#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct MemFile {
149    pub content: String,
150    pub mtime_ns: i64,
151}
152
153/// An in-memory filesystem (the fixture runner's): files keyed by
154/// repo-relative path; the walk treats the `/`-separated paths as a directory
155/// tree and visits each directory's entries in bytewise name order,
156/// depth-first, exactly as [`RealFileSystem`] does (§4.2). The root argument
157/// is ignored.
158#[derive(Clone, Debug, Default, PartialEq, Eq)]
159pub struct MemFileSystem {
160    files: Vec<(String, MemFile)>,
161}
162
163impl MemFileSystem {
164    #[must_use]
165    pub fn new() -> Self {
166        Self::default()
167    }
168
169    /// Set `path` to `content` with `mtime_ns` (`size` is the UTF-8 length).
170    pub fn set(&mut self, path: &str, content: &str, mtime_ns: i64) {
171        let file = MemFile {
172            content: content.to_owned(),
173            mtime_ns,
174        };
175        match self.files.iter_mut().find(|(p, _)| p == path) {
176            Some((_, f)) => *f = file,
177            None => self.files.push((path.to_owned(), file)),
178        }
179    }
180
181    /// Remove `path` (a no-op when absent).
182    pub fn remove(&mut self, path: &str) {
183        self.files.retain(|(p, _)| p != path);
184    }
185
186    /// The file at `path`.
187    #[must_use]
188    pub fn get(&self, path: &str) -> Option<&MemFile> {
189        self.files.iter().find(|(p, _)| p == path).map(|(_, f)| f)
190    }
191
192    /// Every file, in the order they were set.
193    #[must_use]
194    pub fn files(&self) -> &[(String, MemFile)] {
195        &self.files
196    }
197}
198
199/// One directory of a path tree: entries by name, bytewise.
200#[derive(Default)]
201struct DirNode {
202    files: std::collections::BTreeSet<Vec<u8>>,
203    dirs: std::collections::BTreeMap<Vec<u8>, DirNode>,
204}
205
206impl DirNode {
207    fn insert(&mut self, parts: &[&str]) {
208        match parts {
209            [] => {}
210            [file] => {
211                self.files.insert(file.as_bytes().to_vec());
212            }
213            [dir, rest @ ..] => self
214                .dirs
215                .entry(dir.as_bytes().to_vec())
216                .or_default()
217                .insert(rest),
218        }
219    }
220
221    /// Depth-first, entries (files and directories together) by name.
222    fn walk(&self, prefix: &str, out: &mut Vec<String>) {
223        let mut names: Vec<(&[u8], bool)> = self
224            .files
225            .iter()
226            .map(|f| (f.as_slice(), false))
227            .chain(self.dirs.keys().map(|d| (d.as_slice(), true)))
228            .collect();
229        names.sort();
230        for (name, is_dir) in names {
231            let name = String::from_utf8_lossy(name);
232            if is_dir {
233                if is_ignored_dir(&name) {
234                    continue;
235                }
236                self.dirs[name.as_bytes()].walk(&format!("{prefix}{name}/"), out);
237            } else if name.ends_with(".md") {
238                out.push(format!("{prefix}{name}"));
239            }
240        }
241    }
242}
243
244impl FileSystem for MemFileSystem {
245    fn walk_markdown(&self, _root: &Path) -> Result<Vec<String>> {
246        let mut root = DirNode::default();
247        for (p, _) in &self.files {
248            let parts: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect();
249            root.insert(&parts);
250        }
251        let mut out = Vec::new();
252        root.walk("", &mut out);
253        Ok(out)
254    }
255
256    fn stat(&self, _root: &Path, path: &str) -> Result<Option<FileStat>> {
257        Ok(self.get(path).map(|f| FileStat {
258            mtime_ns: f.mtime_ns,
259            size: i64::try_from(f.content.len()).unwrap_or(i64::MAX),
260        }))
261    }
262
263    fn read(&self, _root: &Path, path: &str) -> Result<Option<String>> {
264        Ok(self.get(path).map(|f| f.content.clone()))
265    }
266}
267
268/// A temporary directory for tests, removed on drop.
269#[doc(hidden)]
270pub struct TempDir(pub PathBuf);
271
272impl TempDir {
273    /// A fresh, unique directory under the system temp dir.
274    #[must_use]
275    pub fn new(tag: &str) -> Self {
276        let nanos = std::time::SystemTime::now()
277            .duration_since(UNIX_EPOCH)
278            .map(|d| d.as_nanos())
279            .unwrap_or(0);
280        let dir =
281            std::env::temp_dir().join(format!("omgbase-sync-{tag}-{}-{nanos}", std::process::id()));
282        std::fs::create_dir_all(&dir).expect("temp dir");
283        Self(dir)
284    }
285
286    #[must_use]
287    pub fn path(&self) -> &Path {
288        &self.0
289    }
290}
291
292impl Drop for TempDir {
293    fn drop(&mut self) {
294        let _ = std::fs::remove_dir_all(&self.0);
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn mem_fs_walks_bytewise_per_directory_and_filters() {
304        let mut m = MemFileSystem::new();
305        m.set("b.md", "b", 2);
306        m.set("a.md", "a", 1);
307        m.set("dir/c.md", "c", 3);
308        m.set("notes.txt", "t", 4);
309        m.set(".git/x.md", "x", 5);
310        m.set("node_modules/p/y.md", "y", 6);
311        m.set("sub/.omgbase/z.md", "z", 7);
312        m.set("node_modules.md", "ok", 8);
313        m.set("a/x.md", "x", 9);
314        let root = Path::new("/ignored");
315        assert_eq!(
316            m.walk_markdown(root).unwrap(),
317            ["a/x.md", "a.md", "b.md", "dir/c.md", "node_modules.md"],
318            "bytewise per directory, depth-first: the directory `a` sorts before `a.md`"
319        );
320        m.set("b.md", "bb", 9);
321        assert_eq!(
322            m.stat(root, "b.md").unwrap(),
323            Some(FileStat {
324                mtime_ns: 9,
325                size: 2
326            })
327        );
328        assert_eq!(m.stat(root, "b.md").unwrap().unwrap().revision(), "9:2");
329        m.remove("b.md");
330        m.set("b.md", "b", 10);
331        assert_eq!(
332            m.walk_markdown(root).unwrap()[2],
333            "b.md",
334            "insertion order is irrelevant"
335        );
336        assert_eq!(m.read(root, "a.md").unwrap().as_deref(), Some("a"));
337        assert_eq!(m.read(root, "nope.md").unwrap(), None);
338        assert!(m.exists(root, "a.md").unwrap());
339        assert!(!m.exists(root, "nope.md").unwrap());
340        m.remove("nope.md");
341        assert_eq!(m.stat(root, "é.md").unwrap(), None);
342        m.set("é.md", "é", 1);
343        assert_eq!(
344            m.stat(root, "é.md").unwrap().unwrap().size,
345            2,
346            "UTF-8 bytes"
347        );
348    }
349
350    #[test]
351    fn real_fs_walks_skipping_ignored_dirs() {
352        let tmp = TempDir::new("fs");
353        let root = tmp.path();
354        std::fs::create_dir_all(root.join("sub/deep")).unwrap();
355        std::fs::create_dir_all(root.join(".git")).unwrap();
356        std::fs::create_dir_all(root.join("node_modules/x")).unwrap();
357        std::fs::create_dir_all(root.join(".omgbase")).unwrap();
358        std::fs::write(root.join("a.md"), "# A\n").unwrap();
359        std::fs::write(root.join("sub/deep/b.md"), "# B\n").unwrap();
360        std::fs::write(root.join("sub/c.txt"), "no").unwrap();
361        std::fs::write(root.join(".git/d.md"), "no").unwrap();
362        std::fs::write(root.join("node_modules/x/e.md"), "no").unwrap();
363        std::fs::write(root.join(".omgbase/f.md"), "no").unwrap();
364        std::fs::create_dir_all(root.join("a")).unwrap();
365        std::fs::write(root.join("a/z.md"), "# Z\n").unwrap();
366        std::fs::write(root.join("Z.md"), "# Z\n").unwrap();
367        let fs = RealFileSystem;
368        assert_eq!(
369            fs.walk_markdown(root).unwrap(),
370            ["Z.md", "a/z.md", "a.md", "sub/deep/b.md"],
371            "bytewise per directory (uppercase first, `a/` before `a.md`), depth-first"
372        );
373        let st = fs.stat(root, "a.md").unwrap().unwrap();
374        assert_eq!(st.size, 4);
375        assert!(
376            st.mtime_ns > 1_000_000_000_000_000_000,
377            "nanoseconds since the epoch"
378        );
379        assert_eq!(fs.stat(root, "zzz.md").unwrap(), None);
380        assert_eq!(fs.read(root, "a.md").unwrap().as_deref(), Some("# A\n"));
381        assert_eq!(fs.read(root, "zzz.md").unwrap(), None);
382        assert!(fs.exists(root, "sub/deep/b.md").unwrap());
383        assert!(fs.walk_markdown(&root.join("missing")).is_err());
384    }
385}