1use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use crate::error::{Error, Result};
13
14pub const IGNORED_DIRS: [&str; 3] = [".omgbase", ".git", "node_modules"];
16
17#[must_use]
19pub fn is_ignored_dir(name: &str) -> bool {
20 IGNORED_DIRS.contains(&name)
21}
22
23#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
25pub struct FileStat {
26 pub mtime_ns: i64,
27 pub size: i64,
28}
29
30impl FileStat {
31 #[must_use]
33 pub fn revision(&self) -> String {
34 format!("{}:{}", self.mtime_ns, self.size)
35 }
36}
37
38pub trait FileSystem {
41 fn walk_markdown(&self, root: &Path) -> Result<Vec<String>>;
45 fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>>;
47 fn read(&self, root: &Path, path: &str) -> Result<Option<String>>;
49 fn exists(&self, root: &Path, path: &str) -> Result<bool> {
51 Ok(self.stat(root, path)?.is_some())
52 }
53}
54
55#[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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
134pub struct MemFile {
135 pub content: String,
136 pub mtime_ns: i64,
137}
138
139#[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 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 pub fn remove(&mut self, path: &str) {
169 self.files.retain(|(p, _)| p != path);
170 }
171
172 #[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 #[must_use]
180 pub fn files(&self) -> &[(String, MemFile)] {
181 &self.files
182 }
183}
184
185#[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 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#[doc(hidden)]
256pub struct TempDir(pub PathBuf);
257
258impl TempDir {
259 #[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}