1use std::collections::HashSet;
7use std::path::{Path, PathBuf};
8use std::time::Duration;
9
10use notify::{Config, RecommendedWatcher, RecursiveMode, Watcher};
11use tokio::sync::mpsc;
12
13pub struct DevWatcher {
18 watcher: RecommendedWatcher,
19 rx: mpsc::UnboundedReceiver<PathBuf>,
20 watched: HashSet<PathBuf>,
21}
22
23impl DevWatcher {
24 pub fn new(paths: &[PathBuf]) -> Result<Self, String> {
29 let (tx, rx) = mpsc::unbounded_channel();
30
31 let watcher = RecommendedWatcher::new(
32 move |res: Result<notify::Event, notify::Error>| {
33 if let Ok(event) = res {
34 for path in event.paths {
35 let _ = tx.send(path);
36 }
37 }
38 },
39 Config::default(),
40 )
41 .map_err(|e| format!("failed to create file watcher: {e}"))?;
42
43 let mut dev_watcher = Self {
44 watcher,
45 rx,
46 watched: HashSet::new(),
47 };
48
49 for path in paths {
50 dev_watcher.watch(path)?;
51 }
52
53 Ok(dev_watcher)
54 }
55
56 fn watch(&mut self, path: &Path) -> Result<(), String> {
58 let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
59
60 if self.watched.contains(&canonical) {
61 return Ok(());
62 }
63
64 let mode = if canonical.is_dir() {
65 RecursiveMode::Recursive
66 } else {
67 RecursiveMode::NonRecursive
68 };
69
70 self.watcher
71 .watch(&canonical, mode)
72 .map_err(|e| format!("failed to watch {}: {e}", canonical.display()))?;
73
74 self.watched.insert(canonical);
75 Ok(())
76 }
77
78 pub async fn next_change(&mut self, debounce: Duration) -> Vec<PathBuf> {
83 let first = match self.rx.recv().await {
85 Some(p) => p,
86 None => return vec![],
87 };
88
89 let mut changed = vec![first];
90 let deadline = tokio::time::Instant::now() + debounce;
91
92 loop {
94 tokio::select! {
95 _ = tokio::time::sleep_until(deadline) => break,
96 path = self.rx.recv() => {
97 match path {
98 Some(p) => {
99 if !changed.contains(&p) {
100 changed.push(p);
101 }
102 }
103 None => break,
104 }
105 }
106 }
107 }
108
109 changed
110 }
111
112 pub fn update_watches(&mut self, new_paths: &[PathBuf]) -> Result<(), String> {
116 let new_set: HashSet<PathBuf> = new_paths
117 .iter()
118 .map(|p| p.canonicalize().unwrap_or_else(|_| p.clone()))
119 .collect();
120
121 let to_remove: Vec<PathBuf> = self.watched.difference(&new_set).cloned().collect();
123
124 for path in &to_remove {
125 let _ = self.watcher.unwatch(path);
126 self.watched.remove(path);
127 }
128
129 for path in &new_set {
131 if !self.watched.contains(path) {
132 self.watch(path)?;
133 }
134 }
135
136 Ok(())
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::*;
143
144 #[test]
145 fn watcher_creation_with_valid_paths() {
146 let temp = tempfile::tempdir().unwrap();
147 let file = temp.path().join("test.yaml");
148 std::fs::write(&file, "test").unwrap();
149
150 let watcher = DevWatcher::new(std::slice::from_ref(&file));
151 assert!(watcher.is_ok());
152 }
153
154 #[test]
155 fn watcher_creation_with_directory() {
156 let temp = tempfile::tempdir().unwrap();
157 let watcher = DevWatcher::new(&[temp.path().to_path_buf()]);
158 assert!(watcher.is_ok());
159 }
160
161 #[tokio::test]
162 async fn watcher_detects_file_change() {
163 let temp = tempfile::tempdir().unwrap();
164 let file = temp.path().join("test.yaml");
165 std::fs::write(&file, "v1").unwrap();
166
167 let mut watcher = DevWatcher::new(std::slice::from_ref(&file)).unwrap();
168
169 let file_clone = file.clone();
171 tokio::spawn(async move {
172 tokio::time::sleep(Duration::from_millis(50)).await;
173 std::fs::write(&file_clone, "v2").unwrap();
174 });
175
176 let changed = tokio::time::timeout(
177 Duration::from_secs(5),
178 watcher.next_change(Duration::from_millis(200)),
179 )
180 .await
181 .expect("timed out waiting for change");
182
183 assert!(!changed.is_empty());
184 }
185
186 #[tokio::test]
187 async fn watcher_detects_new_file_in_directory() {
188 let temp = tempfile::tempdir().unwrap();
189
190 let mut watcher = DevWatcher::new(&[temp.path().to_path_buf()]).unwrap();
191
192 let dir = temp.path().to_path_buf();
194 tokio::spawn(async move {
195 tokio::time::sleep(Duration::from_millis(50)).await;
196 std::fs::write(dir.join("new.yaml"), "content").unwrap();
197 });
198
199 let changed = tokio::time::timeout(
200 Duration::from_secs(5),
201 watcher.next_change(Duration::from_millis(200)),
202 )
203 .await
204 .expect("timed out waiting for change");
205
206 assert!(!changed.is_empty());
207 }
208}