use anyhow::{anyhow, Result};
use notify::{RecommendedWatcher, RecursiveMode, Watcher};
use notify_debouncer_full::{new_debouncer, DebounceEventResult, Debouncer, FileIdMap};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver};
use std::time::Duration;
use crate::cache::normalize_path;
fn normalize_event_path(path: &Path) -> PathBuf {
PathBuf::from(normalize_path(path))
}
const INDEXABLE_EXTENSIONS: &[&str] = &[
"rs",
"js",
"mjs",
"cjs",
"jsx",
"ts",
"mts",
"cts",
"tsx",
"py",
"pyw",
"pyi",
"c",
"h",
"cpp",
"cc",
"cxx",
"hpp",
"hxx",
"cs",
"csx",
"java",
"kt",
"kts",
"go",
"rb",
"rake",
"php",
"swift",
"sh",
"bash",
"zsh",
"fish",
"ps1",
"psm1",
"psd1",
"html",
"htm",
"css",
"scss",
"sass",
"less",
"vue",
"svelte",
"json",
"jsonc",
"json5",
"yaml",
"yml",
"toml",
"xml",
"ini",
"conf",
"config",
"csproj",
"sln",
"props",
"targets",
"razor",
"cshtml",
"sql",
"md",
"markdown",
"rst",
"graphql",
"gql",
"proto",
"dockerfile",
];
const IGNORED_DIRS: &[&str] = &[
".git",
".codesearch.db",
"node_modules",
"target",
".venv",
"venv",
"__pycache__",
".cache",
"dist",
"build",
"out",
"bin",
"obj",
".vs",
".idea",
".vscode",
"packages",
".nuget",
];
#[derive(Debug, Clone, PartialEq, Eq)]
#[allow(dead_code)] pub enum FileEvent {
Modified(PathBuf),
Deleted(PathBuf),
Renamed(PathBuf, PathBuf),
}
pub struct FileWatcher {
root: PathBuf,
debouncer: Option<Debouncer<RecommendedWatcher, FileIdMap>>,
receiver: Option<Receiver<DebounceEventResult>>,
}
impl FileWatcher {
pub fn new(root: PathBuf) -> Self {
Self {
root,
debouncer: None,
receiver: None,
}
}
pub fn start(&mut self, debounce_ms: u64) -> Result<()> {
let (tx, rx) = channel();
let debouncer = new_debouncer(
Duration::from_millis(debounce_ms),
None, tx,
)
.map_err(|e| anyhow!("Failed to create file watcher: {}", e))?;
self.receiver = Some(rx);
self.debouncer = Some(debouncer);
if let Some(ref mut debouncer) = self.debouncer {
debouncer
.watcher()
.watch(&self.root, RecursiveMode::Recursive)
.map_err(|e| anyhow!("Failed to watch directory: {}", e))?;
debouncer
.cache()
.add_root(&self.root, RecursiveMode::Recursive);
}
Ok(())
}
pub fn is_started(&self) -> bool {
self.debouncer.is_some()
}
pub fn stop(&mut self) {
if let Some(ref mut debouncer) = self.debouncer {
let _ = debouncer.watcher().unwatch(&self.root);
}
self.debouncer = None;
self.receiver = None;
}
fn is_in_ignored_dir(&self, path: &Path) -> bool {
for component in path.components() {
if let Some(name) = component.as_os_str().to_str() {
if IGNORED_DIRS.contains(&name) {
return true;
}
}
}
false
}
fn is_watchable(&self, path: &Path) -> bool {
if self.is_in_ignored_dir(path) {
return false;
}
if let Some(ext) = path.extension() {
if let Some(ext_str) = ext.to_str() {
return INDEXABLE_EXTENSIONS.contains(&ext_str.to_lowercase().as_str());
}
}
if let Some(name) = path.file_name() {
let name_str = name.to_string_lossy().to_lowercase();
if name_str == "dockerfile" || name_str == "makefile" || name_str == "cmakelists.txt" {
return true;
}
}
false
}
pub fn poll_events(&self) -> Vec<FileEvent> {
let Some(ref receiver) = self.receiver else {
return vec![];
};
let mut events = Vec::new();
let mut seen_paths = HashSet::new();
while let Ok(result) = receiver.try_recv() {
match result {
Ok(debounced_events) => {
for event in debounced_events {
for raw_path in &event.paths {
let path = normalize_event_path(raw_path);
if self.is_in_ignored_dir(&path) || seen_paths.contains(&path) {
continue;
}
seen_paths.insert(path.clone());
use notify::EventKind;
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
if self.is_watchable(&path) && raw_path.exists() {
events.push(FileEvent::Modified(path));
}
}
EventKind::Remove(_) => {
events.push(FileEvent::Deleted(path));
}
_ => {}
}
}
}
}
Err(errors) => {
for error in errors {
tracing::warn!("File watch error: {:?}", error);
}
}
}
}
events
}
pub fn wait_for_events(&self, timeout: Duration) -> Vec<FileEvent> {
let Some(ref receiver) = self.receiver else {
return vec![];
};
let mut events = Vec::new();
let mut seen_paths = HashSet::new();
match receiver.recv_timeout(timeout) {
Ok(result) => {
self.process_debounce_result(result, &mut events, &mut seen_paths);
}
Err(_) => return events, }
while let Ok(result) = receiver.try_recv() {
self.process_debounce_result(result, &mut events, &mut seen_paths);
}
events
}
fn process_debounce_result(
&self,
result: DebounceEventResult,
events: &mut Vec<FileEvent>,
seen_paths: &mut HashSet<PathBuf>,
) {
match result {
Ok(debounced_events) => {
for event in debounced_events {
for raw_path in &event.paths {
let path = normalize_event_path(raw_path);
if self.is_in_ignored_dir(&path) || seen_paths.contains(&path) {
continue;
}
seen_paths.insert(path.clone());
use notify::EventKind;
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) => {
if self.is_watchable(&path) && raw_path.exists() {
events.push(FileEvent::Modified(path));
}
}
EventKind::Remove(_) => {
events.push(FileEvent::Deleted(path));
}
_ => {}
}
}
}
}
Err(errors) => {
for error in errors {
tracing::warn!("File watch error: {:?}", error);
}
}
}
}
}
impl Drop for FileWatcher {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::tempdir;
#[test]
fn test_is_watchable() {
let watcher = FileWatcher::new(PathBuf::from("/tmp"));
assert!(!watcher.is_watchable(Path::new("/tmp/.git/config")));
assert!(!watcher.is_watchable(Path::new("/tmp/node_modules/foo/index.js")));
assert!(!watcher.is_watchable(Path::new("/tmp/target/debug/main")));
assert!(!watcher.is_watchable(Path::new("/tmp/.codesearch.db/data")));
assert!(!watcher.is_watchable(Path::new("/tmp/Cargo.lock")));
assert!(!watcher.is_watchable(Path::new("/tmp/debug.log")));
assert!(!watcher.is_watchable(Path::new("/tmp/image.png")));
assert!(!watcher.is_watchable(Path::new("/tmp/data.bin")));
assert!(watcher.is_watchable(Path::new("/tmp/src/main.rs")));
assert!(watcher.is_watchable(Path::new("/tmp/src/lib.ts")));
assert!(watcher.is_watchable(Path::new("/tmp/Program.cs")));
assert!(watcher.is_watchable(Path::new("/tmp/app.py")));
assert!(watcher.is_watchable(Path::new("/tmp/config.json")));
assert!(watcher.is_watchable(Path::new("/tmp/settings.yaml")));
assert!(watcher.is_watchable(Path::new("/tmp/Cargo.toml")));
assert!(watcher.is_watchable(Path::new("/tmp/appsettings.xml")));
assert!(watcher.is_watchable(Path::new("/tmp/Dockerfile")));
assert!(watcher.is_watchable(Path::new("/tmp/Makefile")));
}
#[test]
#[ignore] fn test_file_watcher() {
let dir = tempdir().unwrap();
let mut watcher = FileWatcher::new(dir.path().to_path_buf());
watcher.start(100).unwrap();
let test_file = dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
std::thread::sleep(Duration::from_millis(200));
let events = watcher.poll_events();
assert!(!events.is_empty());
}
}