1use crate::Result;
2use crate::pitchfork_toml::WatchMode;
3use glob::glob;
4use itertools::Itertools;
5use miette::IntoDiagnostic;
6use notify::{Config, EventKind, PollWatcher, RecommendedWatcher, RecursiveMode};
7use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer_opt};
8use std::collections::HashSet;
9use std::path::{Path, PathBuf};
10use std::time::Duration;
11
12pub struct WatchFiles {
13 pub rx: tokio::sync::mpsc::Receiver<Vec<PathBuf>>,
14 backend: WatchFilesBackend,
15}
16
17enum WatchFilesBackend {
18 Native(Debouncer<RecommendedWatcher, FileIdMap>),
19 Poll(Debouncer<PollWatcher, FileIdMap>),
20}
21
22impl WatchFiles {
23 pub fn new(duration: Duration, mode: WatchMode, poll_interval: Duration) -> Result<Self> {
24 let h = tokio::runtime::Handle::current();
25 let (tx, rx) = tokio::sync::mpsc::channel(256);
26 let make_callback = |tx: tokio::sync::mpsc::Sender<Vec<PathBuf>>,
27 h: tokio::runtime::Handle| {
28 move |res: DebounceEventResult| {
29 let tx = tx.clone();
30 h.spawn(async move {
31 if let Ok(ev) = res {
32 let paths = ev
33 .into_iter()
34 .filter(|e| {
35 matches!(
36 e.kind,
37 EventKind::Modify(_)
38 | EventKind::Create(_)
39 | EventKind::Remove(_)
40 )
41 })
42 .flat_map(|e| e.paths.clone())
43 .unique()
44 .collect_vec();
45 if !paths.is_empty() {
46 let _ = tx.send(paths).await;
48 }
49 }
50 });
51 }
52 };
53
54 let backend = match mode {
55 WatchMode::Native => WatchFilesBackend::Native(
56 new_debouncer_opt(
57 duration,
58 None,
59 make_callback(tx.clone(), h.clone()),
60 FileIdMap::new(),
61 Config::default(),
62 )
63 .into_diagnostic()?,
64 ),
65 WatchMode::Poll => WatchFilesBackend::Poll(
66 new_debouncer_opt(
67 duration,
68 None,
69 make_callback(tx.clone(), h.clone()),
70 FileIdMap::new(),
71 Config::default().with_poll_interval(poll_interval),
72 )
73 .into_diagnostic()?,
74 ),
75 WatchMode::Auto => {
76 return Err(miette::miette!(
77 "WatchMode::Auto must not be passed directly to WatchFiles::new; \
78 the caller must resolve auto to native or poll"
79 ));
80 }
81 };
82
83 Ok(Self { backend, rx })
84 }
85
86 pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
87 match &mut self.backend {
88 WatchFilesBackend::Native(debouncer) => {
89 debouncer.watch(path, recursive_mode).into_diagnostic()
90 }
91 WatchFilesBackend::Poll(debouncer) => {
92 debouncer.watch(path, recursive_mode).into_diagnostic()
93 }
94 }
95 }
96
97 pub fn unwatch(&mut self, path: &Path) -> Result<()> {
98 match &mut self.backend {
99 WatchFilesBackend::Native(debouncer) => debouncer.unwatch(path).into_diagnostic(),
100 WatchFilesBackend::Poll(debouncer) => debouncer.unwatch(path).into_diagnostic(),
101 }
102 }
103}
104
105fn normalize_watch_path(path: &Path) -> PathBuf {
116 match path.canonicalize() {
117 Ok(p) => {
118 #[cfg(windows)]
119 {
120 strip_verbatim_prefix(&p)
121 }
122 #[cfg(not(windows))]
123 {
124 p
125 }
126 }
127 Err(_) => {
128 if path.is_absolute() {
129 path.to_path_buf()
130 } else {
131 crate::env::CWD.join(path)
132 }
133 }
134 }
135}
136
137#[cfg(windows)]
140fn strip_verbatim_prefix(path: &Path) -> PathBuf {
141 let s = path.to_string_lossy();
142 if let Some(rest) = s.strip_prefix(r"\\?\") {
143 if let Some(unc) = rest.strip_prefix(r"UNC\") {
144 PathBuf::from(format!(r"\\{}", unc))
145 } else {
146 PathBuf::from(rest)
147 }
148 } else {
149 path.to_path_buf()
150 }
151}
152
153pub fn expand_watch_patterns(patterns: &[String], base_dir: &Path) -> Result<HashSet<PathBuf>> {
157 let mut dirs_to_watch = HashSet::new();
158
159 for pattern in patterns {
160 let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
162
163 let full_pattern = if Path::new(normalized_pattern).is_absolute() {
165 normalize_path_for_glob(normalized_pattern)
166 } else {
167 normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
168 };
169
170 match glob(&full_pattern) {
172 Ok(paths) => {
173 for entry in paths.flatten() {
174 if let Some(parent) = entry.parent() {
177 dirs_to_watch.insert(normalize_watch_path(parent));
178 }
179 }
180 }
181 Err(e) => {
182 log::warn!("Invalid glob pattern '{pattern}': {e}");
183 }
184 }
185
186 if normalized_pattern.contains('*') {
190 let normalized_pattern_str = normalize_path_for_glob(normalized_pattern);
193 let parts: Vec<&str> = normalized_pattern_str.split('/').collect();
194 let mut base = base_dir.to_path_buf();
195 for part in parts {
196 if part.contains('*') {
197 break;
198 }
199 base = base.join(part);
200 }
201 let dir_to_watch = if base.is_dir() {
204 base
205 } else {
206 base_dir.to_path_buf()
207 };
208 dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
209 } else {
210 let full_path = if Path::new(normalized_pattern).is_absolute() {
213 PathBuf::from(normalized_pattern)
214 } else {
215 base_dir.join(normalized_pattern)
216 };
217 if let Some(parent) = full_path.parent() {
218 let dir_to_watch = if parent.is_dir() {
220 parent.to_path_buf()
221 } else {
222 base_dir.to_path_buf()
223 };
224 dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
225 }
226 }
227 }
228
229 Ok(dirs_to_watch)
230}
231
232fn normalize_path_for_glob(path: &str) -> String {
243 if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
244 format!("//{}", rest.replace('\\', "/"))
245 } else {
246 path.strip_prefix(r"\\?\")
247 .unwrap_or(path)
248 .replace('\\', "/")
249 }
250}
251
252pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
255 let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
257
258 for pattern in patterns {
259 let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
261
262 let full_pattern = if Path::new(normalized_pattern).is_absolute() {
264 normalize_path_for_glob(normalized_pattern)
265 } else {
266 normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
267 };
268
269 let glob = globset::GlobBuilder::new(&full_pattern)
271 .case_insensitive(cfg!(target_os = "windows"))
272 .literal_separator(true) .build();
274
275 if let Ok(glob) = glob {
276 let matcher = glob.compile_matcher();
277 if matcher.is_match(&changed_path_str) {
278 return true;
279 }
280 }
281 }
282 false
283}
284
285#[cfg(test)]
286mod tests {
287 use super::*;
288 use std::fs;
289 use tempfile::TempDir;
290
291 #[test]
292 fn test_normalize_watch_path_existing_directory() {
293 let temp_dir = TempDir::new().unwrap();
294 let dir_path = temp_dir.path().join("test_dir");
295 fs::create_dir(&dir_path).unwrap();
296
297 let normalized = normalize_watch_path(&dir_path);
299 assert!(normalized.is_absolute());
300 assert!(normalized.exists());
301 }
302
303 #[test]
304 fn test_normalize_watch_path_nonexistent_path() {
305 #[cfg(unix)]
309 let path = PathBuf::from("/nonexistent/path/to/dir");
310 #[cfg(windows)]
311 let path = PathBuf::from(r"C:\nonexistent\path\to\dir");
312
313 let normalized = normalize_watch_path(&path);
315 assert_eq!(normalized, path);
316 }
317
318 #[test]
319 fn test_normalize_watch_path_deduplication() {
320 let temp_dir = TempDir::new().unwrap();
321 let dir_path = temp_dir.path().join("test_dir");
322 fs::create_dir(&dir_path).unwrap();
323
324 let subdir = dir_path.join("subdir");
326 fs::create_dir(&subdir).unwrap();
327
328 let path1 = subdir.clone();
331 let path2 = subdir.join("..").join("subdir");
332
333 let normalized1 = normalize_watch_path(&path1);
334 let normalized2 = normalize_watch_path(&path2);
335
336 assert_eq!(normalized1, normalized2);
338 }
339
340 #[test]
341 fn test_expand_watch_patterns_specific_file() {
342 let temp_dir = TempDir::new().unwrap();
343 let base_dir = temp_dir.path();
344
345 let test_file = base_dir.join("package.json");
347 fs::write(&test_file, "{}").unwrap();
348
349 let patterns = vec!["package.json".to_string()];
351 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
352
353 assert_eq!(dirs.len(), 1);
355 let dir = dirs.iter().next().unwrap();
356 assert!(dir.is_absolute());
357 }
358
359 #[test]
360 fn test_expand_watch_patterns_glob() {
361 let temp_dir = TempDir::new().unwrap();
362 let base_dir = temp_dir.path();
363 let subdir = base_dir.join("src");
364 fs::create_dir(&subdir).unwrap();
365
366 fs::write(subdir.join("file1.rs"), "").unwrap();
368 fs::write(subdir.join("file2.rs"), "").unwrap();
369
370 let patterns = vec!["src/**/*.rs".to_string()];
372 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
373
374 assert!(!dirs.is_empty());
376 for dir in &dirs {
377 assert!(dir.is_absolute());
378 }
379 }
380
381 #[test]
382 fn test_expand_watch_patterns_nonexistent_file() {
383 let temp_dir = TempDir::new().unwrap();
384 let base_dir = temp_dir.path();
385
386 let patterns = vec!["config.toml".to_string()];
388 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
389
390 assert_eq!(dirs.len(), 1);
392 }
393
394 #[test]
395 fn test_path_matches_patterns_simple() {
396 let temp_dir = TempDir::new().unwrap();
397 let base_dir = temp_dir.path();
398
399 let test_txt = base_dir.join("test.txt");
401 let test_rs = base_dir.join("test.rs");
402 fs::write(&test_txt, "").unwrap();
403 fs::write(&test_rs, "").unwrap();
404
405 assert!(path_matches_patterns(
407 &test_txt,
408 &["*.txt".to_string()],
409 base_dir
410 ));
411
412 assert!(!path_matches_patterns(
414 &test_rs,
415 &["*.txt".to_string()],
416 base_dir
417 ));
418 }
419
420 #[test]
421 fn test_path_matches_patterns_recursive_glob() {
422 let temp_dir = TempDir::new().unwrap();
423 let base_dir = temp_dir.path();
424 let src_dir = base_dir.join("src");
425 let deep_dir = src_dir.join("deep");
426 fs::create_dir_all(&deep_dir).unwrap();
427
428 let deep_file = deep_dir.join("file.rs");
430 let src_file = src_dir.join("file.rs");
431 fs::write(&deep_file, "").unwrap();
432 fs::write(&src_file, "").unwrap();
433
434 assert!(path_matches_patterns(
436 &deep_file,
437 &["src/**/*.rs".to_string()],
438 base_dir
439 ));
440
441 assert!(path_matches_patterns(
443 &src_file,
444 &["src/**/*.rs".to_string()],
445 base_dir
446 ));
447 }
448
449 #[test]
450 fn test_path_matches_patterns_multiple_patterns() {
451 let temp_dir = TempDir::new().unwrap();
452 let base_dir = temp_dir.path();
453
454 let cargo_toml = base_dir.join("Cargo.toml");
456 let main_rs = base_dir.join("main.rs");
457 let readme_md = base_dir.join("README.md");
458 fs::write(&cargo_toml, "").unwrap();
459 fs::write(&main_rs, "").unwrap();
460 fs::write(&readme_md, "").unwrap();
461
462 let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
464 assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
465 assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
466 assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
467 }
468
469 #[test]
470 fn test_path_matches_patterns_relative_prefix() {
471 let temp_dir = TempDir::new().unwrap();
472 let base_dir = temp_dir.path();
473
474 let test_file = base_dir.join("config.json");
476 fs::write(&test_file, "{}").unwrap();
477
478 assert!(path_matches_patterns(
480 &test_file,
481 &["./config.json".to_string()],
482 base_dir
483 ));
484
485 assert!(path_matches_patterns(
487 &test_file,
488 &["config.json".to_string()],
489 base_dir
490 ));
491 }
492
493 #[test]
494 fn test_expand_watch_patterns_relative_prefix() {
495 let temp_dir = TempDir::new().unwrap();
496 let base_dir = temp_dir.path();
497
498 let test_file = base_dir.join("config.json");
500 fs::write(&test_file, "{}").unwrap();
501
502 let patterns = vec!["./config.json".to_string()];
504 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
505
506 assert_eq!(dirs.len(), 1);
508 let dir = dirs.iter().next().unwrap();
509 assert!(dir.is_absolute());
510 }
511}