use std::collections::hash_map::DefaultHasher;
use std::collections::{HashMap, HashSet};
use std::hash::Hasher;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use anyhow::{Context, Result};
use notify::RecommendedWatcher;
use notify::RecursiveMode;
use notify_debouncer_mini::{new_debouncer, DebounceEventResult, Debouncer};
pub struct WatcherHandle {
_debouncer: Debouncer<RecommendedWatcher>,
}
pub struct WatchConfig {
pub paths: Vec<PathBuf>,
pub explicit_files: Vec<PathBuf>,
pub ignore: Vec<PathBuf>,
pub debounce: Duration,
pub retry_unchanged_when: Option<Arc<dyn Fn() -> bool + Send + Sync>>,
}
pub fn spawn_watcher<F>(cfg: WatchConfig, on_event: F) -> Result<WatcherHandle>
where
F: Fn(Vec<PathBuf>) + Send + 'static,
{
let ignore: Vec<PathBuf> = cfg
.ignore
.iter()
.map(|p| std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
.collect();
let explicit_files: Vec<PathBuf> = cfg
.explicit_files
.iter()
.filter_map(|path| normalize_explicit_file(path))
.collect();
let mut content_hashes: HashMap<PathBuf, u64> = HashMap::new();
let retry_unchanged_when = cfg.retry_unchanged_when.clone();
let explicit_filter = explicit_files.clone();
let mut debouncer = new_debouncer(cfg.debounce, move |res: DebounceEventResult| match res {
Ok(events) => {
let mut paths: Vec<PathBuf> = Vec::with_capacity(events.len());
let mut seen: HashSet<PathBuf> = HashSet::with_capacity(events.len());
for e in events {
if should_ignore_event(&e.path, &ignore, &explicit_filter) {
continue;
}
if seen.insert(e.path.clone()) {
paths.push(e.path);
}
}
let retry_unchanged = retry_unchanged_when
.as_ref()
.is_some_and(|predicate| predicate());
paths.retain(|path| should_forward_path(&mut content_hashes, path, retry_unchanged));
if !paths.is_empty() {
on_event(paths);
}
}
Err(e) => {
eprintln!("watcher error: {e:?}");
}
})
.context("Cannot start file watcher")?;
let mut watched_roots = Vec::with_capacity(cfg.paths.len());
for p in &cfg.paths {
if p.exists() {
watched_roots.push(std::fs::canonicalize(p).unwrap_or_else(|_| p.clone()));
debouncer
.watcher()
.watch(p, RecursiveMode::Recursive)
.with_context(|| format!("Cannot watch {}", p.display()))?;
}
}
let mut explicit_parents = HashSet::new();
for file in &explicit_files {
if watched_roots.iter().any(|root| file.starts_with(root)) {
continue;
}
let Some(parent) = file.parent() else {
continue;
};
if parent.exists() && explicit_parents.insert(parent.to_path_buf()) {
debouncer
.watcher()
.watch(parent, RecursiveMode::NonRecursive)
.with_context(|| format!("Cannot watch {}", parent.display()))?;
}
}
Ok(WatcherHandle {
_debouncer: debouncer,
})
}
fn normalize_explicit_file(path: &Path) -> Option<PathBuf> {
if let Ok(canonical) = std::fs::canonicalize(path) {
return Some(canonical);
}
let parent = path
.parent()
.filter(|value| !value.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let parent = std::fs::canonicalize(parent).ok()?;
Some(parent.join(path.file_name()?))
}
fn is_explicit_file(event_path: &Path, explicit_files: &[PathBuf]) -> bool {
let normalized = std::fs::canonicalize(event_path).unwrap_or_else(|_| event_path.to_path_buf());
explicit_files.iter().any(|path| path == &normalized)
}
fn should_ignore_event(event_path: &Path, ignore: &[PathBuf], explicit_files: &[PathBuf]) -> bool {
is_ignored(event_path, ignore) && !is_explicit_file(event_path, explicit_files)
}
fn is_ignored(event_path: &Path, ignore: &[PathBuf]) -> bool {
if ignore.is_empty() {
return false;
}
let canon = std::fs::canonicalize(event_path).ok();
let candidate: &Path = canon.as_deref().unwrap_or(event_path);
for root in ignore {
if root.is_absolute() {
if candidate.starts_with(root) {
return true;
}
continue;
}
let mut components = root.components();
let first = components.next();
let only_one_component = components.next().is_none();
if let (Some(first), true) = (first, only_one_component) {
let name = first.as_os_str();
if candidate.components().any(|c| c.as_os_str() == name) {
return true;
}
} else if candidate.starts_with(root) {
return true;
}
}
false
}
#[must_use]
pub fn default_ignore_paths() -> Vec<PathBuf> {
vec![
PathBuf::from("node_modules"),
PathBuf::from(".git"),
PathBuf::from("target"),
]
}
const MAX_HASH_BYTES: u64 = 8 * 1024 * 1024;
fn content_changed(cache: &mut HashMap<PathBuf, u64>, path: &Path) -> bool {
match hash_file(path) {
Some(hash) => match cache.insert(path.to_path_buf(), hash) {
Some(previous) => previous != hash,
None => true,
},
None => {
cache.remove(path);
true
}
}
}
fn should_forward_path(
cache: &mut HashMap<PathBuf, u64>,
path: &Path,
retry_unchanged: bool,
) -> bool {
content_changed(cache, path) || retry_unchanged
}
fn hash_file(path: &Path) -> Option<u64> {
let metadata = std::fs::metadata(path).ok()?;
if !metadata.is_file() || metadata.len() > MAX_HASH_BYTES {
return None;
}
let bytes = std::fs::read(path).ok()?;
let mut hasher = DefaultHasher::new();
hasher.write(&bytes);
Some(hasher.finish())
}
#[cfg(test)]
#[allow(clippy::disallowed_methods)]
mod tests {
use super::*;
#[test]
fn is_ignored_matches_descendant() {
let dir = tempfile::tempdir().unwrap();
let dist = dir.path().join("dist");
std::fs::create_dir(&dist).unwrap();
let nested = dist.join("a/b.html");
std::fs::create_dir_all(nested.parent().unwrap()).unwrap();
std::fs::write(&nested, "x").unwrap();
let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
assert!(is_ignored(&nested, &ignore));
}
#[test]
fn explicit_manifest_bypasses_ignored_output_directory() {
let dir = tempfile::tempdir().unwrap();
let dist = dir.path().join("dist");
std::fs::create_dir(&dist).unwrap();
let manifest = dist.join("webui-projection.json");
let bundle = dist.join("index.js");
std::fs::write(&manifest, "{}").unwrap();
std::fs::write(&bundle, "export {};").unwrap();
let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
let explicit = vec![std::fs::canonicalize(&manifest).unwrap()];
assert!(!should_ignore_event(&manifest, &ignore, &explicit));
assert!(should_ignore_event(&bundle, &ignore, &explicit));
}
#[test]
fn is_ignored_misses_sibling() {
let dir = tempfile::tempdir().unwrap();
let dist = dir.path().join("dist");
let other = dir.path().join("src/a.md");
std::fs::create_dir(&dist).unwrap();
std::fs::create_dir_all(other.parent().unwrap()).unwrap();
std::fs::write(&other, "x").unwrap();
let ignore = vec![std::fs::canonicalize(&dist).unwrap()];
assert!(!is_ignored(&other, &ignore));
}
#[test]
fn is_ignored_is_false_when_no_ignores() {
let p = Path::new("/anything/at/all");
assert!(!is_ignored(p, &[]));
}
#[test]
fn is_ignored_matches_relative_component_anywhere() {
let ignore = vec![PathBuf::from("target")];
assert!(is_ignored(Path::new("/repo/target/debug/build"), &ignore));
assert!(is_ignored(
Path::new("/repo/sub/crate/target/foo.rs"),
&ignore
));
assert!(!is_ignored(Path::new("/repo/src/target_name.rs"), &ignore));
}
#[test]
fn is_ignored_handles_node_modules_and_git() {
let ignore = default_ignore_paths();
assert!(is_ignored(
Path::new("/repo/node_modules/foo/index.js"),
&ignore
));
assert!(is_ignored(Path::new("/repo/.git/HEAD"), &ignore));
assert!(!is_ignored(Path::new("/repo/src/index.ts"), &ignore));
}
#[test]
fn content_changed_skips_identical_saves() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a.css");
std::fs::write(&file, "a { color: red; }").unwrap();
let mut cache = HashMap::new();
assert!(content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file));
std::fs::write(&file, "a { color: blue; }").unwrap();
assert!(content_changed(&mut cache, &file));
assert!(!content_changed(&mut cache, &file));
std::fs::remove_file(&file).unwrap();
assert!(content_changed(&mut cache, &file));
std::fs::write(&file, "a { color: blue; }").unwrap();
assert!(content_changed(&mut cache, &file));
}
#[test]
fn unchanged_save_can_retry_when_error_active() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("a.css");
std::fs::write(&file, "a { color: red; }").unwrap();
let mut cache = HashMap::new();
assert!(should_forward_path(&mut cache, &file, false));
assert!(!should_forward_path(&mut cache, &file, false));
assert!(should_forward_path(&mut cache, &file, true));
}
}