use std::path::{Path, PathBuf};
use std::sync::mpsc;
use std::time::Duration;
use notify_debouncer_mini::{DebouncedEventKind, new_debouncer};
#[derive(Debug)]
pub enum WatchEvent {
FilesChanged(Vec<PathBuf>),
}
pub fn start_watcher(
project_path: &Path,
source_extensions: &[&str],
) -> anyhow::Result<(mpsc::Receiver<WatchEvent>, WatcherGuard)> {
let (tx, rx) = mpsc::channel();
let extensions: Vec<String> = source_extensions.iter().map(|s| s.to_string()).collect();
let project = project_path.to_path_buf();
let (debounced_tx, debounced_rx) = mpsc::channel();
let mut debouncer = new_debouncer(Duration::from_millis(500), debounced_tx)?;
debouncer
.watcher()
.watch(project_path, notify::RecursiveMode::Recursive)?;
let filter_thread = std::thread::Builder::new()
.name("grapha-watch-filter".into())
.spawn(move || {
for result in debounced_rx {
match result {
Ok(events) => {
let changed: Vec<PathBuf> = events
.into_iter()
.filter(|e| e.kind == DebouncedEventKind::Any)
.map(|e| e.path)
.filter(|p| is_source_file(p, &extensions, &project))
.collect();
if !changed.is_empty() {
let mut deduped = changed;
deduped.sort();
deduped.dedup();
if tx.send(WatchEvent::FilesChanged(deduped)).is_err() {
break; }
}
}
Err(e) => {
eprintln!("watch error: {e}");
}
}
}
})?;
Ok((
rx,
WatcherGuard {
_debouncer: debouncer,
_filter_thread: Some(filter_thread),
},
))
}
pub struct WatcherGuard {
_debouncer: notify_debouncer_mini::Debouncer<notify::RecommendedWatcher>,
_filter_thread: Option<std::thread::JoinHandle<()>>,
}
fn is_source_file(path: &Path, extensions: &[String], project: &Path) -> bool {
if !path.starts_with(project) {
return false;
}
let rel = path.strip_prefix(project).unwrap_or(path);
for component in rel.components() {
let s = component.as_os_str().to_string_lossy();
if s.starts_with('.')
|| s == "target"
|| s == "node_modules"
|| s == "DerivedData"
|| s == ".build"
|| s == "Pods"
{
return false;
}
}
path.extension()
.and_then(|ext| ext.to_str())
.is_some_and(|ext| extensions.iter().any(|e| e == ext))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn filters_source_files() {
let project = PathBuf::from("/project");
let extensions = vec!["swift".to_string(), "rs".to_string()];
assert!(is_source_file(
&PathBuf::from("/project/src/main.rs"),
&extensions,
&project
));
assert!(is_source_file(
&PathBuf::from("/project/Foo.swift"),
&extensions,
&project
));
assert!(!is_source_file(
&PathBuf::from("/project/target/debug/main.rs"),
&extensions,
&project
));
assert!(!is_source_file(
&PathBuf::from("/project/.build/file.swift"),
&extensions,
&project
));
assert!(!is_source_file(
&PathBuf::from("/project/readme.md"),
&extensions,
&project
));
}
}