1use 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 watcher_disconnected: bool,
32}
33
34impl ConfigReloader {
35 pub fn new(
46 config_path: Arc<Path>,
47 current_config: AppConfig,
48 ) -> Result<Self, Box<dyn std::error::Error>> {
49 let (sender, receiver) = channel();
50
51 let mut watcher = notify::recommended_watcher(move |res: Result<Event, notify::Error>| {
53 if let Err(e) = sender.send(res) {
54 error!("Failed to send file system event: {}", e);
55 }
56 })?;
57
58 watcher.watch(&config_path, RecursiveMode::NonRecursive)?;
60
61 info!(
62 "Configuration hot-reload enabled, watching: {}",
63 config_path.display()
64 );
65
66 Ok(Self {
67 config_path,
68 watcher,
69 receiver,
70 current_config,
71 last_reload: std::time::Instant::now()
72 .checked_sub(Duration::from_secs(10))
73 .unwrap_or_else(std::time::Instant::now),
74 watcher_disconnected: false,
75 })
76 }
77
78 pub fn check_for_changes(&mut self) -> Option<ConfigChange> {
87 match self.receiver.try_recv() {
89 Ok(Ok(event)) => {
90 if matches!(
92 event.kind,
93 EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any
94 ) {
95 if self.last_reload.elapsed() < Duration::from_secs(1) {
97 return None;
98 }
99
100 self.last_reload = std::time::Instant::now();
101
102 info!("Configuration file changed, reloading...");
103
104 match self.reload_config() {
106 Ok(change) => {
107 return Some(change);
108 }
109 Err(e) => {
110 error!("Failed to reload configuration: {}", e);
111 return None;
112 }
113 }
114 }
115 }
116 Ok(Err(e)) => {
117 warn!("File system watcher error: {}", e);
118 }
119 Err(std::sync::mpsc::TryRecvError::Empty) => {
120 }
122 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
123 if !self.watcher_disconnected {
128 warn!(
129 "File system watcher disconnected; configuration hot-reload is now inactive (will not retry)"
130 );
131 self.watcher_disconnected = true;
132 }
133 }
134 }
135
136 None
137 }
138
139 #[must_use]
143 pub fn is_watcher_alive(&self) -> bool {
144 !self.watcher_disconnected
145 }
146
147 fn reload_config(&mut self) -> Result<ConfigChange, Box<dyn std::error::Error>> {
153 let new_config = AppConfig::from_file(&self.config_path)?;
154
155 let change = self.detect_changes(&new_config);
157
158 self.current_config = new_config;
160
161 Ok(change)
162 }
163
164 #[allow(clippy::too_many_lines)]
187 fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
188 let mut changes: Vec<String> = Vec::new();
189
190 #[cfg(feature = "api-key")]
192 {
193 if self.current_config.auth.api_key.enabled != new_config.auth.api_key.enabled {
194 changes.push(if new_config.auth.api_key.enabled {
195 "API key authentication enabled".to_string()
196 } else {
197 "API key authentication disabled".to_string()
198 });
199 }
200
201 if self.current_config.auth.api_key.keys != new_config.auth.api_key.keys {
202 let old_count = self.current_config.auth.api_key.keys.len();
203 let new_count = new_config.auth.api_key.keys.len();
204 changes.push(format!(
205 "API keys changed: {old_count} keys -> {new_count} keys"
206 ));
207
208 for key in &new_config.auth.api_key.keys {
210 if !self.current_config.auth.api_key.keys.contains(key) {
211 let key_type = if key.starts_with("legacy:") {
212 "Legacy Hash"
213 } else if key.starts_with("$argon2") {
214 "Argon2 Hash"
215 } else {
216 "Plaintext"
217 };
218 info!(" + Added API key ({})", key_type);
219 }
220 }
221
222 for key in &self.current_config.auth.api_key.keys {
224 if !new_config.auth.api_key.keys.contains(key) {
225 let key_type = if key.starts_with("legacy:") {
226 "Legacy Hash"
227 } else if key.starts_with("$argon2") {
228 "Argon2 Hash"
229 } else {
230 "Plaintext"
231 };
232 info!(" - Removed API key ({})", key_type);
233 }
234 }
235 }
236
237 if self.current_config.auth.api_key.header_name != new_config.auth.api_key.header_name {
238 changes.push(format!(
239 "API key header name changed: {} -> {}",
240 self.current_config.auth.api_key.header_name,
241 new_config.auth.api_key.header_name
242 ));
243 }
244
245 if self.current_config.auth.api_key.allow_query_param
246 != new_config.auth.api_key.allow_query_param
247 {
248 changes.push(format!(
249 "API key query param allowed: {} -> {}",
250 self.current_config.auth.api_key.allow_query_param,
251 new_config.auth.api_key.allow_query_param
252 ));
253 }
254
255 if self.current_config.auth.api_key.key_prefix != new_config.auth.api_key.key_prefix {
256 changes.push(format!(
257 "API key prefix changed: {} -> {}",
258 self.current_config.auth.api_key.key_prefix, new_config.auth.api_key.key_prefix
259 ));
260 }
261 }
262
263 if self.current_config.oauth.enabled != new_config.oauth.enabled {
265 changes.push(if new_config.oauth.enabled {
266 "OAuth authentication enabled".to_string()
267 } else {
268 "OAuth authentication disabled".to_string()
269 });
270 }
271
272 if self.current_config.oauth.client_id != new_config.oauth.client_id {
273 changes.push("OAuth client ID changed".to_string());
274 }
275
276 if self.current_config.oauth.provider != new_config.oauth.provider {
277 changes.push(format!(
278 "OAuth provider changed: {:?} -> {:?}",
279 self.current_config.oauth.provider, new_config.oauth.provider
280 ));
281 }
282
283 if self.current_config.logging.level != new_config.logging.level {
285 changes.push(format!(
286 "Log level changed: {} -> {}",
287 self.current_config.logging.level, new_config.logging.level
288 ));
289 }
290
291 if self.current_config.logging.enable_console != new_config.logging.enable_console {
292 changes.push(format!(
293 "Console logging {}",
294 if new_config.logging.enable_console {
295 "enabled"
296 } else {
297 "disabled"
298 }
299 ));
300 }
301
302 if self.current_config.logging.enable_file != new_config.logging.enable_file {
303 changes.push(format!(
304 "File logging {}",
305 if new_config.logging.enable_file {
306 "enabled"
307 } else {
308 "disabled"
309 }
310 ));
311 }
312
313 if self.current_config.logging.file_path != new_config.logging.file_path {
314 changes.push(format!(
315 "Log file path changed: {:?} -> {:?}",
316 self.current_config.logging.file_path, new_config.logging.file_path
317 ));
318 }
319
320 if self.current_config.logging.max_file_size_mb != new_config.logging.max_file_size_mb {
321 changes.push(format!(
322 "Max log file size changed: {}MB -> {}MB",
323 self.current_config.logging.max_file_size_mb, new_config.logging.max_file_size_mb
324 ));
325 }
326
327 if self.current_config.logging.max_files != new_config.logging.max_files {
328 changes.push(format!(
329 "Max log files changed: {} -> {}",
330 self.current_config.logging.max_files, new_config.logging.max_files
331 ));
332 }
333
334 if self.current_config.cache.default_ttl != new_config.cache.default_ttl {
336 changes.push(format!(
337 "Cache default TTL changed: {:?} -> {:?}",
338 self.current_config.cache.default_ttl, new_config.cache.default_ttl
339 ));
340 }
341
342 if self.current_config.cache.crate_docs_ttl_secs != new_config.cache.crate_docs_ttl_secs {
343 changes.push(format!(
344 "Crate docs cache TTL changed: {:?} -> {:?}",
345 self.current_config.cache.crate_docs_ttl_secs, new_config.cache.crate_docs_ttl_secs
346 ));
347 }
348
349 if self.current_config.cache.item_docs_ttl_secs != new_config.cache.item_docs_ttl_secs {
350 changes.push(format!(
351 "Item docs cache TTL changed: {:?} -> {:?}",
352 self.current_config.cache.item_docs_ttl_secs, new_config.cache.item_docs_ttl_secs
353 ));
354 }
355
356 if self.current_config.cache.search_results_ttl_secs
357 != new_config.cache.search_results_ttl_secs
358 {
359 changes.push(format!(
360 "Search results cache TTL changed: {:?} -> {:?}",
361 self.current_config.cache.search_results_ttl_secs,
362 new_config.cache.search_results_ttl_secs
363 ));
364 }
365
366 if self.current_config.performance.rate_limit_per_second
368 != new_config.performance.rate_limit_per_second
369 {
370 changes.push(format!(
371 "Rate limit changed: {} -> {} req/s",
372 self.current_config.performance.rate_limit_per_second,
373 new_config.performance.rate_limit_per_second
374 ));
375 }
376
377 if self.current_config.performance.concurrent_request_limit
378 != new_config.performance.concurrent_request_limit
379 {
380 changes.push(format!(
381 "Concurrent request limit changed: {} -> {}",
382 self.current_config.performance.concurrent_request_limit,
383 new_config.performance.concurrent_request_limit
384 ));
385 }
386
387 if self.current_config.performance.enable_metrics != new_config.performance.enable_metrics {
388 changes.push(format!(
389 "Prometheus metrics {}",
390 if new_config.performance.enable_metrics {
391 "enabled"
392 } else {
393 "disabled"
394 }
395 ));
396 }
397
398 if self.current_config.performance.enable_response_compression
399 != new_config.performance.enable_response_compression
400 {
401 changes.push(format!(
402 "Response compression {}",
403 if new_config.performance.enable_response_compression {
404 "enabled"
405 } else {
406 "disabled"
407 }
408 ));
409 }
410
411 let mut restart_required = false;
414
415 if self.current_config.server.host != new_config.server.host {
416 changes.push(format!(
417 "[RESTART REQUIRED] Server host changed: {} -> {}",
418 self.current_config.server.host, new_config.server.host
419 ));
420 restart_required = true;
421 }
422
423 if self.current_config.server.port != new_config.server.port {
424 changes.push(format!(
425 "[RESTART REQUIRED] Server port changed: {} -> {}",
426 self.current_config.server.port, new_config.server.port
427 ));
428 restart_required = true;
429 }
430
431 if self.current_config.server.transport_mode != new_config.server.transport_mode {
432 changes.push(format!(
433 "[RESTART REQUIRED] Transport mode changed: {} -> {}",
434 self.current_config.server.transport_mode, new_config.server.transport_mode
435 ));
436 restart_required = true;
437 }
438
439 if self.current_config.server.max_connections != new_config.server.max_connections {
440 changes.push(format!(
441 "[RESTART REQUIRED] Max connections changed: {} -> {}",
442 self.current_config.server.max_connections, new_config.server.max_connections
443 ));
444 restart_required = true;
445 }
446
447 if restart_required {
448 warn!("Some configuration changes require server restart to take effect");
449 }
450
451 if changes.is_empty() {
452 ConfigChange::NoChange
453 } else {
454 ConfigChange::Changed {
455 changes,
456 new_config: Box::new(new_config.clone()),
457 }
458 }
459 }
460
461 #[must_use]
463 pub fn current_config(&self) -> &AppConfig {
464 &self.current_config
465 }
466
467 pub fn stop(mut self) {
469 let _ = self.watcher.unwatch(&self.config_path);
470 }
471}
472
473#[derive(Debug, Clone)]
475pub enum ConfigChange {
476 NoChange,
478 Changed {
480 changes: Vec<String>,
482 new_config: Box<AppConfig>,
484 },
485}
486
487impl ConfigChange {
488 #[must_use]
490 pub fn is_changed(&self) -> bool {
491 matches!(self, ConfigChange::Changed { .. })
492 }
493
494 #[must_use]
496 pub fn new_config(&self) -> Option<&AppConfig> {
497 match self {
498 ConfigChange::Changed { new_config, .. } => Some(new_config),
499 ConfigChange::NoChange => None,
500 }
501 }
502
503 #[must_use]
505 pub fn changes(&self) -> Option<&[String]> {
506 match self {
507 ConfigChange::Changed { changes, .. } => Some(changes),
508 ConfigChange::NoChange => None,
509 }
510 }
511}
512
513#[cfg(test)]
514mod tests {
515 use super::*;
516 use std::io::Write;
517 use tempfile::NamedTempFile;
518
519 #[test]
520 fn test_is_watcher_alive_true_after_creation() {
521 let config = AppConfig::default();
522 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
523 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
524 temp_file.flush().expect("Failed to flush temp file");
525
526 let reloader = ConfigReloader::new(Arc::from(temp_file.path().to_path_buf()), config)
527 .expect("Failed to create reloader");
528
529 assert!(reloader.is_watcher_alive());
532 }
533
534 #[test]
535 fn test_config_change_detection_no_change() {
536 let config1 = AppConfig::default();
537 let config2 = AppConfig::default();
538
539 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
541 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
542 temp_file.flush().expect("Failed to flush temp file");
543
544 let temp_path = temp_file.path();
545
546 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
549 .expect("Failed to create reloader");
550
551 let change = reloader.detect_changes(&config2);
552 assert!(matches!(change, ConfigChange::NoChange));
553 }
554
555 #[test]
556 #[cfg(feature = "api-key")]
557 fn test_config_change_detection_api_key_change() {
558 let config1 = AppConfig::default();
559 let mut config2 = AppConfig::default();
560
561 config2.auth.api_key.keys.push("test_key".to_string());
563
564 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
566 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
567 temp_file.flush().expect("Failed to flush temp file");
568
569 let temp_path = temp_file.path();
570
571 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
572 .expect("Failed to create reloader");
573
574 let change = reloader.detect_changes(&config2);
575 assert!(matches!(change, ConfigChange::Changed { .. }));
576
577 if let ConfigChange::Changed { changes, .. } = change {
578 assert!(!changes.is_empty());
579 assert!(changes[0].contains("API keys changed"));
580 }
581 }
582
583 #[test]
584 fn test_config_change_is_changed() {
585 assert!(!ConfigChange::NoChange.is_changed());
586
587 let change = ConfigChange::Changed {
588 changes: vec!["test".to_string()],
589 new_config: Box::new(AppConfig::default()),
590 };
591 assert!(change.is_changed());
592 }
593
594 #[test]
595 fn test_config_change_new_config() {
596 let change = ConfigChange::NoChange;
597 assert!(change.new_config().is_none());
598
599 let config = AppConfig::default();
600 let change = ConfigChange::Changed {
601 changes: vec!["test".to_string()],
602 new_config: Box::new(config.clone()),
603 };
604 assert!(change.new_config().is_some());
605 }
606
607 #[test]
608 fn test_config_change_changes() {
609 let change = ConfigChange::NoChange;
610 assert!(change.changes().is_none());
611
612 let change = ConfigChange::Changed {
613 changes: vec!["test".to_string()],
614 new_config: Box::new(AppConfig::default()),
615 };
616 assert!(change.changes().is_some());
617 assert_eq!(change.changes().unwrap().len(), 1);
618 }
619}