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: PollWatcher,
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
35impl ConfigWatcher {
36    /// Create a new config watcher.
37    ///
38    /// # Arguments
39    /// * `config_path` - Path to the config file to watch.
40    /// * `debounce_delay_ms` - Debounce delay in milliseconds to avoid rapid reloads.
41    ///
42    /// # Errors
43    /// Returns an error if the config file doesn't exist or watching fails.
44    pub fn new(config_path: &Path, debounce_delay_ms: u64) -> Result<Self> {
45        if !config_path.exists() {
46            anyhow::bail!("Config file not found: {}", config_path.display());
47        }
48
49        let canonical: PathBuf = config_path
50            .canonicalize()
51            .unwrap_or_else(|_| config_path.to_path_buf());
52
53        let filename: std::ffi::OsString = canonical
54            .file_name()
55            .context("Config path has no filename")?
56            .to_os_string();
57
58        let parent_dir: PathBuf = canonical
59            .parent()
60            .context("Config path has no parent directory")?
61            .to_path_buf();
62
63        let (tx, rx) = channel::<ConfigReloadEvent>();
64        let debounce_delay: Duration = Duration::from_millis(debounce_delay_ms);
65        let last_event_time: Arc<Mutex<Option<Instant>>> = Arc::new(Mutex::new(None));
66        let last_event_clone: Arc<Mutex<Option<Instant>>> = Arc::clone(&last_event_time);
67        let canonical_path: PathBuf = canonical.clone();
68
69        let mut watcher: PollWatcher = PollWatcher::new(
70            move |result: std::result::Result<Event, notify::Error>| {
71                if let Ok(event) = result {
72                    // Only process modify and create events (create handles atomic saves)
73                    if !matches!(
74                        event.kind,
75                        notify::EventKind::Modify(_) | notify::EventKind::Create(_)
76                    ) {
77                        return;
78                    }
79
80                    // Check if any event path matches our config filename
81                    let matches_config: bool = event
82                        .paths
83                        .iter()
84                        .any(|p: &PathBuf| p.file_name().map(|f| f == filename).unwrap_or(false));
85
86                    if !matches_config {
87                        return;
88                    }
89
90                    // Debounce: skip if we sent an event too recently
91                    let should_send: bool = {
92                        let now: Instant = Instant::now();
93                        let mut last: parking_lot::MutexGuard<'_, Option<Instant>> =
94                            last_event_clone.lock();
95                        if let Some(last_time) = *last {
96                            if now.duration_since(last_time) < debounce_delay {
97                                log::trace!("Debouncing config reload event");
98                                false
99                            } else {
100                                *last = Some(now);
101                                true
102                            }
103                        } else {
104                            *last = Some(now);
105                            true
106                        }
107                    };
108
109                    if should_send {
110                        let reload_event = ConfigReloadEvent {
111                            path: canonical_path.clone(),
112                        };
113                        log::info!("Config file changed: {}", reload_event.path.display());
114                        if let Err(e) = tx.send(reload_event) {
115                            log::error!("Failed to send config reload event: {}", e);
116                        }
117                    }
118                }
119            },
120            NotifyConfig::default().with_poll_interval(Duration::from_millis(500)),
121        )
122        .context("Failed to create config file watcher")?;
123
124        watcher
125            .watch(&parent_dir, RecursiveMode::NonRecursive)
126            .with_context(|| {
127                format!("Failed to watch config directory: {}", parent_dir.display())
128            })?;
129
130        log::info!("Config hot reload: watching {}", canonical.display());
131
132        Ok(Self {
133            _watcher: watcher,
134            event_receiver: rx,
135        })
136    }
137
138    /// Check for pending config reload events (non-blocking).
139    ///
140    /// Returns the next reload event if one is available, or `None` if no events are pending.
141    pub fn try_recv(&self) -> Option<ConfigReloadEvent> {
142        self.event_receiver.try_recv().ok()
143    }
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::fs;
150    use tempfile::TempDir;
151
152    #[test]
153    fn test_watcher_creation_with_existing_file() {
154        let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
155        let config_path: PathBuf = temp_dir.path().join("config.yaml");
156        fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
157
158        let result = ConfigWatcher::new(&config_path, 100);
159        assert!(
160            result.is_ok(),
161            "ConfigWatcher should succeed with existing file"
162        );
163    }
164
165    #[test]
166    fn test_watcher_creation_with_nonexistent_file() {
167        let path = PathBuf::from("/tmp/nonexistent_config_watcher_test/config.yaml");
168        let result = ConfigWatcher::new(&path, 100);
169        assert!(
170            result.is_err(),
171            "ConfigWatcher should fail with nonexistent file"
172        );
173    }
174
175    #[test]
176    fn test_no_initial_events() {
177        let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
178        let config_path: PathBuf = temp_dir.path().join("config.yaml");
179        fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
180
181        let watcher: ConfigWatcher =
182            ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
183
184        // Should return None immediately with no events
185        assert!(
186            watcher.try_recv().is_none(),
187            "No events should be pending after creation"
188        );
189    }
190
191    #[test]
192    fn test_file_change_detection() {
193        let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
194        let config_path: PathBuf = temp_dir.path().join("config.yaml");
195        fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
196
197        let watcher: ConfigWatcher =
198            ConfigWatcher::new(&config_path, 50).expect("Failed to create watcher");
199
200        // Give the watcher time to set up
201        std::thread::sleep(Duration::from_millis(100));
202
203        // Modify the file
204        fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
205
206        // Wait for the poll watcher to detect the change
207        std::thread::sleep(Duration::from_millis(700));
208
209        // Check for the reload event (platform-dependent, don't assert failure)
210        if let Some(event) = watcher.try_recv() {
211            assert!(
212                event.path.ends_with("config.yaml"),
213                "Event path should end with config.yaml"
214            );
215        }
216    }
217
218    #[test]
219    fn test_debug_impl() {
220        let temp_dir: TempDir = TempDir::new().expect("Failed to create temp dir");
221        let config_path: PathBuf = temp_dir.path().join("config.yaml");
222        fs::write(&config_path, "font_size: 12.0\n").expect("Failed to write config");
223
224        let watcher: ConfigWatcher =
225            ConfigWatcher::new(&config_path, 100).expect("Failed to create watcher");
226
227        let debug_str: String = format!("{:?}", watcher);
228        assert!(
229            debug_str.contains("ConfigWatcher"),
230            "Debug output should contain struct name"
231        );
232    }
233}