1use std::path::{Path, PathBuf};
10use std::time::UNIX_EPOCH;
11
12use omgbase_store::{DocStore, FsDocStore};
13
14use crate::error::{Error, Result};
15
16pub const IGNORED_DIRS: [&str; 3] = [".omgbase", ".git", "node_modules"];
18
19#[must_use]
21pub fn is_ignored_dir(name: &str) -> bool {
22 IGNORED_DIRS.contains(&name)
23}
24
25#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
27pub struct FileStat {
28 pub mtime_ns: i64,
29 pub size: i64,
30}
31
32impl FileStat {
33 #[must_use]
35 pub fn revision(&self) -> String {
36 format!("{}:{}", self.mtime_ns, self.size)
37 }
38}
39
40pub trait FileSystem {
43 fn walk_markdown(&self, root: &Path) -> Result<Vec<String>>;
47 fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>>;
49 fn read(&self, root: &Path, path: &str) -> Result<Option<String>>;
51 fn exists(&self, root: &Path, path: &str) -> Result<bool> {
53 Ok(self.stat(root, path)?.is_some())
54 }
55 fn doc_store(&self, _root: &Path) -> Option<Box<dyn DocStore>> {
61 None
62 }
63}
64
65#[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 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 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#[derive(Clone, Debug, PartialEq, Eq)]
148pub struct MemFile {
149 pub content: String,
150 pub mtime_ns: i64,
151}
152
153#[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 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 pub fn remove(&mut self, path: &str) {
183 self.files.retain(|(p, _)| p != path);
184 }
185
186 #[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 #[must_use]
194 pub fn files(&self) -> &[(String, MemFile)] {
195 &self.files
196 }
197}
198
199#[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 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#[doc(hidden)]
270pub struct TempDir(pub PathBuf);
271
272impl TempDir {
273 #[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}