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