use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use notify::{Config, PollWatcher, RecursiveMode, Watcher};
use crate::debounce::{ChangeKind, Debouncer, RawChange};
use crate::filter::PathFilter;
#[derive(Debug, thiserror::Error)]
pub enum WatchError {
#[error("notify error: {0}")]
Notify(#[from] notify::Error),
#[error("watch root does not exist: {0}")]
RootNotFound(String),
}
pub struct FileWatcher {
_watcher: PollWatcher,
receiver: mpsc::Receiver<notify::Result<notify::Event>>,
root: PathBuf,
filter: PathFilter,
debouncer: Debouncer,
}
impl FileWatcher {
pub fn new(root: &Path, filter: PathFilter) -> Result<Self, WatchError> {
Self::with_poll_interval(root, filter, Duration::from_millis(200))
}
pub fn with_poll_interval(root: &Path, filter: PathFilter, interval: Duration) -> Result<Self, WatchError> {
if !root.exists() {
return Err(WatchError::RootNotFound(root.display().to_string()));
}
let (tx, rx) = mpsc::channel();
let config = Config::default()
.with_poll_interval(interval)
.with_compare_contents(true);
let mut watcher = PollWatcher::new(tx, config)?;
watcher.watch(root, RecursiveMode::Recursive)?;
Ok(FileWatcher {
_watcher: watcher,
receiver: rx,
root: root.to_path_buf(),
filter,
debouncer: Debouncer::new(),
})
}
pub fn poll_changes(&mut self) -> Vec<RawChange> {
while let Ok(event_result) = self.receiver.try_recv() {
if let Ok(event) = event_result {
let kind = match event.kind {
notify::EventKind::Create(_) => ChangeKind::Created,
notify::EventKind::Modify(_) => ChangeKind::Modified,
notify::EventKind::Remove(_) => ChangeKind::Deleted,
_ => continue,
};
for path in event.paths {
if let Ok(relative) = path.strip_prefix(&self.root) {
let rel_str = relative.to_string_lossy().to_string();
if self.filter.is_included(&rel_str) {
self.debouncer.push(RawChange {
path: relative.to_path_buf(),
kind: kind.clone(),
});
}
}
}
}
}
self.debouncer.drain()
}
pub fn root(&self) -> &Path {
&self.root
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
use std::fs;
fn test_watcher(root: &Path, filter: PathFilter) -> FileWatcher {
FileWatcher::with_poll_interval(root, filter, Duration::from_millis(100)).unwrap()
}
fn poll_with_retry(watcher: &mut FileWatcher, retries: u32) -> Vec<RawChange> {
for _ in 0..retries {
std::thread::sleep(Duration::from_millis(300));
let changes = watcher.poll_changes();
if !changes.is_empty() {
return changes;
}
}
vec![]
}
#[test]
fn detect_file_change() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("test.rs");
fs::write(&file_path, b"original").unwrap();
let filter = PathFilter::new(vec![], vec![]);
let mut watcher = test_watcher(dir.path(), filter);
std::thread::sleep(Duration::from_millis(500));
fs::write(&file_path, b"modified").unwrap();
let changes = poll_with_retry(&mut watcher, 10);
assert!(!changes.is_empty(), "should detect the file change");
}
#[test]
fn filtered_files_ignored() {
let dir = TempDir::new().unwrap();
let log_path = dir.path().join("debug.log");
let rs_path = dir.path().join("main.rs");
fs::write(&log_path, b"log").unwrap();
fs::write(&rs_path, b"code").unwrap();
let filter = PathFilter::new(vec![], vec!["*.log".into()]);
let mut watcher = test_watcher(dir.path(), filter);
std::thread::sleep(Duration::from_millis(500));
fs::write(&log_path, b"more log").unwrap();
fs::write(&rs_path, b"more code").unwrap();
let changes = poll_with_retry(&mut watcher, 10);
let paths: Vec<_> = changes.iter().map(|c| c.path.clone()).collect();
assert!(!paths.iter().any(|p| p.to_string_lossy().contains(".log")),
"log files should be filtered out");
}
#[test]
fn detect_new_file() {
let dir = TempDir::new().unwrap();
let filter = PathFilter::new(vec![], vec![]);
let mut watcher = test_watcher(dir.path(), filter);
std::thread::sleep(Duration::from_millis(500));
let new_file = dir.path().join("new.rs");
fs::write(&new_file, b"new content").unwrap();
let changes = poll_with_retry(&mut watcher, 10);
assert!(!changes.is_empty(), "should detect new file creation");
}
}