Skip to main content

pitchfork_cli/
watch_files.rs

1use crate::Result;
2use glob::glob;
3use itertools::Itertools;
4use miette::IntoDiagnostic;
5use notify::{Config, EventKind, RecommendedWatcher, RecursiveMode};
6use notify_debouncer_full::{DebounceEventResult, Debouncer, FileIdMap, new_debouncer_opt};
7use std::collections::HashSet;
8use std::path::{Path, PathBuf};
9use std::time::Duration;
10
11pub struct WatchFiles {
12    pub rx: tokio::sync::mpsc::Receiver<Vec<PathBuf>>,
13    debouncer: Debouncer<RecommendedWatcher, FileIdMap>,
14}
15
16impl WatchFiles {
17    pub fn new(duration: Duration) -> Result<Self> {
18        let h = tokio::runtime::Handle::current();
19        let (tx, rx) = tokio::sync::mpsc::channel(1);
20        let debouncer = new_debouncer_opt(
21            duration,
22            None,
23            move |res: DebounceEventResult| {
24                let tx = tx.clone();
25                h.spawn(async move {
26                    if let Ok(ev) = res {
27                        let paths = ev
28                            .into_iter()
29                            .filter(|e| {
30                                matches!(
31                                    e.kind,
32                                    EventKind::Modify(_)
33                                        | EventKind::Create(_)
34                                        | EventKind::Remove(_)
35                                )
36                            })
37                            .flat_map(|e| e.paths.clone())
38                            .unique()
39                            .collect_vec();
40                        if !paths.is_empty() {
41                            // Ignore send errors - receiver may be dropped during shutdown
42                            let _ = tx.send(paths).await;
43                        }
44                    }
45                });
46            },
47            FileIdMap::new(),
48            Config::default(),
49        )
50        .into_diagnostic()?;
51
52        Ok(Self { debouncer, rx })
53    }
54
55    pub fn watch(&mut self, path: &Path, recursive_mode: RecursiveMode) -> Result<()> {
56        self.debouncer.watch(path, recursive_mode).into_diagnostic()
57    }
58
59    pub fn unwatch(&mut self, path: &Path) -> Result<()> {
60        self.debouncer.unwatch(path).into_diagnostic()
61    }
62}
63
64/// Normalize a path by attempting to canonicalize it. If that fails, it attempts
65/// to resolve it as an absolute path. This helps ensure that different relative
66/// paths to the same directory are deduplicated.
67fn normalize_watch_path(path: &Path) -> PathBuf {
68    path.canonicalize().unwrap_or_else(|_| {
69        if path.is_absolute() {
70            path.to_path_buf()
71        } else {
72            crate::env::CWD.join(path)
73        }
74    })
75}
76
77/// Expand glob patterns to actual file paths.
78/// Patterns are resolved relative to base_dir.
79/// Returns unique directories that need to be watched.
80pub fn expand_watch_patterns(patterns: &[String], base_dir: &Path) -> Result<HashSet<PathBuf>> {
81    let mut dirs_to_watch = HashSet::new();
82
83    for pattern in patterns {
84        // Strip leading "./" from patterns to handle relative path prefixes
85        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
86
87        // Make the pattern absolute by joining with base_dir
88        let full_pattern = if Path::new(normalized_pattern).is_absolute() {
89            normalize_path_for_glob(normalized_pattern)
90        } else {
91            normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
92        };
93
94        // Expand the glob pattern
95        match glob(&full_pattern) {
96            Ok(paths) => {
97                for entry in paths.flatten() {
98                    // Watch the parent directory of each matched file
99                    // This allows us to detect new files that match the pattern
100                    if let Some(parent) = entry.parent() {
101                        dirs_to_watch.insert(normalize_watch_path(parent));
102                    }
103                }
104            }
105            Err(e) => {
106                log::warn!("Invalid glob pattern '{pattern}': {e}");
107            }
108        }
109
110        // For patterns with wildcards, watch the base directory (before the wildcard)
111        // For non-wildcard patterns, watch the parent directory of the specific file
112        // This ensures we catch new files even if they don't exist at startup
113        if normalized_pattern.contains('*') {
114            // Find the first directory without wildcards
115            // Normalize to use forward slashes for cross-platform compatibility
116            let normalized_pattern_str = normalize_path_for_glob(normalized_pattern);
117            let parts: Vec<&str> = normalized_pattern_str.split('/').collect();
118            let mut base = base_dir.to_path_buf();
119            for part in parts {
120                if part.contains('*') {
121                    break;
122                }
123                base = base.join(part);
124            }
125            // Watch the base directory if it exists, otherwise fall back to base_dir
126            // This ensures we can detect when the directory is created
127            let dir_to_watch = if base.is_dir() {
128                base
129            } else {
130                base_dir.to_path_buf()
131            };
132            dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
133        } else {
134            // Non-wildcard pattern (specific file like "package.json")
135            // Always watch the parent directory, even if file doesn't exist yet
136            let full_path = if Path::new(normalized_pattern).is_absolute() {
137                PathBuf::from(normalized_pattern)
138            } else {
139                base_dir.join(normalized_pattern)
140            };
141            if let Some(parent) = full_path.parent() {
142                // Watch the parent if it exists (or base_dir as fallback)
143                let dir_to_watch = if parent.is_dir() {
144                    parent.to_path_buf()
145                } else {
146                    base_dir.to_path_buf()
147                };
148                dirs_to_watch.insert(normalize_watch_path(&dir_to_watch));
149            }
150        }
151    }
152
153    Ok(dirs_to_watch)
154}
155
156/// Normalize a path string to use forward slashes for glob pattern matching.
157/// This ensures consistent behavior across Windows and Unix platforms.
158fn normalize_path_for_glob(path: &str) -> String {
159    path.replace('\\', "/")
160}
161
162/// Check if a changed path matches any of the watch patterns.
163/// Uses globset which properly supports ** for recursive directory matching.
164pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
165    // Normalize the changed path to use forward slashes for consistent matching
166    let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
167
168    for pattern in patterns {
169        // Strip leading "./" from patterns to handle relative path prefixes
170        let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
171
172        // Build the full pattern and normalize to use forward slashes
173        let full_pattern = if Path::new(normalized_pattern).is_absolute() {
174            normalize_path_for_glob(normalized_pattern)
175        } else {
176            normalize_path_for_glob(&base_dir.join(normalized_pattern).to_string_lossy())
177        };
178
179        // Use globset which properly supports ** for recursive matching
180        let glob = globset::GlobBuilder::new(&full_pattern)
181            .case_insensitive(cfg!(target_os = "windows"))
182            .literal_separator(true) // * doesn't match /, use ** for recursive
183            .build();
184
185        if let Ok(glob) = glob {
186            let matcher = glob.compile_matcher();
187            if matcher.is_match(&changed_path_str) {
188                return true;
189            }
190        }
191    }
192    false
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use std::fs;
199    use tempfile::TempDir;
200
201    #[test]
202    fn test_normalize_watch_path_existing_directory() {
203        let temp_dir = TempDir::new().unwrap();
204        let dir_path = temp_dir.path().join("test_dir");
205        fs::create_dir(&dir_path).unwrap();
206
207        // Canonicalize should work for existing directories
208        let normalized = normalize_watch_path(&dir_path);
209        assert!(normalized.is_absolute());
210        assert!(normalized.exists());
211    }
212
213    #[test]
214    fn test_normalize_watch_path_nonexistent_path() {
215        let path = PathBuf::from("/nonexistent/path/to/dir");
216
217        // Should return the original path when canonicalization fails
218        let normalized = normalize_watch_path(&path);
219        assert_eq!(normalized, path);
220    }
221
222    #[test]
223    fn test_normalize_watch_path_deduplication() {
224        let temp_dir = TempDir::new().unwrap();
225        let dir_path = temp_dir.path().join("test_dir");
226        fs::create_dir(&dir_path).unwrap();
227
228        // Create a subdirectory to test path traversal
229        let subdir = dir_path.join("subdir");
230        fs::create_dir(&subdir).unwrap();
231
232        // Create two different relative paths pointing to the same directory
233        // One is direct, the other uses parent/child traversal
234        let path1 = subdir.clone();
235        let path2 = subdir.join("..").join("subdir");
236
237        let normalized1 = normalize_watch_path(&path1);
238        let normalized2 = normalize_watch_path(&path2);
239
240        // Both should canonicalize to the same path
241        assert_eq!(normalized1, normalized2);
242    }
243
244    #[test]
245    fn test_expand_watch_patterns_specific_file() {
246        let temp_dir = TempDir::new().unwrap();
247        let base_dir = temp_dir.path();
248
249        // Create a test file
250        let test_file = base_dir.join("package.json");
251        fs::write(&test_file, "{}").unwrap();
252
253        // Expand pattern for a specific file
254        let patterns = vec!["package.json".to_string()];
255        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
256
257        // Should watch the parent directory
258        assert_eq!(dirs.len(), 1);
259        let dir = dirs.iter().next().unwrap();
260        assert!(dir.is_absolute());
261    }
262
263    #[test]
264    fn test_expand_watch_patterns_glob() {
265        let temp_dir = TempDir::new().unwrap();
266        let base_dir = temp_dir.path();
267        let subdir = base_dir.join("src");
268        fs::create_dir(&subdir).unwrap();
269
270        // Create test files in src directory
271        fs::write(subdir.join("file1.rs"), "").unwrap();
272        fs::write(subdir.join("file2.rs"), "").unwrap();
273
274        // Expand glob pattern
275        let patterns = vec!["src/**/*.rs".to_string()];
276        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
277
278        // Should watch the src directory
279        assert!(!dirs.is_empty());
280        for dir in &dirs {
281            assert!(dir.is_absolute());
282        }
283    }
284
285    #[test]
286    fn test_expand_watch_patterns_nonexistent_file() {
287        let temp_dir = TempDir::new().unwrap();
288        let base_dir = temp_dir.path();
289
290        // Pattern for a file that doesn't exist yet
291        let patterns = vec!["config.toml".to_string()];
292        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
293
294        // Should still watch the parent directory (base_dir in this case)
295        assert_eq!(dirs.len(), 1);
296    }
297
298    #[test]
299    fn test_path_matches_patterns_simple() {
300        let temp_dir = TempDir::new().unwrap();
301        let base_dir = temp_dir.path();
302
303        // Create test files
304        let test_txt = base_dir.join("test.txt");
305        let test_rs = base_dir.join("test.rs");
306        fs::write(&test_txt, "").unwrap();
307        fs::write(&test_rs, "").unwrap();
308
309        // Simple pattern match
310        assert!(path_matches_patterns(
311            &test_txt,
312            &["*.txt".to_string()],
313            base_dir
314        ));
315
316        // Non-matching pattern
317        assert!(!path_matches_patterns(
318            &test_rs,
319            &["*.txt".to_string()],
320            base_dir
321        ));
322    }
323
324    #[test]
325    fn test_path_matches_patterns_recursive_glob() {
326        let temp_dir = TempDir::new().unwrap();
327        let base_dir = temp_dir.path();
328        let src_dir = base_dir.join("src");
329        let deep_dir = src_dir.join("deep");
330        fs::create_dir_all(&deep_dir).unwrap();
331
332        // Create test files
333        let deep_file = deep_dir.join("file.rs");
334        let src_file = src_dir.join("file.rs");
335        fs::write(&deep_file, "").unwrap();
336        fs::write(&src_file, "").unwrap();
337
338        // ** pattern should match any depth
339        assert!(path_matches_patterns(
340            &deep_file,
341            &["src/**/*.rs".to_string()],
342            base_dir
343        ));
344
345        // Should also match top-level
346        assert!(path_matches_patterns(
347            &src_file,
348            &["src/**/*.rs".to_string()],
349            base_dir
350        ));
351    }
352
353    #[test]
354    fn test_path_matches_patterns_multiple_patterns() {
355        let temp_dir = TempDir::new().unwrap();
356        let base_dir = temp_dir.path();
357
358        // Create test files
359        let cargo_toml = base_dir.join("Cargo.toml");
360        let main_rs = base_dir.join("main.rs");
361        let readme_md = base_dir.join("README.md");
362        fs::write(&cargo_toml, "").unwrap();
363        fs::write(&main_rs, "").unwrap();
364        fs::write(&readme_md, "").unwrap();
365
366        // Multiple patterns - should match if any pattern matches
367        let patterns = vec!["*.rs".to_string(), "*.toml".to_string()];
368        assert!(path_matches_patterns(&cargo_toml, &patterns, base_dir));
369        assert!(path_matches_patterns(&main_rs, &patterns, base_dir));
370        assert!(!path_matches_patterns(&readme_md, &patterns, base_dir));
371    }
372
373    #[test]
374    fn test_path_matches_patterns_relative_prefix() {
375        let temp_dir = TempDir::new().unwrap();
376        let base_dir = temp_dir.path();
377
378        // Create a test file
379        let test_file = base_dir.join("config.json");
380        fs::write(&test_file, "{}").unwrap();
381
382        // Pattern with "./" prefix should match the file
383        assert!(path_matches_patterns(
384            &test_file,
385            &["./config.json".to_string()],
386            base_dir
387        ));
388
389        // Same pattern without prefix should also match
390        assert!(path_matches_patterns(
391            &test_file,
392            &["config.json".to_string()],
393            base_dir
394        ));
395    }
396
397    #[test]
398    fn test_expand_watch_patterns_relative_prefix() {
399        let temp_dir = TempDir::new().unwrap();
400        let base_dir = temp_dir.path();
401
402        // Create a test file
403        let test_file = base_dir.join("config.json");
404        fs::write(&test_file, "{}").unwrap();
405
406        // Pattern with "./" prefix should expand correctly
407        let patterns = vec!["./config.json".to_string()];
408        let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
409
410        // Should watch the parent directory
411        assert_eq!(dirs.len(), 1);
412        let dir = dirs.iter().next().unwrap();
413        assert!(dir.is_absolute());
414    }
415}