use crossbeam_channel::{Receiver, Sender};
use notify::{RecursiveMode, Watcher};
use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer};
use std::path::{Path, PathBuf};
use std::time::Duration;
#[derive(Debug, Clone)]
pub struct FileWatcherConfig {
pub watch_paths: Vec<PathBuf>,
pub debounce_ms: u64,
pub extension_filter: String,
pub recursive: bool,
}
impl Default for FileWatcherConfig {
fn default() -> Self {
Self {
watch_paths: vec![PathBuf::from("src/ui")],
debounce_ms: 100,
extension_filter: ".dampen".to_string(),
recursive: true,
}
}
}
#[derive(Debug)]
pub enum FileWatcherState {
Idle,
Watching {
paths: Vec<PathBuf>,
},
Failed {
error: String,
},
}
pub struct FileWatcher {
config: FileWatcherConfig,
debouncer: Debouncer<notify::RecommendedWatcher, FileIdMap>,
receiver: Receiver<PathBuf>,
}
impl FileWatcher {
pub fn new(config: FileWatcherConfig) -> Result<Self, FileWatcherError> {
let (tx, rx) = crossbeam_channel::unbounded();
let extension_filter = config.extension_filter.clone();
let debouncer = new_debouncer(
Duration::from_millis(config.debounce_ms),
None, move |result: DebounceEventResult| {
handle_debounced_events(result, &tx, &extension_filter);
},
)
.map_err(|e| FileWatcherError::InitializationFailed(e.to_string()))?;
Ok(Self {
config,
debouncer,
receiver: rx,
})
}
pub fn watch(&mut self, path: PathBuf) -> Result<(), FileWatcherError> {
if !path.exists() {
return Err(FileWatcherError::PathNotFound(path));
}
let recursive_mode = if self.config.recursive {
RecursiveMode::Recursive
} else {
RecursiveMode::NonRecursive
};
self.debouncer
.watcher()
.watch(&path, recursive_mode)
.map_err(|e| {
let error_string = e.to_string().to_lowercase();
if error_string.contains("permission denied")
|| error_string.contains("access is denied")
{
return FileWatcherError::PermissionDenied(path.clone());
}
FileWatcherError::WatchError {
path: path.clone(),
error: e.to_string(),
}
})?;
Ok(())
}
pub fn unwatch(&mut self, path: PathBuf) -> Result<(), FileWatcherError> {
self.debouncer
.watcher()
.unwatch(&path)
.map_err(|e| FileWatcherError::WatchError {
path: path.clone(),
error: e.to_string(),
})?;
Ok(())
}
pub fn receiver(&self) -> &Receiver<PathBuf> {
&self.receiver
}
pub fn config(&self) -> &FileWatcherConfig {
&self.config
}
}
fn handle_debounced_events(
result: DebounceEventResult,
sender: &Sender<PathBuf>,
extension_filter: &str,
) {
match result {
Ok(events) => {
for event in events {
for path in &event.paths {
if !path_matches_extension(path, extension_filter) {
continue;
}
if !path.exists() {
#[cfg(debug_assertions)]
eprintln!("File watcher: ignoring deleted file {:?}", path);
continue;
}
let _ = sender.send(path.clone());
}
}
}
Err(errors) => {
for error in errors {
eprintln!("File watcher error: {:?}", error);
}
}
}
}
fn path_matches_extension(path: &Path, extension: &str) -> bool {
path.extension()
.and_then(|ext| ext.to_str())
.map(|ext| format!(".{}", ext) == extension)
.unwrap_or(false)
}
#[derive(Debug, thiserror::Error)]
pub enum FileWatcherError {
#[error("Failed to initialize file watcher: {0}")]
InitializationFailed(String),
#[error("Path not found: {0}")]
PathNotFound(PathBuf),
#[error("Failed to watch path {path}: {error}")]
WatchError {
path: PathBuf,
error: String,
},
#[error("Permission denied for path: {0}")]
PermissionDenied(PathBuf),
}