use anyhow::{Context, Result};
use notify::{Config, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::mpsc::{self, Receiver};
use std::time::{Duration, Instant};
use tokio::sync::mpsc as tokio_mpsc;
use tracing::{debug, error, info};
use crate::git::GitignoreFilter;
const MIN_DEBOUNCE_DURATION_MS: u64 = 50; const MAX_DEBOUNCE_DURATION_MS: u64 = 10000; const MIN_BATCH_SIZE: usize = 1;
const MAX_BATCH_SIZE: usize = 10000;
#[derive(Debug, Clone)]
pub struct WatcherConfig {
pub debounce_duration: Duration,
pub max_batch_size: usize,
}
impl Default for WatcherConfig {
fn default() -> Self {
Self {
debounce_duration: Duration::from_millis(500),
max_batch_size: 100,
}
}
}
impl WatcherConfig {
pub fn new(debounce_duration: Duration, max_batch_size: usize) -> Result<Self> {
let config = Self {
debounce_duration,
max_batch_size,
};
config.validate()?;
Ok(config)
}
pub fn validate(&self) -> Result<()> {
let debounce_ms = self.debounce_duration.as_millis() as u64;
if debounce_ms < MIN_DEBOUNCE_DURATION_MS {
return Err(anyhow::anyhow!(
"Debounce duration {}ms is below minimum {}ms",
debounce_ms,
MIN_DEBOUNCE_DURATION_MS
));
}
if debounce_ms > MAX_DEBOUNCE_DURATION_MS {
return Err(anyhow::anyhow!(
"Debounce duration {}ms exceeds maximum {}ms",
debounce_ms,
MAX_DEBOUNCE_DURATION_MS
));
}
if self.max_batch_size < MIN_BATCH_SIZE {
return Err(anyhow::anyhow!(
"Max batch size {} is below minimum {}",
self.max_batch_size,
MIN_BATCH_SIZE
));
}
if self.max_batch_size > MAX_BATCH_SIZE {
return Err(anyhow::anyhow!(
"Max batch size {} exceeds maximum {}",
self.max_batch_size,
MAX_BATCH_SIZE
));
}
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum WatchEvent {
Modified(PathBuf),
Created(PathBuf),
Deleted(PathBuf),
Renamed { from: PathBuf, to: PathBuf },
DirectoryCreated(PathBuf),
DirectoryDeleted(PathBuf),
}
impl WatchEvent {
pub fn path(&self) -> &Path {
match self {
WatchEvent::Modified(path) => path,
WatchEvent::Created(path) => path,
WatchEvent::Deleted(path) => path,
WatchEvent::Renamed { to, .. } => to,
WatchEvent::DirectoryCreated(path) => path,
WatchEvent::DirectoryDeleted(path) => path,
}
}
pub fn is_file_event(&self) -> bool {
matches!(
self,
WatchEvent::Modified(_)
| WatchEvent::Created(_)
| WatchEvent::Deleted(_)
| WatchEvent::Renamed { .. }
)
}
}
#[derive(Debug)]
pub struct WatchEventBatch {
pub events: Vec<WatchEvent>,
pub timestamp: Instant,
}
impl WatchEventBatch {
pub fn new(events: Vec<WatchEvent>) -> Self {
Self {
events,
timestamp: Instant::now(),
}
}
pub fn unique_paths(&self) -> Vec<PathBuf> {
let mut path_to_index = std::collections::HashMap::new();
for (index, event) in self.events.iter().enumerate() {
let path = event.path().to_path_buf();
path_to_index.insert(path, index);
}
let mut indexed_paths: Vec<(usize, PathBuf)> = path_to_index
.into_iter()
.map(|(path, index)| (index, path))
.collect();
indexed_paths.sort_by_key(|(index, _)| *index);
indexed_paths.into_iter().map(|(_, path)| path).collect()
}
pub fn group_by_type(&self) -> (Vec<PathBuf>, Vec<PathBuf>, Vec<PathBuf>) {
let mut modified = Vec::new();
let mut created = Vec::new();
let mut deleted = Vec::new();
for event in &self.events {
match event {
WatchEvent::Modified(path) => modified.push(path.clone()),
WatchEvent::Created(path) | WatchEvent::DirectoryCreated(path) => {
created.push(path.clone())
}
WatchEvent::Deleted(path) | WatchEvent::DirectoryDeleted(path) => {
deleted.push(path.clone())
}
WatchEvent::Renamed { from, to } => {
deleted.push(from.clone());
created.push(to.clone());
}
}
}
(modified, created, deleted)
}
}
pub struct FileWatcher {
_watcher: RecommendedWatcher,
event_receiver: tokio_mpsc::Receiver<WatchEventBatch>,
gitignore_filter: GitignoreFilter,
root_path: PathBuf,
}
impl FileWatcher {
pub fn new(path: &Path, gitignore_filter: GitignoreFilter) -> Result<Self> {
Self::with_config(path, gitignore_filter, WatcherConfig::default())
}
pub fn with_config(
path: &Path,
gitignore_filter: GitignoreFilter,
config: WatcherConfig,
) -> Result<Self> {
let (event_sender, event_receiver) = tokio_mpsc::channel(1000);
let (tx, rx) = mpsc::channel();
let debouncer = EventDebouncer::new(event_sender, config);
let _debouncer_handle = debouncer.spawn(rx);
let mut watcher = RecommendedWatcher::new(
tx,
Config::default().with_poll_interval(Duration::from_millis(100)),
)
.context("Failed to create file system watcher")?;
watcher
.watch(path, RecursiveMode::Recursive)
.with_context(|| format!("Failed to watch path: {}", path.display()))?;
info!("File watcher initialized for path: {}", path.display());
Ok(Self {
_watcher: watcher,
event_receiver,
gitignore_filter,
root_path: path.to_path_buf(),
})
}
pub async fn next_batch(&mut self) -> Option<WatchEventBatch> {
loop {
match self.event_receiver.recv().await {
Some(batch) => {
let filtered_events = batch
.events
.into_iter()
.filter(|event| self.should_include_path(event.path()))
.collect::<Vec<_>>();
if filtered_events.is_empty() {
debug!("All events in batch were filtered out by gitignore");
continue;
} else {
debug!("Received batch with {} events", filtered_events.len());
return Some(WatchEventBatch::new(filtered_events));
}
}
None => {
info!("File watcher event channel closed");
return None;
}
}
}
}
fn should_include_path(&self, path: &Path) -> bool {
let relative_path = match path.strip_prefix(&self.root_path) {
Ok(rel) => rel,
Err(_) => {
debug!("Path outside watch root: {}", path.display());
return false;
}
};
let should_include = self.gitignore_filter.should_include(relative_path);
if !should_include {
debug!("Path filtered by gitignore: {}", relative_path.display());
}
should_include
}
pub fn root_path(&self) -> &Path {
&self.root_path
}
}
struct EventDebouncer {
event_sender: tokio_mpsc::Sender<WatchEventBatch>,
config: WatcherConfig,
}
impl EventDebouncer {
fn new(event_sender: tokio_mpsc::Sender<WatchEventBatch>, config: WatcherConfig) -> Self {
Self {
event_sender,
config,
}
}
fn spawn(self, event_receiver: Receiver<notify::Result<Event>>) -> std::thread::JoinHandle<()> {
std::thread::spawn(move || {
let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime");
rt.block_on(async {
self.run(event_receiver).await;
});
})
}
async fn run(self, event_receiver: Receiver<notify::Result<Event>>) {
let mut pending_events: HashMap<PathBuf, (WatchEvent, Instant)> = HashMap::new();
let mut last_batch_time = Instant::now();
let mut interval = tokio::time::interval(Duration::from_millis(50));
loop {
tokio::select! {
_ = interval.tick() => {
while let Ok(event_result) = event_receiver.try_recv() {
match event_result {
Ok(event) => {
if let Some(watch_event) = self.convert_notify_event(event) {
let path = watch_event.path().to_path_buf();
pending_events.insert(path, (watch_event, Instant::now()));
}
}
Err(e) => {
error!("File watcher error: {}", e);
}
}
}
let now = Instant::now();
let should_send_batch = !pending_events.is_empty()
&& (now.duration_since(last_batch_time) >= self.config.debounce_duration
|| pending_events.len() >= self.config.max_batch_size);
if should_send_batch {
let mut ready_events = Vec::new();
let mut keys_to_remove = Vec::new();
for (path, (event, timestamp)) in &pending_events {
if now.duration_since(*timestamp) >= self.config.debounce_duration {
ready_events.push(event.clone());
keys_to_remove.push(path.clone());
}
}
for key in keys_to_remove {
pending_events.remove(&key);
}
if !ready_events.is_empty() {
let batch = WatchEventBatch::new(ready_events);
debug!("Sending batch with {} events", batch.events.len());
if let Err(e) = self.event_sender.send(batch).await {
error!("Failed to send event batch: {}", e);
break;
}
last_batch_time = now;
}
}
}
}
}
}
fn convert_notify_event(&self, event: Event) -> Option<WatchEvent> {
if event.paths.is_empty() {
return None;
}
let path = event.paths[0].clone();
match event.kind {
EventKind::Create(_) => {
if path.is_dir() {
Some(WatchEvent::DirectoryCreated(path))
} else {
Some(WatchEvent::Created(path))
}
}
EventKind::Modify(_) => Some(WatchEvent::Modified(path)),
EventKind::Remove(_) => {
Some(WatchEvent::Deleted(path))
}
_ => {
debug!("Ignoring event kind: {:?}", event.kind);
None
}
}
}
}
pub struct SignalHandler {
shutdown_receiver: tokio::sync::oneshot::Receiver<()>,
}
impl SignalHandler {
pub fn new() -> Result<Self> {
let (shutdown_sender, shutdown_receiver) = tokio::sync::oneshot::channel();
let shutdown_sender = std::sync::Arc::new(std::sync::Mutex::new(Some(shutdown_sender)));
let shutdown_sender_clone = shutdown_sender.clone();
signal_hook::flag::register(
signal_hook::consts::SIGINT,
std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
)
.context("Failed to register SIGINT handler")?;
tokio::spawn(async move {
tokio::signal::ctrl_c()
.await
.expect("Failed to listen for ctrl-c");
if let Some(sender) = shutdown_sender_clone.lock().unwrap().take() {
let _ = sender.send(());
}
});
Ok(Self { shutdown_receiver })
}
pub async fn wait_for_shutdown(self) {
let _ = self.shutdown_receiver.await;
info!("Shutdown signal received");
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn test_watch_event_path() {
let path = PathBuf::from("/test/path");
let event = WatchEvent::Modified(path.clone());
assert_eq!(event.path(), path);
}
#[test]
fn test_watch_event_is_file_event() {
assert!(WatchEvent::Modified(PathBuf::new()).is_file_event());
assert!(WatchEvent::Created(PathBuf::new()).is_file_event());
assert!(WatchEvent::Deleted(PathBuf::new()).is_file_event());
assert!(!WatchEvent::DirectoryCreated(PathBuf::new()).is_file_event());
assert!(!WatchEvent::DirectoryDeleted(PathBuf::new()).is_file_event());
}
#[test]
fn test_event_batch_unique_paths() {
let events = vec![
WatchEvent::Modified(PathBuf::from("/path1")),
WatchEvent::Created(PathBuf::from("/path2")),
WatchEvent::Modified(PathBuf::from("/path1")), ];
let batch = WatchEventBatch::new(events);
let unique_paths = batch.unique_paths();
assert_eq!(unique_paths.len(), 2);
assert!(unique_paths.contains(&PathBuf::from("/path1")));
assert!(unique_paths.contains(&PathBuf::from("/path2")));
}
#[test]
fn test_event_batch_group_by_type() {
let events = vec![
WatchEvent::Modified(PathBuf::from("/modified")),
WatchEvent::Created(PathBuf::from("/created")),
WatchEvent::Deleted(PathBuf::from("/deleted")),
WatchEvent::DirectoryCreated(PathBuf::from("/dir_created")),
];
let batch = WatchEventBatch::new(events);
let (modified, created, deleted) = batch.group_by_type();
assert_eq!(modified, vec![PathBuf::from("/modified")]);
assert_eq!(
created,
vec![PathBuf::from("/created"), PathBuf::from("/dir_created")]
);
assert_eq!(deleted, vec![PathBuf::from("/deleted")]);
}
#[tokio::test]
async fn test_file_watcher_creation() {
let temp_dir = TempDir::new().unwrap();
let gitignore_filter = GitignoreFilter::new(temp_dir.path()).unwrap();
let result = FileWatcher::new(temp_dir.path(), gitignore_filter);
assert!(result.is_ok());
let watcher = result.unwrap();
assert_eq!(watcher.root_path(), temp_dir.path());
}
}