Skip to main content

par_term_config/
watcher.rs

1//! Config file watcher for automatic reload.
2//!
3//! Watches the config.yaml file for changes and triggers automatic reloading.
4//! Uses debouncing to avoid multiple reloads during rapid saves from editors.
5
6use 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/// Event indicating the config file has changed and needs reloading.
15#[derive(Debug, Clone)]
16pub struct ConfigReloadEvent {
17    /// Path to the config file that changed.
18    pub path: PathBuf,
19}
20
21/// Watches the config file for changes and sends reload events.
22pub struct ConfigWatcher {
23    /// The file system watcher (kept alive to maintain watching).
24    _watcher: Box<dyn Watcher + Send>,
25    /// Receiver for config change events.
26    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
35/// Build the shared event-handler closure used by both watcher backends.
36///
37/// Returns a closure that filters events to the given `filename`, applies
38/// debouncing, and sends `ConfigReloadEvent` values on `tx`.
39fn 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            // Only process modify and create events (create handles atomic saves)
49            if !matches!(
50                event.kind,
51                notify::EventKind::Modify(_) | notify::EventKind::Create(_)
52            ) {
53                return;
54            }
55
56            // Check if any event path matches our config filename
57            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            // Debounce: skip if we sent an event too recently
67            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    /// Create a new config watcher.
99    ///
100    /// Attempts to use the platform's native watcher (`RecommendedWatcher`: inotify on
101    /// Linux, kqueue on macOS, ReadDirectoryChanges on Windows) for low-latency,
102    /// event-driven notifications. If the native backend fails to initialise (e.g.
103    /// inside a container or on a network filesystem), falls back to a `PollWatcher`
104    /// that checks for changes every 500 ms.
105    ///
106    /// # Arguments
107    /// * `config_path` - Path to the config file to watch.
108    /// * `debounce_delay_ms` - Debounce delay in milliseconds to avoid rapid reloads.
109    ///
110    /// # Errors
111    /// Returns an error if the config file doesn't exist or watching fails on both
112    /// backends.
113    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        // Try the platform-native watcher first; fall back to PollWatcher on failure.
137        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    /// Try to create the best available watcher backend.
160    ///
161    /// Attempts `RecommendedWatcher` first. If that fails (e.g. inside a
162    /// container, network filesystem, or restricted environment), logs a warning
163    /// and falls back to `PollWatcher` with a 500 ms poll interval.
164    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        // Build the shared handler (clone inputs for the fallback path).
172        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    /// Check for pending config reload events (non-blocking).
214    ///
215    /// Returns the next reload event if one is available, or `None` if no events are pending.
216    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        // Should return None immediately with no events
260        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        // Give the watcher time to set up
276        std::thread::sleep(Duration::from_millis(100));
277
278        // Modify the file
279        fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
280
281        // Wait for the watcher to detect the change (native is faster; poll takes up to 500ms)
282        std::thread::sleep(Duration::from_millis(700));
283
284        // Check for the reload event (platform-dependent, don't assert failure)
285        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}