Skip to main content

pitchfork_cli/
watch_files.rs

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                            // Ignore send errors - receiver may be dropped during shutdown
47                            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
105/// Normalize a path by attempting to canonicalize it. If that fails, it attempts
106/// to resolve it as an absolute path. This helps ensure that different relative
107/// paths to the same directory are deduplicated.
108///
109/// On Windows, `std::fs::canonicalize()` returns paths with the `\\?\` (verbatim)
110/// prefix. The `notify` crate's PollWatcher may not correctly report changes for
111/// verbatim-prefixed paths, and the changed paths it reports would carry the
112/// prefix, causing mismatches with non-canonicalized glob patterns. We strip
113/// the prefix after canonicalization to keep paths consistent across the watcher
114/// and the pattern matcher.
115fn 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/// Strip the `\\?\` verbatim prefix from a Windows path.
138/// `\\?\C:\dir` → `C:\dir`, `\\?\UNC\server\share` → `\\server\share`
139#[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
153/// Expand glob patterns to actual file paths.
154/// Patterns are resolved relative to base_dir.
155/// Returns unique directories that need to be watched.
156pub 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        // Strip leading "./" from patterns to handle relative path prefixes
161        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
162
163        // Make the pattern absolute by joining with base_dir
164        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        // Expand the glob pattern
171        match glob(&full_pattern) {
172            Ok(paths) => {
173                for entry in paths.flatten() {
174                    // Watch the parent directory of each matched file
175                    // This allows us to detect new files that match the pattern
176                    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        // For patterns with wildcards, watch the base directory (before the wildcard)
187        // For non-wildcard patterns, watch the parent directory of the specific file
188        // This ensures we catch new files even if they don't exist at startup
189        if normalized_pattern.contains('*') {
190            // Find the first directory without wildcards
191            // Normalize to use forward slashes for cross-platform compatibility
192            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            // Watch the base directory if it exists, otherwise fall back to base_dir
202            // This ensures we can detect when the directory is created
203            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            // Non-wildcard pattern (specific file like "package.json")
211            // Always watch the parent directory, even if file doesn't exist yet
212            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                // Watch the parent if it exists (or base_dir as fallback)
219                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
232/// Normalize a path string to use forward slashes for glob pattern matching.
233/// This ensures consistent behavior across Windows and Unix platforms.
234///
235/// On Windows, `std::fs::canonicalize()` returns paths with the `\\?\` prefix
236/// (verbatim path). If we don't strip it, canonicalized watcher paths won't
237/// match non-canonicalized glob patterns built from `env::CWD`, causing all
238/// file-change matching to silently fail on Windows.
239///
240/// Verbatim UNC paths (`\\?\UNC\server\share`) are converted to the regular
241/// UNC form (`//server/share`) so they match glob patterns consistently.
242fn 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
252/// Check if a changed path matches any of the watch patterns.
253/// Uses globset which properly supports ** for recursive directory matching.
254pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
255    // Normalize the changed path to use forward slashes for consistent matching
256    let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
257
258    for pattern in patterns {
259        // Strip leading "./" from patterns to handle relative path prefixes
260        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
261
262        // Build the full pattern and normalize to use forward slashes
263        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        // Use globset which properly supports ** for recursive matching
270        let glob = globset::GlobBuilder::new(&full_pattern)
271            .case_insensitive(cfg!(target_os = "windows"))
272            .literal_separator(true) // * doesn't match /, use ** for recursive
273            .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        // Canonicalize should work for existing directories
298        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        // Use a platform-appropriate absolute path that doesn't exist.
306        // On Windows, "/nonexistent/..." is not absolute (no drive letter),
307        // so normalize_watch_path would prepend CWD instead of returning as-is.
308        #[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        // Should return the original path when canonicalization fails
314        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        // Create a subdirectory to test path traversal
325        let subdir = dir_path.join("subdir");
326        fs::create_dir(&subdir).unwrap();
327
328        // Create two different relative paths pointing to the same directory
329        // One is direct, the other uses parent/child traversal
330        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        // Both should canonicalize to the same path
337        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        // Create a test file
346        let test_file = base_dir.join("package.json");
347        fs::write(&test_file, "{}").unwrap();
348
349        // Expand pattern for a specific file
350        let patterns = vec!["package.json".to_string()];
351        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
352
353        // Should watch the parent directory
354        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        // Create test files in src directory
367        fs::write(subdir.join("file1.rs"), "").unwrap();
368        fs::write(subdir.join("file2.rs"), "").unwrap();
369
370        // Expand glob pattern
371        let patterns = vec!["src/**/*.rs".to_string()];
372        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
373
374        // Should watch the src directory
375        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        // Pattern for a file that doesn't exist yet
387        let patterns = vec!["config.toml".to_string()];
388        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
389
390        // Should still watch the parent directory (base_dir in this case)
391        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        // Create test files
400        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        // Simple pattern match
406        assert!(path_matches_patterns(
407            &test_txt,
408            &["*.txt".to_string()],
409            base_dir
410        ));
411
412        // Non-matching pattern
413        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        // Create test files
429        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        // ** pattern should match any depth
435        assert!(path_matches_patterns(
436            &deep_file,
437            &["src/**/*.rs".to_string()],
438            base_dir
439        ));
440
441        // Should also match top-level
442        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        // Create test files
455        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        // Multiple patterns - should match if any pattern matches
463        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        // Create a test file
475        let test_file = base_dir.join("config.json");
476        fs::write(&test_file, "{}").unwrap();
477
478        // Pattern with "./" prefix should match the file
479        assert!(path_matches_patterns(
480            &test_file,
481            &["./config.json".to_string()],
482            base_dir
483        ));
484
485        // Same pattern without prefix should also match
486        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        // Create a test file
499        let test_file = base_dir.join("config.json");
500        fs::write(&test_file, "{}").unwrap();
501
502        // Pattern with "./" prefix should expand correctly
503        let patterns = vec!["./config.json".to_string()];
504        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
505
506        // Should watch the parent directory
507        assert_eq!(dirs.len(), 1);
508        let dir = dirs.iter().next().unwrap();
509        assert!(dir.is_absolute());
510    }
511}