use std::path::{Path, PathBuf};
use std::time::UNIX_EPOCH;
use crate::error::{Error, Result};
pub const IGNORED_DIRS: [&str; 3] = [".omgbase", ".git", "node_modules"];
#[must_use]
pub fn is_ignored_dir(name: &str) -> bool {
IGNORED_DIRS.contains(&name)
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub struct FileStat {
pub mtime_ns: i64,
pub size: i64,
}
impl FileStat {
#[must_use]
pub fn revision(&self) -> String {
format!("{}:{}", self.mtime_ns, self.size)
}
}
pub trait FileSystem {
fn walk_markdown(&self, root: &Path) -> Result<Vec<String>>;
fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>>;
fn read(&self, root: &Path, path: &str) -> Result<Option<String>>;
fn exists(&self, root: &Path, path: &str) -> Result<bool> {
Ok(self.stat(root, path)?.is_some())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct RealFileSystem;
fn mtime_ns(meta: &std::fs::Metadata) -> i64 {
meta.modified()
.ok()
.and_then(|t| match t.duration_since(UNIX_EPOCH) {
Ok(d) => i64::try_from(d.as_nanos()).ok(),
Err(e) => i64::try_from(e.duration().as_nanos()).ok().map(|n| -n),
})
.unwrap_or(0)
}
fn walk_real(dir: &Path, root: &Path, out: &mut Vec<String>) -> Result<()> {
let mut entries: Vec<std::fs::DirEntry> = std::fs::read_dir(dir)
.map_err(|e| Error::io("cannot read directory", dir, e))?
.collect::<std::io::Result<_>>()
.map_err(|e| Error::io("cannot read directory entry in", dir, e))?;
entries.sort_by(|a, b| {
a.file_name()
.as_encoded_bytes()
.cmp(b.file_name().as_encoded_bytes())
});
for entry in entries {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if is_ignored_dir(&name_str) {
continue;
}
let full = entry.path();
let meta = std::fs::metadata(&full).map_err(|e| Error::io("cannot stat", &full, e))?;
if meta.is_dir() {
walk_real(&full, root, out)?;
} else if name_str.ends_with(".md") {
let rel = full.strip_prefix(root).unwrap_or(&full);
let parts: Vec<String> = rel
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
out.push(parts.join("/"));
}
}
Ok(())
}
impl FileSystem for RealFileSystem {
fn walk_markdown(&self, root: &Path) -> Result<Vec<String>> {
let mut out = Vec::new();
walk_real(root, root, &mut out)?;
Ok(out)
}
fn stat(&self, root: &Path, path: &str) -> Result<Option<FileStat>> {
let abs = root.join(path);
match std::fs::metadata(&abs) {
Ok(meta) => Ok(Some(FileStat {
mtime_ns: mtime_ns(&meta),
size: i64::try_from(meta.len()).unwrap_or(i64::MAX),
})),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::io("cannot stat", abs, e)),
}
}
fn read(&self, root: &Path, path: &str) -> Result<Option<String>> {
let abs = root.join(path);
match std::fs::read(&abs) {
Ok(bytes) => Ok(Some(String::from_utf8_lossy(&bytes).into_owned())),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(Error::io("cannot read", abs, e)),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct MemFile {
pub content: String,
pub mtime_ns: i64,
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct MemFileSystem {
files: Vec<(String, MemFile)>,
}
impl MemFileSystem {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn set(&mut self, path: &str, content: &str, mtime_ns: i64) {
let file = MemFile {
content: content.to_owned(),
mtime_ns,
};
match self.files.iter_mut().find(|(p, _)| p == path) {
Some((_, f)) => *f = file,
None => self.files.push((path.to_owned(), file)),
}
}
pub fn remove(&mut self, path: &str) {
self.files.retain(|(p, _)| p != path);
}
#[must_use]
pub fn get(&self, path: &str) -> Option<&MemFile> {
self.files.iter().find(|(p, _)| p == path).map(|(_, f)| f)
}
#[must_use]
pub fn files(&self) -> &[(String, MemFile)] {
&self.files
}
}
#[derive(Default)]
struct DirNode {
files: std::collections::BTreeSet<Vec<u8>>,
dirs: std::collections::BTreeMap<Vec<u8>, DirNode>,
}
impl DirNode {
fn insert(&mut self, parts: &[&str]) {
match parts {
[] => {}
[file] => {
self.files.insert(file.as_bytes().to_vec());
}
[dir, rest @ ..] => self
.dirs
.entry(dir.as_bytes().to_vec())
.or_default()
.insert(rest),
}
}
fn walk(&self, prefix: &str, out: &mut Vec<String>) {
let mut names: Vec<(&[u8], bool)> = self
.files
.iter()
.map(|f| (f.as_slice(), false))
.chain(self.dirs.keys().map(|d| (d.as_slice(), true)))
.collect();
names.sort();
for (name, is_dir) in names {
let name = String::from_utf8_lossy(name);
if is_dir {
if is_ignored_dir(&name) {
continue;
}
self.dirs[name.as_bytes()].walk(&format!("{prefix}{name}/"), out);
} else if name.ends_with(".md") {
out.push(format!("{prefix}{name}"));
}
}
}
}
impl FileSystem for MemFileSystem {
fn walk_markdown(&self, _root: &Path) -> Result<Vec<String>> {
let mut root = DirNode::default();
for (p, _) in &self.files {
let parts: Vec<&str> = p.split('/').filter(|s| !s.is_empty()).collect();
root.insert(&parts);
}
let mut out = Vec::new();
root.walk("", &mut out);
Ok(out)
}
fn stat(&self, _root: &Path, path: &str) -> Result<Option<FileStat>> {
Ok(self.get(path).map(|f| FileStat {
mtime_ns: f.mtime_ns,
size: i64::try_from(f.content.len()).unwrap_or(i64::MAX),
}))
}
fn read(&self, _root: &Path, path: &str) -> Result<Option<String>> {
Ok(self.get(path).map(|f| f.content.clone()))
}
}
#[doc(hidden)]
pub struct TempDir(pub PathBuf);
impl TempDir {
#[must_use]
pub fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let dir =
std::env::temp_dir().join(format!("omgbase-sync-{tag}-{}-{nanos}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
Self(dir)
}
#[must_use]
pub fn path(&self) -> &Path {
&self.0
}
}
impl Drop for TempDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mem_fs_walks_bytewise_per_directory_and_filters() {
let mut m = MemFileSystem::new();
m.set("b.md", "b", 2);
m.set("a.md", "a", 1);
m.set("dir/c.md", "c", 3);
m.set("notes.txt", "t", 4);
m.set(".git/x.md", "x", 5);
m.set("node_modules/p/y.md", "y", 6);
m.set("sub/.omgbase/z.md", "z", 7);
m.set("node_modules.md", "ok", 8);
m.set("a/x.md", "x", 9);
let root = Path::new("/ignored");
assert_eq!(
m.walk_markdown(root).unwrap(),
["a/x.md", "a.md", "b.md", "dir/c.md", "node_modules.md"],
"bytewise per directory, depth-first: the directory `a` sorts before `a.md`"
);
m.set("b.md", "bb", 9);
assert_eq!(
m.stat(root, "b.md").unwrap(),
Some(FileStat {
mtime_ns: 9,
size: 2
})
);
assert_eq!(m.stat(root, "b.md").unwrap().unwrap().revision(), "9:2");
m.remove("b.md");
m.set("b.md", "b", 10);
assert_eq!(
m.walk_markdown(root).unwrap()[2],
"b.md",
"insertion order is irrelevant"
);
assert_eq!(m.read(root, "a.md").unwrap().as_deref(), Some("a"));
assert_eq!(m.read(root, "nope.md").unwrap(), None);
assert!(m.exists(root, "a.md").unwrap());
assert!(!m.exists(root, "nope.md").unwrap());
m.remove("nope.md");
assert_eq!(m.stat(root, "é.md").unwrap(), None);
m.set("é.md", "é", 1);
assert_eq!(
m.stat(root, "é.md").unwrap().unwrap().size,
2,
"UTF-8 bytes"
);
}
#[test]
fn real_fs_walks_skipping_ignored_dirs() {
let tmp = TempDir::new("fs");
let root = tmp.path();
std::fs::create_dir_all(root.join("sub/deep")).unwrap();
std::fs::create_dir_all(root.join(".git")).unwrap();
std::fs::create_dir_all(root.join("node_modules/x")).unwrap();
std::fs::create_dir_all(root.join(".omgbase")).unwrap();
std::fs::write(root.join("a.md"), "# A\n").unwrap();
std::fs::write(root.join("sub/deep/b.md"), "# B\n").unwrap();
std::fs::write(root.join("sub/c.txt"), "no").unwrap();
std::fs::write(root.join(".git/d.md"), "no").unwrap();
std::fs::write(root.join("node_modules/x/e.md"), "no").unwrap();
std::fs::write(root.join(".omgbase/f.md"), "no").unwrap();
std::fs::create_dir_all(root.join("a")).unwrap();
std::fs::write(root.join("a/z.md"), "# Z\n").unwrap();
std::fs::write(root.join("Z.md"), "# Z\n").unwrap();
let fs = RealFileSystem;
assert_eq!(
fs.walk_markdown(root).unwrap(),
["Z.md", "a/z.md", "a.md", "sub/deep/b.md"],
"bytewise per directory (uppercase first, `a/` before `a.md`), depth-first"
);
let st = fs.stat(root, "a.md").unwrap().unwrap();
assert_eq!(st.size, 4);
assert!(
st.mtime_ns > 1_000_000_000_000_000_000,
"nanoseconds since the epoch"
);
assert_eq!(fs.stat(root, "zzz.md").unwrap(), None);
assert_eq!(fs.read(root, "a.md").unwrap().as_deref(), Some("# A\n"));
assert_eq!(fs.read(root, "zzz.md").unwrap(), None);
assert!(fs.exists(root, "sub/deep/b.md").unwrap());
assert!(fs.walk_markdown(&root.join("missing")).is_err());
}
}