1use crate::error::{CliError, Result};
7use notify::{Event, RecommendedWatcher, RecursiveMode, Watcher};
8use std::path::{Path, PathBuf};
9use std::time::{Duration, Instant};
10use tokio::sync::mpsc;
11
12#[derive(Debug, Clone)]
14pub enum FileChange {
15 Modified(PathBuf),
17 Created(PathBuf),
19 Removed(PathBuf),
21}
22
23impl FileChange {
24 pub fn path(&self) -> &Path {
26 match self {
27 FileChange::Modified(p) | FileChange::Created(p) | FileChange::Removed(p) => p,
28 }
29 }
30}
31
32pub struct FileWatcher {
37 _watcher: RecommendedWatcher,
39 root: PathBuf,
41 ignore_patterns: Vec<String>,
43}
44
45impl FileWatcher {
46 pub fn new(
62 root: PathBuf,
63 ignore_patterns: Vec<String>,
64 debounce_ms: u64,
65 ) -> Result<(Self, mpsc::Receiver<FileChange>)> {
66 if !root.exists() {
68 return Err(CliError::FileNotFound(root));
69 }
70
71 let (tx, rx) = mpsc::channel(100);
72
73 let debounce_duration = Duration::from_millis(debounce_ms);
75 let mut last_event: Option<(PathBuf, Instant)> = None;
76 let ignore_patterns_clone = ignore_patterns.clone();
77 let root_clone = root.clone();
78
79 let mut watcher = notify::recommended_watcher(move |res: notify::Result<Event>| {
81 if let Ok(event) = res {
82 for path in &event.paths {
84 if Self::should_ignore(path, &root_clone, &ignore_patterns_clone) {
86 continue;
87 }
88
89 let now = Instant::now();
91 if let Some((last_path, last_time)) = &last_event {
92 if last_path == path && now.duration_since(*last_time) < debounce_duration {
93 continue;
94 }
95 }
96
97 last_event = Some((path.clone(), now));
98
99 let change = match event.kind {
101 notify::EventKind::Create(_) => FileChange::Created(path.clone()),
102 notify::EventKind::Modify(_) => FileChange::Modified(path.clone()),
103 notify::EventKind::Remove(_) => FileChange::Removed(path.clone()),
104 _ => continue,
105 };
106
107 let _ = tx.blocking_send(change);
109 }
110 }
111 })
112 .map_err(CliError::Watch)?;
113
114 watcher
116 .watch(&root, RecursiveMode::Recursive)
117 .map_err(CliError::Watch)?;
118
119 Ok((
120 Self {
121 _watcher: watcher,
122 root,
123 ignore_patterns,
124 },
125 rx,
126 ))
127 }
128
129 fn should_ignore(path: &Path, root: &Path, ignore_patterns: &[String]) -> bool {
136 if !path.starts_with(root) {
138 return true;
139 }
140
141 let rel_path = match path.strip_prefix(root) {
143 Ok(p) => p,
144 Err(_) => return true,
145 };
146
147 let path_str = rel_path.to_string_lossy();
148
149 for pattern in ignore_patterns {
151 if pattern.starts_with('*') {
153 let ext = pattern.trim_start_matches('*');
155 if path_str.ends_with(ext) {
156 return true;
157 }
158 } else if path_str.starts_with(pattern) || path_str.contains(&format!("/{}", pattern)) {
159 return true;
161 }
162 }
163
164 for component in rel_path.components() {
166 if let Some(name) = component.as_os_str().to_str() {
167 if name.starts_with('.') && name != "." && name != ".." {
168 return true;
169 }
170 }
171 }
172
173 false
174 }
175
176 pub fn root(&self) -> &Path {
178 &self.root
179 }
180
181 pub fn ignore_patterns(&self) -> &[String] {
183 &self.ignore_patterns
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn test_should_ignore_node_modules() {
193 let root = PathBuf::from("/project");
194 let patterns = vec!["node_modules".to_string()];
195
196 let path = PathBuf::from("/project/node_modules/package/index.js");
197 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
198
199 let path = PathBuf::from("/project/src/index.js");
200 assert!(!FileWatcher::should_ignore(&path, &root, &patterns));
201 }
202
203 #[test]
204 fn test_should_ignore_extension() {
205 let root = PathBuf::from("/project");
206 let patterns = vec!["*.log".to_string()];
207
208 let path = PathBuf::from("/project/debug.log");
209 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
210
211 let path = PathBuf::from("/project/src/index.js");
212 assert!(!FileWatcher::should_ignore(&path, &root, &patterns));
213 }
214
215 #[test]
216 fn test_should_ignore_hidden_files() {
217 let root = PathBuf::from("/project");
218 let patterns = vec![];
219
220 let path = PathBuf::from("/project/.git/config");
221 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
222
223 let path = PathBuf::from("/project/.env");
224 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
225
226 let path = PathBuf::from("/project/src/.hidden/file.js");
227 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
228 }
229
230 #[test]
231 fn test_should_ignore_outside_root() {
232 let root = PathBuf::from("/project");
233 let patterns = vec![];
234
235 let path = PathBuf::from("/other/file.js");
236 assert!(FileWatcher::should_ignore(&path, &root, &patterns));
237 }
238
239 #[test]
240 fn test_file_change_path() {
241 let path = PathBuf::from("/project/src/index.js");
242
243 let change = FileChange::Modified(path.clone());
244 assert_eq!(change.path(), path.as_path());
245
246 let change = FileChange::Created(path.clone());
247 assert_eq!(change.path(), path.as_path());
248
249 let change = FileChange::Removed(path.clone());
250 assert_eq!(change.path(), path.as_path());
251 }
252}