use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use tokio::sync::mpsc as tokio_mpsc;
#[derive(Debug, Clone)]
pub enum WatchEvent {
FilesChanged(Vec<PathBuf>),
Error(String),
}
#[derive(Debug, Clone)]
pub struct WatcherConfig {
pub debounce: Duration,
pub extensions: Vec<String>,
pub ignore_dirs: Vec<String>,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
debounce: Duration::from_millis(500),
extensions: vec!["rs".to_string()],
ignore_dirs: vec![
"target".to_string(),
".git".to_string(),
"node_modules".to_string(),
],
}
}
}
pub struct FileWatcher {
event_rx: tokio_mpsc::Receiver<WatchEvent>,
_handle: std::thread::JoinHandle<()>,
}
impl FileWatcher {
pub fn new(watch_path: &Path, config: WatcherConfig) -> anyhow::Result<Self> {
let (tx, rx) = tokio_mpsc::channel(100);
let watch_path = watch_path.to_path_buf();
let config_clone = config.clone();
let handle = std::thread::spawn(move || {
if let Err(e) = run_watcher(watch_path, config_clone, tx) {
tracing::error!("Watcher thread error: {}", e);
}
});
Ok(Self {
event_rx: rx,
_handle: handle,
})
}
pub async fn recv(&mut self) -> Option<WatchEvent> {
self.event_rx.recv().await
}
}
fn run_watcher(
watch_path: PathBuf,
config: WatcherConfig,
tx: tokio_mpsc::Sender<WatchEvent>,
) -> anyhow::Result<()> {
let (notify_tx, notify_rx) = mpsc::channel();
let mut debouncer = new_debouncer(config.debounce, notify_tx)?;
debouncer
.watcher()
.watch(&watch_path, RecursiveMode::Recursive)?;
tracing::info!("File watcher started for {:?}", watch_path);
loop {
match notify_rx.recv() {
Ok(Ok(events)) => {
let mut changed: HashSet<PathBuf> = HashSet::new();
for event in events {
if event.kind != DebouncedEventKind::Any {
continue;
}
let path = &event.path;
if should_ignore(path, &config.ignore_dirs) {
continue;
}
if !config.extensions.is_empty() {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if !config.extensions.iter().any(|e| e == ext) {
continue;
}
}
if path.exists() && path.is_file() {
changed.insert(path.clone());
}
}
if !changed.is_empty() {
let paths: Vec<_> = changed.into_iter().collect();
tracing::debug!("Files changed: {:?}", paths);
if tx.blocking_send(WatchEvent::FilesChanged(paths)).is_err() {
break;
}
}
}
Ok(Err(error)) => {
tracing::warn!("Watch error: {:?}", error);
let _ = tx.blocking_send(WatchEvent::Error(format!("{:?}", error)));
}
Err(_) => {
break;
}
}
}
tracing::info!("File watcher stopped");
Ok(())
}
fn should_ignore(path: &Path, ignore_dirs: &[String]) -> bool {
for component in path.components() {
if let std::path::Component::Normal(name) = component {
let name_str = name.to_string_lossy();
if ignore_dirs.iter().any(|d| d == name_str.as_ref()) {
return true;
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_should_ignore() {
let ignore = vec!["target".to_string(), ".git".to_string()];
assert!(should_ignore(Path::new("target/debug/foo.rs"), &ignore));
assert!(should_ignore(Path::new(".git/config"), &ignore));
assert!(should_ignore(Path::new("src/target/mod.rs"), &ignore));
assert!(!should_ignore(Path::new("src/main.rs"), &ignore));
assert!(!should_ignore(Path::new("crates/foo/src/lib.rs"), &ignore));
}
#[test]
fn test_config_default() {
let config = WatcherConfig::default();
assert_eq!(config.debounce, Duration::from_millis(500));
assert!(config.extensions.contains(&"rs".to_string()));
assert!(config.ignore_dirs.contains(&"target".to_string()));
}
}