use anyhow::{Context, Result};
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, RecvTimeoutError};
use std::time::{Duration, Instant};
use crate::indexer::Indexer;
use crate::models::Language;
use crate::output;
#[derive(Debug, Clone)]
pub struct WatchConfig {
pub debounce_ms: u64,
pub quiet: bool,
}
impl Default for WatchConfig {
fn default() -> Self {
Self {
debounce_ms: 15000, quiet: false,
}
}
}
pub fn watch(path: &Path, indexer: Indexer, config: WatchConfig) -> Result<()> {
log::info!(
"Starting file watcher for {:?} with {}ms debounce",
path,
config.debounce_ms
);
let (tx, rx) = channel();
let mut watcher = RecommendedWatcher::new(tx, Config::default())
.context("Failed to create file watcher")?;
watcher
.watch(path, RecursiveMode::Recursive)
.context("Failed to start watching directory")?;
if !config.quiet {
println!("Watching for changes (debounce: {}s)...", config.debounce_ms / 1000);
}
let mut pending_files: HashSet<PathBuf> = HashSet::new();
let mut last_event_time: Option<Instant> = None;
let debounce_duration = Duration::from_millis(config.debounce_ms);
loop {
match rx.recv_timeout(Duration::from_millis(100)) {
Ok(Ok(event)) => {
if let Some(changed_path) = process_event(&event) {
if should_watch_file(&changed_path) {
log::debug!("Detected change: {:?}", changed_path);
pending_files.insert(changed_path);
last_event_time = Some(Instant::now());
}
}
}
Ok(Err(e)) => {
log::warn!("Watch error: {}", e);
}
Err(RecvTimeoutError::Timeout) => {
if let Some(last_time) = last_event_time {
if !pending_files.is_empty() && last_time.elapsed() >= debounce_duration {
if !config.quiet {
println!(
"\nDetected {} changed file(s), reindexing...",
pending_files.len()
);
}
let start = Instant::now();
match indexer.index(path, false) {
Ok(stats) => {
let elapsed = start.elapsed();
if !config.quiet {
println!(
"✓ Reindexed {} files in {:.1}ms\n",
stats.total_files,
elapsed.as_secs_f64() * 1000.0
);
}
log::info!(
"Reindexed {} files in {:?}",
stats.total_files,
elapsed
);
}
Err(e) => {
output::error(&format!("✗ Reindex failed: {}", e));
log::error!("Reindex failed: {}", e);
}
}
pending_files.clear();
last_event_time = None;
}
}
}
Err(RecvTimeoutError::Disconnected) => {
log::info!("Watcher channel disconnected, stopping...");
break;
}
}
}
if !config.quiet {
println!("Watcher stopped.");
}
Ok(())
}
fn process_event(event: &Event) -> Option<PathBuf> {
match event.kind {
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_) => {
event.paths.first().cloned()
}
_ => None,
}
}
fn should_watch_file(path: &Path) -> bool {
if let Some(file_name) = path.file_name() {
if file_name.to_string_lossy().starts_with('.') {
return false;
}
}
if path.is_dir() {
return false;
}
if let Some(ext) = path.extension() {
let ext_str = ext.to_string_lossy();
let lang = Language::from_extension(&ext_str);
return lang.is_supported();
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_should_watch_rust_file() {
let temp = TempDir::new().unwrap();
let rust_file = temp.path().join("test.rs");
fs::write(&rust_file, "fn main() {}").unwrap();
assert!(should_watch_file(&rust_file));
}
#[test]
fn test_should_not_watch_unsupported_file() {
let temp = TempDir::new().unwrap();
let txt_file = temp.path().join("test.txt");
fs::write(&txt_file, "plain text").unwrap();
assert!(!should_watch_file(&txt_file));
}
#[test]
fn test_should_not_watch_hidden_file() {
let temp = TempDir::new().unwrap();
let hidden_file = temp.path().join(".hidden.rs");
fs::write(&hidden_file, "fn main() {}").unwrap();
assert!(!should_watch_file(&hidden_file));
}
#[test]
fn test_should_not_watch_directory() {
let temp = TempDir::new().unwrap();
let dir = temp.path().join("src");
fs::create_dir(&dir).unwrap();
assert!(!should_watch_file(&dir));
}
#[test]
fn test_watch_config_default() {
let config = WatchConfig::default();
assert_eq!(config.debounce_ms, 15000);
assert!(!config.quiet);
}
#[test]
fn test_process_event_create() {
let event = Event {
kind: EventKind::Create(notify::event::CreateKind::File),
paths: vec![PathBuf::from("/test/file.rs")],
attrs: Default::default(),
};
let path = process_event(&event);
assert!(path.is_some());
assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
}
#[test]
fn test_process_event_modify() {
let event = Event {
kind: EventKind::Modify(notify::event::ModifyKind::Data(
notify::event::DataChange::Any,
)),
paths: vec![PathBuf::from("/test/file.rs")],
attrs: Default::default(),
};
let path = process_event(&event);
assert!(path.is_some());
assert_eq!(path.unwrap(), PathBuf::from("/test/file.rs"));
}
#[test]
fn test_process_event_access_ignored() {
let event = Event {
kind: EventKind::Access(notify::event::AccessKind::Read),
paths: vec![PathBuf::from("/test/file.rs")],
attrs: Default::default(),
};
let path = process_event(&event);
assert!(path.is_none());
}
}