use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver, Sender};
use std::time::Duration;
use crate::Language;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchEventKind {
Created,
Modified,
Removed,
Renamed,
}
#[derive(Debug, Clone)]
pub struct WatchEvent {
pub path: PathBuf,
pub kind: WatchEventKind,
pub language: Option<Language>,
}
impl WatchEvent {
pub fn new(path: PathBuf, kind: WatchEventKind) -> Self {
let language = Language::from_path(&path);
Self {
path,
kind,
language,
}
}
pub fn is_lintable(&self) -> bool {
self.language.is_some()
}
}
#[derive(Debug, thiserror::Error)]
pub enum WatchError {
#[error("Failed to create watcher: {0}")]
WatcherCreation(#[from] notify::Error),
#[error("Failed to watch path {path}: {message}")]
WatchPath { path: PathBuf, message: String },
#[error("Watch channel closed")]
ChannelClosed,
}
pub struct FileWatcher {
_watcher: RecommendedWatcher,
rx: Receiver<WatchEvent>,
watched_paths: Vec<PathBuf>,
}
impl FileWatcher {
pub fn new(paths: Vec<PathBuf>) -> Result<Self, WatchError> {
let (tx, rx) = mpsc::channel();
let event_tx = tx.clone();
let mut watcher = RecommendedWatcher::new(
move |result: Result<Event, notify::Error>| {
if let Ok(event) = result {
Self::handle_event(event, &event_tx);
}
},
Config::default().with_poll_interval(Duration::from_millis(100)),
)?;
let mut watched_paths = Vec::new();
for path in &paths {
let canonical = path.canonicalize().unwrap_or_else(|_| path.clone());
watcher
.watch(&canonical, RecursiveMode::Recursive)
.map_err(|e| WatchError::WatchPath {
path: canonical.clone(),
message: e.to_string(),
})?;
watched_paths.push(canonical);
}
Ok(Self {
_watcher: watcher,
rx,
watched_paths,
})
}
fn handle_event(event: Event, tx: &Sender<WatchEvent>) {
let kind = match event.kind {
EventKind::Create(_) => Some(WatchEventKind::Created),
EventKind::Modify(_) => Some(WatchEventKind::Modified),
EventKind::Remove(_) => Some(WatchEventKind::Removed),
EventKind::Any => None,
EventKind::Access(_) => None,
EventKind::Other => None,
};
if let Some(kind) = kind {
for path in event.paths {
if path.is_dir() {
continue;
}
if Self::should_skip_path(&path) {
continue;
}
let watch_event = WatchEvent::new(path, kind.clone());
if watch_event.is_lintable() {
let _ = tx.send(watch_event);
}
}
}
}
fn should_skip_path(path: &Path) -> bool {
let path_str = path.to_string_lossy();
if let Some(name) = path.file_name() {
if name.to_string_lossy().starts_with('.') {
return true;
}
}
let skip_patterns = [
"/target/",
"/node_modules/",
"/.git/",
"/build/",
"/dist/",
"/__pycache__/",
"/.venv/",
"/venv/",
"/.idea/",
"/.vscode/",
"/vendor/",
"/.gradle/",
"/Pods/",
];
for pattern in skip_patterns {
if path_str.contains(pattern) {
return true;
}
}
let skip_suffixes = [
".lock",
".log",
".min.js",
".min.css",
".generated.",
".g.dart",
".freezed.dart",
];
for suffix in skip_suffixes {
if path_str.ends_with(suffix) || path_str.contains(suffix) {
return true;
}
}
false
}
pub fn recv(&self) -> Result<WatchEvent, WatchError> {
self.rx.recv().map_err(|_| WatchError::ChannelClosed)
}
pub fn try_recv(&self) -> Option<WatchEvent> {
self.rx.try_recv().ok()
}
pub fn drain_events(&self) -> Vec<WatchEvent> {
let mut events = Vec::new();
while let Ok(event) = self.rx.try_recv() {
events.push(event);
}
events
}
pub fn watched_paths(&self) -> &[PathBuf] {
&self.watched_paths
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_watch_event_lintable() {
let event = WatchEvent::new(PathBuf::from("test.rs"), WatchEventKind::Modified);
assert!(event.is_lintable());
assert_eq!(event.language, Some(Language::Rust));
let event = WatchEvent::new(PathBuf::from("test.txt"), WatchEventKind::Modified);
assert!(!event.is_lintable());
assert_eq!(event.language, None);
}
#[test]
fn test_should_skip_path() {
assert!(FileWatcher::should_skip_path(Path::new(
"/project/target/debug/main.rs"
)));
assert!(FileWatcher::should_skip_path(Path::new(
"/project/.git/config"
)));
assert!(FileWatcher::should_skip_path(Path::new(
"/project/node_modules/package/index.js"
)));
assert!(!FileWatcher::should_skip_path(Path::new(
"/project/src/main.rs"
)));
}
#[test]
fn test_file_watcher_creation() {
let temp_dir = TempDir::new().unwrap();
let watcher = FileWatcher::new(vec![temp_dir.path().to_path_buf()]);
assert!(watcher.is_ok());
}
#[test]
fn test_file_watcher_detects_changes() {
let temp_dir = TempDir::new().unwrap();
let watcher = FileWatcher::new(vec![temp_dir.path().to_path_buf()]).unwrap();
let test_file = temp_dir.path().join("test.rs");
fs::write(&test_file, "fn main() {}").unwrap();
let test_file_canonical = test_file.canonicalize().unwrap_or(test_file.clone());
std::thread::sleep(Duration::from_millis(200));
let events = watcher.drain_events();
assert!(
events.iter().any(|e| {
let event_path = e.path.canonicalize().unwrap_or(e.path.clone());
event_path == test_file_canonical
}),
"Expected to find event for {:?}, got {:?}",
test_file_canonical,
events
);
}
}