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
60/// Expand glob patterns to actual file paths.
61/// Patterns are resolved relative to base_dir.
62/// Returns unique directories that need to be watched.
63pub fn expand_watch_patterns(patterns: &[String], base_dir: &Path) -> Result<HashSet<PathBuf>> {
64    let mut dirs_to_watch = HashSet::new();
65
66    for pattern in patterns {
67        // Make the pattern absolute by joining with base_dir
68        let full_pattern = if Path::new(pattern).is_absolute() {
69            pattern.clone()
70        } else {
71            base_dir.join(pattern).to_string_lossy().to_string()
72        };
73
74        // Expand the glob pattern
75        match glob(&full_pattern) {
76            Ok(paths) => {
77                for entry in paths.flatten() {
78                    // Watch the parent directory of each matched file
79                    // This allows us to detect new files that match the pattern
80                    if let Some(parent) = entry.parent() {
81                        dirs_to_watch.insert(parent.to_path_buf());
82                    }
83                }
84            }
85            Err(e) => {
86                log::warn!("Invalid glob pattern '{}': {}", pattern, e);
87            }
88        }
89
90        // For patterns with wildcards, watch the base directory (before the wildcard)
91        // For non-wildcard patterns, watch the parent directory of the specific file
92        // This ensures we catch new files even if they don't exist at startup
93        if pattern.contains('*') {
94            // Find the first directory without wildcards
95            let parts: Vec<&str> = pattern.split('/').collect();
96            let mut base = base_dir.to_path_buf();
97            for part in parts {
98                if part.contains('*') {
99                    break;
100                }
101                base = base.join(part);
102            }
103            // Watch the base directory if it exists, otherwise fall back to base_dir
104            // This ensures we can detect when the directory is created
105            let dir_to_watch = if base.is_dir() {
106                base
107            } else {
108                base_dir.to_path_buf()
109            };
110            dirs_to_watch.insert(dir_to_watch);
111        } else {
112            // Non-wildcard pattern (specific file like "package.json")
113            // Always watch the parent directory, even if file doesn't exist yet
114            let full_path = if Path::new(pattern).is_absolute() {
115                PathBuf::from(pattern)
116            } else {
117                base_dir.join(pattern)
118            };
119            if let Some(parent) = full_path.parent() {
120                // Watch the parent if it exists (or base_dir as fallback)
121                let dir_to_watch = if parent.is_dir() {
122                    parent.to_path_buf()
123                } else {
124                    base_dir.to_path_buf()
125                };
126                dirs_to_watch.insert(dir_to_watch);
127            }
128        }
129    }
130
131    Ok(dirs_to_watch)
132}
133
134/// Normalize a path string to use forward slashes for glob pattern matching.
135/// This ensures consistent behavior across Windows and Unix platforms.
136fn normalize_path_for_glob(path: &str) -> String {
137    path.replace('\\', "/")
138}
139
140/// Check if a changed path matches any of the watch patterns.
141/// Uses globset which properly supports ** for recursive directory matching.
142pub fn path_matches_patterns(changed_path: &Path, patterns: &[String], base_dir: &Path) -> bool {
143    // Normalize the changed path to use forward slashes for consistent matching
144    let changed_path_str = normalize_path_for_glob(&changed_path.to_string_lossy());
145
146    for pattern in patterns {
147        // Build the full pattern and normalize to use forward slashes
148        let full_pattern = if Path::new(pattern).is_absolute() {
149            normalize_path_for_glob(pattern)
150        } else {
151            normalize_path_for_glob(&base_dir.join(pattern).to_string_lossy())
152        };
153
154        // Use globset which properly supports ** for recursive matching
155        let glob = globset::GlobBuilder::new(&full_pattern)
156            .case_insensitive(cfg!(target_os = "windows"))
157            .literal_separator(true) // * doesn't match /, use ** for recursive
158            .build();
159
160        if let Ok(glob) = glob {
161            let matcher = glob.compile_matcher();
162            if matcher.is_match(&changed_path_str) {
163                return true;
164            }
165        }
166    }
167    false
168}