use notify::{
Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher,
};
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::{Duration, Instant};
#[derive(Debug, Clone)]
pub struct WatcherEvent {
pub path: PathBuf,
pub kind: ChangeKind,
}
pub struct FileWatcher {
_watcher: RecommendedWatcher,
_thread: std::thread::JoinHandle<()>,
pub event_rx: mpsc::Receiver<WatcherEvent>,
}
impl FileWatcher {
pub fn start(
root: &Path,
dirs: &[PathBuf],
) -> Result<Self, notify::Error> {
let (notify_tx, notify_rx) = mpsc::channel::<notify::Result<Event>>();
let (event_tx, event_rx) = mpsc::channel::<WatcherEvent>();
let mut watcher = RecommendedWatcher::new(
move |res| {
let _ = notify_tx.send(res);
},
Config::default(),
)?;
for dir in dirs {
let abs_dir = root.join(dir);
if abs_dir.exists() {
watcher.watch(&abs_dir, RecursiveMode::Recursive)?;
}
}
let thread = std::thread::spawn(move || {
let debounce = Duration::from_millis(100);
let mut pending: Vec<(PathBuf, ChangeKind)> = Vec::new();
let mut last_event = Instant::now();
loop {
match notify_rx.recv_timeout(debounce) {
Ok(Ok(event)) => {
let kind = match event.kind {
EventKind::Create(_) => Some(ChangeKind::Created),
EventKind::Modify(_) => Some(ChangeKind::Modified),
EventKind::Remove(_) => Some(ChangeKind::Deleted),
_ => None,
};
if let Some(kind) = kind {
for path in event.paths {
if is_document_file(&path) {
pending.push((path, kind));
}
}
}
last_event = Instant::now();
}
Ok(Err(e)) => {
log::warn!("File watcher error: {e}");
}
Err(mpsc::RecvTimeoutError::Timeout) => {
if !pending.is_empty() && last_event.elapsed() >= debounce {
let mut seen = std::collections::HashMap::new();
for (path, kind) in pending.drain(..) {
seen.insert(path, kind);
}
for (path, kind) in seen {
if event_tx.send(WatcherEvent { path, kind }).is_err() {
return; }
}
}
}
Err(mpsc::RecvTimeoutError::Disconnected) => {
break;
}
}
}
});
Ok(FileWatcher {
_watcher: watcher,
_thread: thread,
event_rx,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum ChangeKind {
Created,
Modified,
Deleted,
}
fn is_document_file(path: &Path) -> bool {
match path.extension().and_then(|e| e.to_str()) {
Some("md") | Some("json") | Some("jsonl") => true,
_ => false,
}
}