use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{channel, Receiver};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use tracing::{debug, error, info, warn};
use xore_core::{Result, XoreError};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FileEvent {
Created(PathBuf),
Modified(PathBuf),
Deleted(PathBuf),
Renamed { from: PathBuf, to: PathBuf },
}
impl FileEvent {
pub fn paths(&self) -> Vec<&PathBuf> {
match self {
FileEvent::Created(p) | FileEvent::Modified(p) | FileEvent::Deleted(p) => vec![p],
FileEvent::Renamed { from, to } => vec![from, to],
}
}
pub fn kind_str(&self) -> &'static str {
match self {
FileEvent::Created(_) => "created",
FileEvent::Modified(_) => "modified",
FileEvent::Deleted(_) => "deleted",
FileEvent::Renamed { .. } => "renamed",
}
}
}
#[derive(Debug, Clone)]
pub struct WatcherConfig {
pub debounce_duration: Duration,
pub batch_size: usize,
pub exclude_patterns: Vec<String>,
pub include_hidden: bool,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
debounce_duration: Duration::from_millis(500),
batch_size: 50,
exclude_patterns: vec![
".git".to_string(),
"node_modules".to_string(),
"target".to_string(),
".xore".to_string(),
"*.tmp".to_string(),
"*.swp".to_string(),
],
include_hidden: false,
}
}
}
struct Debouncer {
pending: Arc<Mutex<HashMap<PathBuf, (FileEvent, Instant)>>>,
duration: Duration,
}
impl Debouncer {
fn new(duration: Duration) -> Self {
Self { pending: Arc::new(Mutex::new(HashMap::new())), duration }
}
fn add(&self, event: FileEvent) {
let mut pending = self.pending.lock().unwrap();
for path in event.paths() {
pending.insert(path.clone(), (event.clone(), Instant::now()));
}
}
fn drain(&self) -> Vec<FileEvent> {
let mut pending = self.pending.lock().unwrap();
let now = Instant::now();
let mut ready = Vec::new();
pending.retain(|_path, (event, timestamp)| {
if now.duration_since(*timestamp) >= self.duration {
ready.push(event.clone());
false } else {
true }
});
ready
}
}
pub struct EventFilter {
excludes: Vec<String>,
include_hidden: bool,
}
impl EventFilter {
pub fn new(config: &WatcherConfig) -> Self {
Self { excludes: config.exclude_patterns.clone(), include_hidden: config.include_hidden }
}
pub fn should_index(&self, path: &Path) -> bool {
if !self.include_hidden {
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
if name.starts_with('.') {
return false;
}
}
}
let path_str = path.to_string_lossy();
for pattern in &self.excludes {
if pattern.contains('*') {
if Self::wildcard_match(&path_str, pattern) {
return false;
}
} else if path_str.contains(pattern) {
return false;
}
}
if path.is_dir() {
return false;
}
true
}
fn wildcard_match(text: &str, pattern: &str) -> bool {
if pattern.contains('*') {
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 2 {
text.starts_with(parts[0]) && text.ends_with(parts[1])
} else {
false
}
} else {
text == pattern
}
}
}
pub struct FileWatcher {
_watcher: RecommendedWatcher,
event_rx: Receiver<std::result::Result<Event, notify::Error>>,
debouncer: Debouncer,
filter: EventFilter,
config: WatcherConfig,
}
impl FileWatcher {
pub fn new(config: WatcherConfig) -> Result<Self> {
let (tx, rx) = channel();
let watcher = RecommendedWatcher::new(
move |res| {
if let Err(e) = tx.send(res) {
error!("Failed to send watch event: {}", e);
}
},
Config::default(),
)
.map_err(|e| XoreError::Other(format!("Failed to create watcher: {}", e)))?;
let debouncer = Debouncer::new(config.debounce_duration);
let filter = EventFilter::new(&config);
Ok(Self { _watcher: watcher, event_rx: rx, debouncer, filter, config })
}
pub fn watch_path(&mut self, path: &Path) -> Result<()> {
self._watcher
.watch(path, RecursiveMode::Recursive)
.map_err(|e| XoreError::Other(format!("Failed to watch path: {}", e)))?;
info!("Started watching: {}", path.display());
Ok(())
}
pub fn unwatch_path(&mut self, path: &Path) -> Result<()> {
self._watcher
.unwatch(path)
.map_err(|e| XoreError::Other(format!("Failed to unwatch path: {}", e)))?;
info!("Stopped watching: {}", path.display());
Ok(())
}
pub fn recv_events(&mut self) -> Result<Vec<FileEvent>> {
while let Ok(res) = self.event_rx.try_recv() {
match res {
Ok(event) => {
if let Some(file_event) = self.process_event(event) {
self.debouncer.add(file_event);
}
}
Err(e) => {
warn!("Watch error: {}", e);
}
}
}
let events = self.debouncer.drain();
let filtered: Vec<FileEvent> = events
.into_iter()
.filter(|e| {
for path in e.paths() {
if !self.filter.should_index(path) {
debug!("Filtered out: {}", path.display());
return false;
}
}
true
})
.collect();
Ok(filtered)
}
fn process_event(&self, event: Event) -> Option<FileEvent> {
match event.kind {
EventKind::Create(_) => {
if let Some(path) = event.paths.first() {
debug!("File created: {}", path.display());
return Some(FileEvent::Created(path.clone()));
}
}
EventKind::Modify(_) => {
if let Some(path) = event.paths.first() {
debug!("File modified: {}", path.display());
return Some(FileEvent::Modified(path.clone()));
}
}
EventKind::Remove(_) => {
if let Some(path) = event.paths.first() {
debug!("File deleted: {}", path.display());
return Some(FileEvent::Deleted(path.clone()));
}
}
EventKind::Access(_) => {
return None;
}
_ => {
debug!("Unhandled event kind: {:?}", event.kind);
}
}
None
}
pub fn config(&self) -> &WatcherConfig {
&self.config
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::thread;
use tempfile::TempDir;
#[test]
fn test_file_event_paths() {
let event = FileEvent::Created(PathBuf::from("/test.txt"));
assert_eq!(event.paths().len(), 1);
let event =
FileEvent::Renamed { from: PathBuf::from("/old.txt"), to: PathBuf::from("/new.txt") };
assert_eq!(event.paths().len(), 2);
}
#[test]
fn test_event_filter_hidden_files() {
let config = WatcherConfig { include_hidden: false, ..Default::default() };
let filter = EventFilter::new(&config);
assert!(!filter.should_index(Path::new(".hidden")));
assert!(filter.should_index(Path::new("visible.txt")));
}
#[test]
fn test_event_filter_exclude_patterns() {
let config = WatcherConfig {
exclude_patterns: vec!["node_modules".to_string(), "*.tmp".to_string()],
..Default::default()
};
let filter = EventFilter::new(&config);
assert!(!filter.should_index(Path::new("node_modules/test.js")));
assert!(!filter.should_index(Path::new("test.tmp")));
assert!(filter.should_index(Path::new("src/main.rs")));
}
#[test]
fn test_event_filter_directories() {
let config = WatcherConfig::default();
let filter = EventFilter::new(&config);
let temp_dir = TempDir::new().unwrap();
assert!(!filter.should_index(temp_dir.path()));
}
#[test]
fn test_wildcard_match() {
assert!(EventFilter::wildcard_match("test.tmp", "*.tmp"));
assert!(EventFilter::wildcard_match("backup.bak", "*.bak"));
assert!(!EventFilter::wildcard_match("test.txt", "*.tmp"));
}
#[test]
fn test_debouncer() {
let debouncer = Debouncer::new(Duration::from_millis(100));
debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
let events = debouncer.drain();
assert_eq!(events.len(), 0);
thread::sleep(Duration::from_millis(150));
let events = debouncer.drain();
assert_eq!(events.len(), 1);
}
#[test]
fn test_debouncer_multiple_events_same_file() {
let debouncer = Debouncer::new(Duration::from_millis(100));
debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
thread::sleep(Duration::from_millis(20));
debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
thread::sleep(Duration::from_millis(20));
debouncer.add(FileEvent::Modified(PathBuf::from("test.txt")));
thread::sleep(Duration::from_millis(150));
let events = debouncer.drain();
assert_eq!(events.len(), 1);
}
#[test]
fn test_watcher_creation() {
let config = WatcherConfig::default();
let watcher = FileWatcher::new(config);
assert!(watcher.is_ok());
}
#[test]
fn test_watcher_watch_path() {
let temp_dir = TempDir::new().unwrap();
let config = WatcherConfig::default();
let mut watcher = FileWatcher::new(config).unwrap();
let result = watcher.watch_path(temp_dir.path());
assert!(result.is_ok());
}
fn wait_for_events(watcher: &mut FileWatcher, max_attempts: usize) -> Vec<FileEvent> {
for _ in 0..max_attempts {
thread::sleep(Duration::from_millis(100));
if let Ok(events) = watcher.recv_events() {
if !events.is_empty() {
return events;
}
}
}
vec![]
}
#[test]
fn test_watcher_file_create() {
let temp_dir = TempDir::new().unwrap();
let config =
WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
let mut watcher = FileWatcher::new(config).unwrap();
watcher.watch_path(temp_dir.path()).unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "hello").unwrap();
let events = wait_for_events(&mut watcher, 10);
if !events.is_empty() {
println!("Received {} events", events.len());
}
}
#[test]
fn test_watcher_file_modify() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "hello").unwrap();
let config =
WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
let mut watcher = FileWatcher::new(config).unwrap();
watcher.watch_path(temp_dir.path()).unwrap();
thread::sleep(Duration::from_millis(100));
fs::write(&test_file, "world").unwrap();
let events = wait_for_events(&mut watcher, 10);
if !events.is_empty() {
println!("Received {} events", events.len());
}
}
#[test]
fn test_watcher_file_delete() {
let temp_dir = TempDir::new().unwrap();
let test_file = temp_dir.path().join("test.txt");
fs::write(&test_file, "hello").unwrap();
let config =
WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
let mut watcher = FileWatcher::new(config).unwrap();
watcher.watch_path(temp_dir.path()).unwrap();
thread::sleep(Duration::from_millis(100));
fs::remove_file(&test_file).unwrap();
let events = wait_for_events(&mut watcher, 10);
if !events.is_empty() {
println!("Received {} events", events.len());
}
}
#[test]
fn test_watcher_multiple_files() {
let temp_dir = TempDir::new().unwrap();
let config =
WatcherConfig { debounce_duration: Duration::from_millis(50), ..Default::default() };
let mut watcher = FileWatcher::new(config).unwrap();
watcher.watch_path(temp_dir.path()).unwrap();
for i in 0..5 {
let file = temp_dir.path().join(format!("test{}.txt", i));
fs::write(file, format!("content {}", i)).unwrap();
thread::sleep(Duration::from_millis(10));
}
let events = wait_for_events(&mut watcher, 10);
if !events.is_empty() {
println!("Received {} events", events.len());
}
}
#[test]
fn test_watcher_exclude_patterns() {
let temp_dir = TempDir::new().unwrap();
let config = WatcherConfig {
debounce_duration: Duration::from_millis(100),
exclude_patterns: vec!["*.tmp".to_string()],
..Default::default()
};
let mut watcher = FileWatcher::new(config).unwrap();
watcher.watch_path(temp_dir.path()).unwrap();
let tmp_file = temp_dir.path().join("test.tmp");
fs::write(&tmp_file, "temp").unwrap();
let txt_file = temp_dir.path().join("test.txt");
fs::write(&txt_file, "real").unwrap();
thread::sleep(Duration::from_millis(200));
let events = watcher.recv_events().unwrap();
for event in &events {
for path in event.paths() {
assert!(!path.to_string_lossy().ends_with(".tmp"));
}
}
}
}