par_term_config/
watcher.rs1use anyhow::{Context, Result};
7use notify::{Config as NotifyConfig, Event, PollWatcher, RecursiveMode, Watcher};
8use parking_lot::Mutex;
9use std::path::{Path, PathBuf};
10use std::sync::Arc;
11use std::sync::mpsc::{Receiver, channel};
12use std::time::{Duration, Instant};
13
14#[derive(Debug, Clone)]
16pub struct ConfigReloadEvent {
17 pub path: PathBuf,
19}
20
21pub struct ConfigWatcher {
23 _watcher: Box<dyn Watcher + Send>,
25 event_receiver: Receiver<ConfigReloadEvent>,
27}
28
29impl std::fmt::Debug for ConfigWatcher {
30 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
31 f.debug_struct("ConfigWatcher").finish_non_exhaustive()
32 }
33}
34
35fn make_event_handler(
40 filename: std::ffi::OsString,
41 canonical_path: PathBuf,
42 debounce_delay: Duration,
43 tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
44 last_event_time: Arc<Mutex<Option<Instant>>>,
45) -> impl Fn(std::result::Result<Event, notify::Error>) + Send + 'static {
46 move |result: std::result::Result<Event, notify::Error>| {
47 if let Ok(event) = result {
48 if !matches!(
50 event.kind,
51 notify::EventKind::Modify(_) | notify::EventKind::Create(_)
52 ) {
53 return;
54 }
55
56 let matches_config: bool = event
58 .paths
59 .iter()
60 .any(|p: &PathBuf| p.file_name().map(|f| f == filename).unwrap_or(false));
61
62 if !matches_config {
63 return;
64 }
65
66 let should_send: bool = {
68 let now: Instant = Instant::now();
69 let mut last: parking_lot::MutexGuard<'_, Option<Instant>> = last_event_time.lock();
70 if let Some(last_time) = *last {
71 if now.duration_since(last_time) < debounce_delay {
72 log::trace!("Debouncing config reload event");
73 false
74 } else {
75 *last = Some(now);
76 true
77 }
78 } else {
79 *last = Some(now);
80 true
81 }
82 };
83
84 if should_send {
85 let reload_event = ConfigReloadEvent {
86 path: canonical_path.clone(),
87 };
88 log::info!("Config file changed: {}", reload_event.path.display());
89 if let Err(e) = tx.send(reload_event) {
90 log::error!("Failed to send config reload event: {}", e);
91 }
92 }
93 }
94 }
95}
96
97impl ConfigWatcher {
98 pub fn new(config_path: &Path, debounce_delay_ms: u64) -> Result<Self> {
114 if !config_path.exists() {
115 anyhow::bail!("Config file not found: {}", config_path.display());
116 }
117
118 let canonical: PathBuf = config_path
119 .canonicalize()
120 .unwrap_or_else(|_| config_path.to_path_buf());
121
122 let filename: std::ffi::OsString = canonical
123 .file_name()
124 .context("Config path has no filename")?
125 .to_os_string();
126
127 let parent_dir: PathBuf = canonical
128 .parent()
129 .context("Config path has no parent directory")?
130 .to_path_buf();
131
132 let (tx, rx) = channel::<ConfigReloadEvent>();
133 let debounce_delay: Duration = Duration::from_millis(debounce_delay_ms);
134 let last_event_time: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
135
136 let mut watcher: Box<dyn Watcher + Send> = Self::create_watcher(
138 filename,
139 canonical.clone(),
140 debounce_delay,
141 tx,
142 last_event_time,
143 )?;
144
145 watcher
146 .watch(&parent_dir, RecursiveMode::NonRecursive)
147 .with_context(|| {
148 format!("Failed to watch config directory: {}", parent_dir.display())
149 })?;
150
151 log::info!("Config hot reload: watching {}", canonical.display());
152
153 Ok(Self {
154 _watcher: watcher,
155 event_receiver: rx,
156 })
157 }
158
159 fn create_watcher(
165 filename: std::ffi::OsString,
166 canonical_path: PathBuf,
167 debounce_delay: Duration,
168 tx: std::sync::mpsc::Sender<ConfigReloadEvent>,
169 last_event_time: Arc<Mutex<Option<Instant>>>,
170 ) -> Result<Box<dyn Watcher + Send>> {
171 let filename2 = filename.clone();
173 let canonical_path2 = canonical_path.clone();
174 let debounce_delay2 = debounce_delay;
175 let tx2 = tx.clone();
176 let last_event_time2 = Arc::clone(&last_event_time);
177
178 let handler = make_event_handler(
179 filename,
180 canonical_path,
181 debounce_delay,
182 tx,
183 last_event_time,
184 );
185
186 match notify::recommended_watcher(handler) {
187 Ok(w) => {
188 log::debug!("Config watcher: using native (RecommendedWatcher) backend");
189 Ok(Box::new(w))
190 }
191 Err(e) => {
192 log::warn!(
193 "Config watcher: native backend unavailable ({}); falling back to PollWatcher",
194 e
195 );
196 let fallback_handler = make_event_handler(
197 filename2,
198 canonical_path2,
199 debounce_delay2,
200 tx2,
201 last_event_time2,
202 );
203 let poll_watcher = PollWatcher::new(
204 fallback_handler,
205 NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
206 )
207 .context("Failed to create fallback PollWatcher")?;
208 Ok(Box::new(poll_watcher))
209 }
210 }
211 }
212
213 pub fn try_recv(&self) -> Option<ConfigReloadEvent> {
217 self.event_receiver.try_recv().ok()
218 }
219}
220
221#[cfg(test)]
222mod tests {
223 use super::*;
224 use std::fs;
225 use tempfile::TempDir;
226
227 #[test]
228 fn test_watcher_creation_with_existing_file() {
229 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
230 let config_path: PathBuf = temp_dir.path().join("config.yaml");
231 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
232
233 let result = ConfigWatcher::new(&config_path, 100);
234 assert!(
235 result.is_ok(),
236 "ConfigWatcher should succeed with existing file"
237 );
238 }
239
240 #[test]
241 fn test_watcher_creation_with_nonexistent_file() {
242 let path = PathBuf::from("/tmp/nonexistent_config_watcher_test/config.yaml");
243 let result = ConfigWatcher::new(&path, 100);
244 assert!(
245 result.is_err(),
246 "ConfigWatcher should fail with nonexistent file"
247 );
248 }
249
250 #[test]
251 fn test_no_initial_events() {
252 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
253 let config_path: PathBuf = temp_dir.path().join("config.yaml");
254 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
255
256 let watcher: ConfigWatcher =
257 ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
258
259 assert!(
261 watcher.try_recv().is_none(),
262 "No events should be pending after creation"
263 );
264 }
265
266 #[test]
267 fn test_file_change_detection() {
268 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
269 let config_path: PathBuf = temp_dir.path().join("config.yaml");
270 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
271
272 let watcher: ConfigWatcher =
273 ConfigWatcher::new(&config_path, 50).expect("Failed to create watcher");
274
275 std::thread::sleep(Duration::from_millis(100));
277
278 fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
280
281 std::thread::sleep(Duration::from_millis(700));
283
284 if let Some(event) = watcher.try_recv() {
286 assert!(
287 event.path.ends_with("config.yaml"),
288 "Event path should end with config.yaml"
289 );
290 }
291 }
292
293 #[test]
294 fn test_debug_impl() {
295 let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
296 let config_path: PathBuf = temp_dir.path().join("config.yaml");
297 fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
298
299 let watcher: ConfigWatcher =
300 ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
301
302 let debug_str: String = format!("{:?}", watcher);
303 assert!(
304 debug_str.contains("ConfigWatcher"),
305 "Debug output should contain struct name"
306 );
307 }
308}