1use notify::RecursiveMode;
6use notify_debouncer_mini::{new_debouncer, DebouncedEventKind};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::sync::mpsc;
10use std::time::Duration;
11use tokio::sync::mpsc as tokio_mpsc;
12
13#[derive(Debug, Clone)]
15pub enum WatchEvent {
16 FilesChanged(Vec<PathBuf>),
18 Error(String),
20}
21
22#[derive(Debug, Clone)]
24pub struct WatcherConfig {
25 pub debounce: Duration,
27 pub extensions: Vec<String>,
29 pub ignore_dirs: Vec<String>,
31}
32
33impl Default for WatcherConfig {
34 fn default() -> Self {
35 Self {
36 debounce: Duration::from_millis(500),
37 extensions: vec!["rs".to_string()],
38 ignore_dirs: vec![
39 "target".to_string(),
40 ".git".to_string(),
41 "node_modules".to_string(),
42 ],
43 }
44 }
45}
46
47pub struct FileWatcher {
49 event_rx: tokio_mpsc::Receiver<WatchEvent>,
51 _handle: std::thread::JoinHandle<()>,
53}
54
55impl FileWatcher {
56 pub fn new(watch_path: &Path, config: WatcherConfig) -> anyhow::Result<Self> {
58 let (tx, rx) = tokio_mpsc::channel(100);
59 let watch_path = watch_path.to_path_buf();
60 let config_clone = config.clone();
61
62 let handle = std::thread::spawn(move || {
64 if let Err(e) = run_watcher(watch_path, config_clone, tx) {
65 tracing::error!("Watcher thread error: {}", e);
66 }
67 });
68
69 Ok(Self {
70 event_rx: rx,
71 _handle: handle,
72 })
73 }
74
75 pub async fn recv(&mut self) -> Option<WatchEvent> {
77 self.event_rx.recv().await
78 }
79}
80
81fn run_watcher(
83 watch_path: PathBuf,
84 config: WatcherConfig,
85 tx: tokio_mpsc::Sender<WatchEvent>,
86) -> anyhow::Result<()> {
87 let (notify_tx, notify_rx) = mpsc::channel();
88
89 let mut debouncer = new_debouncer(config.debounce, notify_tx)?;
91
92 debouncer
94 .watcher()
95 .watch(&watch_path, RecursiveMode::Recursive)?;
96
97 tracing::info!("File watcher started for {:?}", watch_path);
98
99 loop {
101 match notify_rx.recv() {
102 Ok(Ok(events)) => {
103 let mut changed: HashSet<PathBuf> = HashSet::new();
105
106 for event in events {
107 if event.kind != DebouncedEventKind::Any {
108 continue;
109 }
110
111 let path = &event.path;
112
113 if should_ignore(path, &config.ignore_dirs) {
115 continue;
116 }
117
118 if !config.extensions.is_empty() {
120 let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
121 if !config.extensions.iter().any(|e| e == ext) {
122 continue;
123 }
124 }
125
126 if path.exists() && path.is_file() {
128 changed.insert(path.clone());
129 }
130 }
131
132 if !changed.is_empty() {
133 let paths: Vec<_> = changed.into_iter().collect();
134 tracing::debug!("Files changed: {:?}", paths);
135
136 if tx.blocking_send(WatchEvent::FilesChanged(paths)).is_err() {
137 break;
139 }
140 }
141 }
142 Ok(Err(error)) => {
143 tracing::warn!("Watch error: {:?}", error);
144 let _ = tx.blocking_send(WatchEvent::Error(format!("{:?}", error)));
145 }
146 Err(_) => {
147 break;
149 }
150 }
151 }
152
153 tracing::info!("File watcher stopped");
154 Ok(())
155}
156
157fn should_ignore(path: &Path, ignore_dirs: &[String]) -> bool {
159 for component in path.components() {
160 if let std::path::Component::Normal(name) = component {
161 let name_str = name.to_string_lossy();
162 if ignore_dirs.iter().any(|d| d == name_str.as_ref()) {
163 return true;
164 }
165 }
166 }
167 false
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173
174 #[test]
175 fn test_should_ignore() {
176 let ignore = vec!["target".to_string(), ".git".to_string()];
177
178 assert!(should_ignore(Path::new("target/debug/foo.rs"), &ignore));
179 assert!(should_ignore(Path::new(".git/config"), &ignore));
180 assert!(should_ignore(Path::new("src/target/mod.rs"), &ignore));
181 assert!(!should_ignore(Path::new("src/main.rs"), &ignore));
182 assert!(!should_ignore(Path::new("crates/foo/src/lib.rs"), &ignore));
183 }
184
185 #[test]
186 fn test_config_default() {
187 let config = WatcherConfig::default();
188 assert_eq!(config.debounce, Duration::from_millis(500));
189 assert!(config.extensions.contains(&"rs".to_string()));
190 assert!(config.ignore_dirs.contains(&"target".to_string()));
191 }
192}