#![allow(dead_code)]
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use notify_debouncer_mini::notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
pub type ChangeHandler = Arc<dyn Fn(&[PathBuf]) + Send + Sync>;
pub const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(500);
pub const DEFAULT_SKIP_SUBSTRINGS: &[&str] = &[
"/.git/", "/target/", "/node_modules/", "/__pycache__/", "/.venv/", "/build/", "/dist/", "/.DS_Store", ];
pub const DEFAULT_SKIP_EXTENSIONS: &[&str] = &[
"pyc", "pyo", "swp", "swo", "tmp", ];
#[derive(Clone, Debug)]
pub struct WatchConfig {
pub skip_substrings: Vec<String>,
pub skip_extensions: Vec<String>,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
skip_substrings: DEFAULT_SKIP_SUBSTRINGS
.iter()
.map(|s| (*s).to_string())
.collect(),
skip_extensions: DEFAULT_SKIP_EXTENSIONS
.iter()
.map(|s| (*s).to_string())
.collect(),
}
}
}
impl WatchConfig {
pub fn unfiltered() -> Self {
Self {
skip_substrings: Vec::new(),
skip_extensions: Vec::new(),
}
}
pub fn is_skipped(&self, path: &Path) -> bool {
if let Some(s) = path.to_str() {
for needle in &self.skip_substrings {
if s.contains(needle.as_str()) {
return true;
}
}
}
if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
for skip in &self.skip_extensions {
if ext == skip {
return true;
}
}
}
false
}
}
fn retain_unskipped(
config: &WatchConfig,
paths: impl IntoIterator<Item = PathBuf>,
) -> Vec<PathBuf> {
paths
.into_iter()
.filter(|p| !config.is_skipped(p))
.collect()
}
pub struct WatchHandle {
_debouncer: Debouncer<notify_debouncer_mini::notify::RecommendedWatcher>,
}
pub fn watch(
dir: &Path,
on_change: Option<ChangeHandler>,
debounce: Option<Duration>,
) -> Result<WatchHandle> {
watch_with_config(dir, on_change, debounce, WatchConfig::default())
}
pub fn watch_with_config(
dir: &Path,
on_change: Option<ChangeHandler>,
debounce: Option<Duration>,
config: WatchConfig,
) -> Result<WatchHandle> {
if !dir.is_dir() {
anyhow::bail!("--watch path is not a directory: {}", dir.display());
}
let debounce = debounce.unwrap_or(DEFAULT_DEBOUNCE);
let dir_for_log = dir.to_path_buf();
let on_change = on_change.unwrap_or_else(|| {
Arc::new(|_| {
})
});
let mut debouncer = new_debouncer(debounce, move |result: DebounceEventResult| match result {
Ok(events) => {
let paths = retain_unskipped(&config, events.into_iter().map(|e| e.path));
if paths.is_empty() {
return;
}
tracing::info!(
root = %dir_for_log.display(),
changed = paths.len(),
"watch: file change debounced"
);
on_change(&paths);
}
Err(e) => {
tracing::warn!(error = %e, "watch: error from notify");
}
})
.context("failed to construct file-system debouncer")?;
debouncer
.watcher()
.watch(dir, RecursiveMode::Recursive)
.with_context(|| format!("failed to watch {}", dir.display()))?;
tracing::info!(root = %dir.display(), debounce_ms = debounce.as_millis() as u64, "watch: active");
Ok(WatchHandle {
_debouncer: debouncer,
})
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::atomic::{AtomicUsize, Ordering};
#[test]
fn watch_rejects_non_directory() {
let result = watch(Path::new("/this/does/not/exist"), None, None);
assert!(result.is_err());
}
#[test]
fn watch_starts_and_drops_clean() {
let dir = tempfile::tempdir().unwrap();
let _handle = watch(dir.path(), None, Some(Duration::from_millis(100))).unwrap();
}
#[test]
fn callback_fires_on_file_change() {
use std::thread::sleep;
let dir = tempfile::tempdir().unwrap();
let counter = Arc::new(AtomicUsize::new(0));
let counter_for_cb = counter.clone();
let cb: ChangeHandler = Arc::new(move |_paths: &[PathBuf]| {
counter_for_cb.fetch_add(1, Ordering::SeqCst);
});
let _handle = watch(dir.path(), Some(cb), Some(Duration::from_millis(100))).unwrap();
sleep(Duration::from_millis(50)); std::fs::write(dir.path().join("a.txt"), "hi").unwrap();
sleep(Duration::from_millis(400)); assert!(
counter.load(Ordering::SeqCst) >= 1,
"expected callback to fire at least once after file write"
);
}
#[test]
fn default_config_skips_git_dir() {
let cfg = WatchConfig::default();
assert!(cfg.is_skipped(Path::new("/repo/.git/HEAD")));
assert!(cfg.is_skipped(Path::new("/repo/.git/objects/ab/cdef")));
}
#[test]
fn default_config_skips_target_dir() {
let cfg = WatchConfig::default();
assert!(cfg.is_skipped(Path::new("/repo/target/debug/foo.rlib")));
assert!(cfg.is_skipped(Path::new("/repo/target/release/build/x.o")));
}
#[test]
fn default_config_skips_node_modules() {
let cfg = WatchConfig::default();
assert!(cfg.is_skipped(Path::new("/repo/node_modules/@scope/package/index.js")));
}
#[test]
fn default_config_skips_python_bytecode() {
let cfg = WatchConfig::default();
assert!(cfg.is_skipped(Path::new("/repo/pkg/__pycache__/m.cpython-312.pyc")));
assert!(cfg.is_skipped(Path::new("/repo/lib.pyc")));
}
#[test]
fn default_config_skips_editor_swap() {
let cfg = WatchConfig::default();
assert!(cfg.is_skipped(Path::new("/repo/src/main.rs.swp")));
assert!(cfg.is_skipped(Path::new("/repo/draft.tmp")));
}
#[test]
fn default_config_passes_source_files() {
let cfg = WatchConfig::default();
assert!(!cfg.is_skipped(Path::new("/repo/src/main.rs")));
assert!(!cfg.is_skipped(Path::new("/repo/lib.py")));
assert!(!cfg.is_skipped(Path::new("/repo/index.ts")));
assert!(!cfg.is_skipped(Path::new("/repo/.gitignore")));
}
#[test]
fn unfiltered_config_skips_nothing() {
let cfg = WatchConfig::unfiltered();
assert!(!cfg.is_skipped(Path::new("/repo/.git/HEAD")));
assert!(!cfg.is_skipped(Path::new("/repo/target/foo.rlib")));
assert!(!cfg.is_skipped(Path::new("/repo/lib.pyc")));
}
#[test]
fn custom_config_round_trip() {
let cfg = WatchConfig {
skip_substrings: vec!["/secret/".to_string()],
skip_extensions: vec!["bak".to_string()],
};
assert!(cfg.is_skipped(Path::new("/repo/secret/key.txt")));
assert!(cfg.is_skipped(Path::new("/repo/file.bak")));
assert!(!cfg.is_skipped(Path::new("/repo/.git/HEAD")));
assert!(!cfg.is_skipped(Path::new("/repo/lib.pyc")));
}
#[test]
fn default_skip_substrings_are_anchored() {
let cfg = WatchConfig::default();
assert!(!cfg.is_skipped(Path::new("/repo/target")));
assert!(cfg.is_skipped(Path::new("/repo/target/foo")));
}
#[test]
fn noise_only_batch_retains_nothing() {
let cfg = WatchConfig::default();
let batch = vec![
PathBuf::from("/repo/target/debug/deps/a.rlib"),
PathBuf::from("/repo/target/release/build/x.o"),
PathBuf::from("/repo/.git/objects/ab/cdef"),
PathBuf::from("/repo/pkg/__pycache__/m.cpython-312.pyc"),
PathBuf::from("/repo/lib.pyc"),
];
assert!(retain_unskipped(&cfg, batch).is_empty());
}
#[test]
fn mixed_batch_retains_only_non_noise() {
let cfg = WatchConfig::default();
let batch = vec![
PathBuf::from("/repo/target/debug/deps/a.rlib"), PathBuf::from("/repo/src/main.rs"), PathBuf::from("/repo/.git/HEAD"), PathBuf::from("/repo/lib.py"), ];
let kept = retain_unskipped(&cfg, batch);
assert_eq!(
kept,
vec![
PathBuf::from("/repo/src/main.rs"),
PathBuf::from("/repo/lib.py"),
]
);
}
#[test]
fn unfiltered_config_retains_everything() {
let cfg = WatchConfig::unfiltered();
let batch = vec![
PathBuf::from("/repo/target/debug/a.rlib"),
PathBuf::from("/repo/.git/HEAD"),
];
assert_eq!(retain_unskipped(&cfg, batch.clone()), batch);
}
}