crates_docs/
config_reload.rs1use crate::config::AppConfig;
7use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
8use std::path::Path;
9use std::sync::mpsc::{channel, Receiver};
10use std::sync::Arc;
11use std::time::Duration;
12use tracing::{error, info, warn};
13
14pub struct ConfigReloader {
18 config_path: Arc<Path>,
20 watcher: RecommendedWatcher,
22 receiver: Receiver<Result<Event, notify::Error>>,
24 current_config: AppConfig,
26 last_reload: std::time::Instant,
28}
29
30impl ConfigReloader {
31 pub fn new(
42 config_path: Arc<Path>,
43 current_config: AppConfig,
44 ) -> Result<Self, Box<dyn std::error::Error>> {
45 let (sender, receiver) = channel();
46
47 let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
49 if let Err(e) = sender.send(res) {
50 error!("Failed to send file system event: {}", e);
51 }
52 })?;
53
54 watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
56
57 info!(
58 "Configuration hot-reload enabled, watching: {}",
59 config_path.display()
60 );
61
62 Ok(Self {
63 config_path,
64 watcher,
65 receiver,
66 current_config,
67 last_reload: std::time::Instant::now()
68 .checked_sub(Duration::from_secs(10))
69 .unwrap_or_else(std::time::Instant::now),
70 })
71 }
72
73 pub fn check_for_changes(&mut self) -> Option<ConfigChange> {
82 match self.receiver.try_recv() {
84 Ok(Ok(event)) => {
85 if matches!(
87 event.kind,
88 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any
89 ) {
90 if self.last_reload.elapsed() < Duration::from_secs(1) {
92 return None;
93 }
94
95 self.last_reload = std::time::Instant::now();
96
97 info!("Configuration file changed, reloading...");
98
99 match self.reload_config() {
101 Ok(change) => {
102 return Some(change);
103 }
104 Err(e) => {
105 error!("Failed to reload configuration: {}", e);
106 return None;
107 }
108 }
109 }
110 }
111 Ok(Err(e)) => {
112 warn!("File system watcher error: {}", e);
113 }
114 Err(std::sync::mpsc::TryRecvError::Empty) => {
115 }
117 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
118 warn!("File system watcher disconnected");
119 }
120 }
121
122 None
123 }
124
125 fn reload_config(&mut self) -> Result<ConfigChange, Box<dyn std::error::Error>> {
131 let new_config = AppConfig::from_file(&self.config_path)?;
132
133 let change = self.detect_changes(&new_config);
135
136 self.current_config = new_config;
138
139 Ok(change)
140 }
141
142 fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
152 let mut changes: Vec<String> = Vec::new();
153
154 #[cfg(feature = "api-key")]
156 {
157 if self.current_config.auth.api_key.enabled != new_config.auth.api_key.enabled {
158 changes.push(if new_config.auth.api_key.enabled {
159 "API key authentication enabled".to_string()
160 } else {
161 "API key authentication disabled".to_string()
162 });
163 }
164
165 if self.current_config.auth.api_key.keys != new_config.auth.api_key.keys {
166 let old_count = self.current_config.auth.api_key.keys.len();
167 let new_count = new_config.auth.api_key.keys.len();
168 changes.push(format!(
169 "API keys changed: {old_count} keys -> {new_count} keys"
170 ));
171
172 for key in &new_config.auth.api_key.keys {
174 if !self.current_config.auth.api_key.keys.contains(key) {
175 let key_type = if key.starts_with("legacy:") {
176 "Legacy Hash"
177 } else if key.starts_with("$argon2") {
178 "Argon2 Hash"
179 } else {
180 "Plaintext"
181 };
182 info!(" + Added API key ({})", key_type);
183 }
184 }
185
186 for key in &self.current_config.auth.api_key.keys {
188 if !new_config.auth.api_key.keys.contains(key) {
189 let key_type = if key.starts_with("legacy:") {
190 "Legacy Hash"
191 } else if key.starts_with("$argon2") {
192 "Argon2 Hash"
193 } else {
194 "Plaintext"
195 };
196 info!(" - Removed API key ({})", key_type);
197 }
198 }
199 }
200
201 if self.current_config.auth.api_key.header_name != new_config.auth.api_key.header_name {
202 changes.push(format!(
203 "API key header name changed: {} -> {}",
204 self.current_config.auth.api_key.header_name,
205 new_config.auth.api_key.header_name
206 ));
207 }
208
209 if self.current_config.auth.api_key.allow_query_param
210 != new_config.auth.api_key.allow_query_param
211 {
212 changes.push(format!(
213 "API key query param allowed: {} -> {}",
214 self.current_config.auth.api_key.allow_query_param,
215 new_config.auth.api_key.allow_query_param
216 ));
217 }
218
219 if self.current_config.auth.api_key.key_prefix != new_config.auth.api_key.key_prefix {
220 changes.push(format!(
221 "API key prefix changed: {} -> {}",
222 self.current_config.auth.api_key.key_prefix, new_config.auth.api_key.key_prefix
223 ));
224 }
225 }
226
227 if self.current_config.server.host != new_config.server.host {
229 changes.push(format!(
230 "Server host changed: {} -> {}",
231 self.current_config.server.host, new_config.server.host
232 ));
233 }
234
235 if self.current_config.server.port != new_config.server.port {
236 changes.push(format!(
237 "Server port changed: {} -> {}",
238 self.current_config.server.port, new_config.server.port
239 ));
240 }
241
242 if self.current_config.cache.default_ttl != new_config.cache.default_ttl {
244 changes.push(format!(
245 "Cache TTL changed: {:?} -> {:?}",
246 self.current_config.cache.default_ttl, new_config.cache.default_ttl
247 ));
248 }
249
250 if changes.is_empty() {
251 ConfigChange::NoChange
252 } else {
253 ConfigChange::Changed {
254 changes,
255 new_config: Box::new(new_config.clone()),
256 }
257 }
258 }
259
260 #[must_use]
262 pub fn current_config(&self) -> &AppConfig {
263 &self.current_config
264 }
265
266 pub fn stop(mut self) {
268 let _ = self.watcher.unwatch(&self.config_path);
269 }
270}
271
272#[derive(Debug, Clone)]
274pub enum ConfigChange {
275 NoChange,
277 Changed {
279 changes: Vec<String>,
281 new_config: Box<AppConfig>,
283 },
284}
285
286impl ConfigChange {
287 #[must_use]
289 pub fn is_changed(&self) -> bool {
290 matches!(self, ConfigChange::Changed { .. })
291 }
292
293 #[must_use]
295 pub fn new_config(&self) -> Option<&AppConfig> {
296 match self {
297 ConfigChange::Changed { new_config, .. } => Some(new_config),
298 ConfigChange::NoChange => None,
299 }
300 }
301
302 #[must_use]
304 pub fn changes(&self) -> Option<&[String]> {
305 match self {
306 ConfigChange::Changed { changes, .. } => Some(changes),
307 ConfigChange::NoChange => None,
308 }
309 }
310}
311
312#[cfg(test)]
313mod tests {
314 use super::*;
315 use std::io::Write;
316 use tempfile::NamedTempFile;
317
318 #[test]
319 fn test_config_change_detection_no_change() {
320 let config1 = AppConfig::default();
321 let config2 = AppConfig::default();
322
323 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
325 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
326 temp_file.flush().expect("Failed to flush temp file");
327
328 let temp_path = temp_file.path();
329
330 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
333 .expect("Failed to create reloader");
334
335 let change = reloader.detect_changes(&config2);
336 assert!(matches!(change, ConfigChange::NoChange));
337 }
338
339 #[test]
340 #[cfg(feature = "api-key")]
341 fn test_config_change_detection_api_key_change() {
342 let config1 = AppConfig::default();
343 let mut config2 = AppConfig::default();
344
345 config2.auth.api_key.keys.push("test_key".to_string());
347
348 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
350 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
351 temp_file.flush().expect("Failed to flush temp file");
352
353 let temp_path = temp_file.path();
354
355 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
356 .expect("Failed to create reloader");
357
358 let change = reloader.detect_changes(&config2);
359 assert!(matches!(change, ConfigChange::Changed { .. }));
360
361 if let ConfigChange::Changed { changes, .. } = change {
362 assert!(!changes.is_empty());
363 assert!(changes[0].contains("API keys changed"));
364 }
365 }
366
367 #[test]
368 fn test_config_change_is_changed() {
369 assert!(!ConfigChange::NoChange.is_changed());
370
371 let change = ConfigChange::Changed {
372 changes: vec!["test".to_string()],
373 new_config: Box::new(AppConfig::default()),
374 };
375 assert!(change.is_changed());
376 }
377
378 #[test]
379 fn test_config_change_new_config() {
380 let change = ConfigChange::NoChange;
381 assert!(change.new_config().is_none());
382
383 let config = AppConfig::default();
384 let change = ConfigChange::Changed {
385 changes: vec!["test".to_string()],
386 new_config: Box::new(config.clone()),
387 };
388 assert!(change.new_config().is_some());
389 }
390
391 #[test]
392 fn test_config_change_changes() {
393 let change = ConfigChange::NoChange;
394 assert!(change.changes().is_none());
395
396 let change = ConfigChange::Changed {
397 changes: vec!["test".to_string()],
398 new_config: Box::new(AppConfig::default()),
399 };
400 assert!(change.changes().is_some());
401 assert_eq!(change.changes().unwrap().len(), 1);
402 }
403}