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: PollWatcher,
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
35impl ConfigWatcher {
36 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 if !matches!(
74 event.kind,
75 notify::EventKind::Modify(_) | notify::EventKind::Create(_)
76 ) {
77 return;
78 }
79
80 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 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 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 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 std::thread::sleep(Duration::from_millis(100));
202
203 fs::write(&config_path, "font_size: 14.0\n").expect("Failed to write config");
205
206 std::thread::sleep(Duration::from_millis(700));
208
209 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}