pitchfork_cli/
watch_files.rs1use 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 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
64fn 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
77pub 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 let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
86
87 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 match glob(&full_pattern) {
96 Ok(paths) => {
97 for entry in paths.flatten() {
98 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 if normalized_pattern.contains('*') {
114 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 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 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 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
156fn normalize_path_for_glob(path: &str) -> String {
159 path.replace('\\', "/")
160}
161
162pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
165 let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
167
168 for pattern in patterns {
169 let normalized_pattern = pattern.strip_prefix("./").unwrap_or(pattern);
171
172 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 let glob = globset::GlobBuilder::new(&full_pattern)
181 .case_insensitive(cfg!(target_os = "windows"))
182 .literal_separator(true) .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 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 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 let subdir = dir_path.join("subdir");
230 fs::create_dir(&subdir).unwrap();
231
232 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 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 let test_file = base_dir.join("package.json");
251 fs::write(&test_file, "{}").unwrap();
252
253 let patterns = vec!["package.json".to_string()];
255 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
256
257 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 fs::write(subdir.join("file1.rs"), "").unwrap();
272 fs::write(subdir.join("file2.rs"), "").unwrap();
273
274 let patterns = vec!["src/**/*.rs".to_string()];
276 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
277
278 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 let patterns = vec!["config.toml".to_string()];
292 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
293
294 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 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 assert!(path_matches_patterns(
311 &test_txt,
312 &["*.txt".to_string()],
313 base_dir
314 ));
315
316 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 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 assert!(path_matches_patterns(
340 &deep_file,
341 &["src/**/*.rs".to_string()],
342 base_dir
343 ));
344
345 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 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 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 let test_file = base_dir.join("config.json");
380 fs::write(&test_file, "{}").unwrap();
381
382 assert!(path_matches_patterns(
384 &test_file,
385 &["./config.json".to_string()],
386 base_dir
387 ));
388
389 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 let test_file = base_dir.join("config.json");
404 fs::write(&test_file, "{}").unwrap();
405
406 let patterns = vec!["./config.json".to_string()];
408 let dirs = expand_watch_patterns(&patterns, base_dir).unwrap();
409
410 assert_eq!(dirs.len(), 1);
412 let dir = dirs.iter().next().unwrap();
413 assert!(dir.is_absolute());
414 }
415}