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}
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 #[allow(clippy::too_many_lines)]
165 fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
166 let mut changes: Vec<String> = Vec::new();
167
168 #[cfg(feature = "api-key")]
170 {
171 if self.current_config.auth.api_key.enabled != new_config.auth.api_key.enabled {
172 changes.push(if new_config.auth.api_key.enabled {
173 "API key authentication enabled".to_string()
174 } else {
175 "API key authentication disabled".to_string()
176 });
177 }
178
179 if self.current_config.auth.api_key.keys != new_config.auth.api_key.keys {
180 let old_count = self.current_config.auth.api_key.keys.len();
181 let new_count = new_config.auth.api_key.keys.len();
182 changes.push(format!(
183 "API keys changed: {old_count} keys -> {new_count} keys"
184 ));
185
186 for key in &new_config.auth.api_key.keys {
188 if !self.current_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!(" + Added API key ({})", key_type);
197 }
198 }
199
200 for key in &self.current_config.auth.api_key.keys {
202 if !new_config.auth.api_key.keys.contains(key) {
203 let key_type = if key.starts_with("legacy:") {
204 "Legacy Hash"
205 } else if key.starts_with("$argon2") {
206 "Argon2 Hash"
207 } else {
208 "Plaintext"
209 };
210 info!(" - Removed API key ({})", key_type);
211 }
212 }
213 }
214
215 if self.current_config.auth.api_key.header_name != new_config.auth.api_key.header_name {
216 changes.push(format!(
217 "API key header name changed: {} -> {}",
218 self.current_config.auth.api_key.header_name,
219 new_config.auth.api_key.header_name
220 ));
221 }
222
223 if self.current_config.auth.api_key.allow_query_param
224 != new_config.auth.api_key.allow_query_param
225 {
226 changes.push(format!(
227 "API key query param allowed: {} -> {}",
228 self.current_config.auth.api_key.allow_query_param,
229 new_config.auth.api_key.allow_query_param
230 ));
231 }
232
233 if self.current_config.auth.api_key.key_prefix != new_config.auth.api_key.key_prefix {
234 changes.push(format!(
235 "API key prefix changed: {} -> {}",
236 self.current_config.auth.api_key.key_prefix, new_config.auth.api_key.key_prefix
237 ));
238 }
239 }
240
241 if self.current_config.oauth.enabled != new_config.oauth.enabled {
243 changes.push(if new_config.oauth.enabled {
244 "OAuth authentication enabled".to_string()
245 } else {
246 "OAuth authentication disabled".to_string()
247 });
248 }
249
250 if self.current_config.oauth.client_id != new_config.oauth.client_id {
251 changes.push("OAuth client ID changed".to_string());
252 }
253
254 if self.current_config.oauth.provider != new_config.oauth.provider {
255 changes.push(format!(
256 "OAuth provider changed: {:?} -> {:?}",
257 self.current_config.oauth.provider, new_config.oauth.provider
258 ));
259 }
260
261 if self.current_config.logging.level != new_config.logging.level {
263 changes.push(format!(
264 "Log level changed: {} -> {}",
265 self.current_config.logging.level, new_config.logging.level
266 ));
267 }
268
269 if self.current_config.logging.enable_console != new_config.logging.enable_console {
270 changes.push(format!(
271 "Console logging {}",
272 if new_config.logging.enable_console {
273 "enabled"
274 } else {
275 "disabled"
276 }
277 ));
278 }
279
280 if self.current_config.logging.enable_file != new_config.logging.enable_file {
281 changes.push(format!(
282 "File logging {}",
283 if new_config.logging.enable_file {
284 "enabled"
285 } else {
286 "disabled"
287 }
288 ));
289 }
290
291 if self.current_config.logging.file_path != new_config.logging.file_path {
292 changes.push(format!(
293 "Log file path changed: {:?} -> {:?}",
294 self.current_config.logging.file_path, new_config.logging.file_path
295 ));
296 }
297
298 if self.current_config.logging.max_file_size_mb != new_config.logging.max_file_size_mb {
299 changes.push(format!(
300 "Max log file size changed: {}MB -> {}MB",
301 self.current_config.logging.max_file_size_mb, new_config.logging.max_file_size_mb
302 ));
303 }
304
305 if self.current_config.logging.max_files != new_config.logging.max_files {
306 changes.push(format!(
307 "Max log files changed: {} -> {}",
308 self.current_config.logging.max_files, new_config.logging.max_files
309 ));
310 }
311
312 if self.current_config.cache.default_ttl != new_config.cache.default_ttl {
314 changes.push(format!(
315 "Cache default TTL changed: {:?} -> {:?}",
316 self.current_config.cache.default_ttl, new_config.cache.default_ttl
317 ));
318 }
319
320 if self.current_config.cache.crate_docs_ttl_secs != new_config.cache.crate_docs_ttl_secs {
321 changes.push(format!(
322 "Crate docs cache TTL changed: {:?} -> {:?}",
323 self.current_config.cache.crate_docs_ttl_secs, new_config.cache.crate_docs_ttl_secs
324 ));
325 }
326
327 if self.current_config.cache.item_docs_ttl_secs != new_config.cache.item_docs_ttl_secs {
328 changes.push(format!(
329 "Item docs cache TTL changed: {:?} -> {:?}",
330 self.current_config.cache.item_docs_ttl_secs, new_config.cache.item_docs_ttl_secs
331 ));
332 }
333
334 if self.current_config.cache.search_results_ttl_secs
335 != new_config.cache.search_results_ttl_secs
336 {
337 changes.push(format!(
338 "Search results cache TTL changed: {:?} -> {:?}",
339 self.current_config.cache.search_results_ttl_secs,
340 new_config.cache.search_results_ttl_secs
341 ));
342 }
343
344 if self.current_config.performance.rate_limit_per_second
346 != new_config.performance.rate_limit_per_second
347 {
348 changes.push(format!(
349 "Rate limit changed: {} -> {} req/s",
350 self.current_config.performance.rate_limit_per_second,
351 new_config.performance.rate_limit_per_second
352 ));
353 }
354
355 if self.current_config.performance.concurrent_request_limit
356 != new_config.performance.concurrent_request_limit
357 {
358 changes.push(format!(
359 "Concurrent request limit changed: {} -> {}",
360 self.current_config.performance.concurrent_request_limit,
361 new_config.performance.concurrent_request_limit
362 ));
363 }
364
365 if self.current_config.performance.enable_metrics != new_config.performance.enable_metrics {
366 changes.push(format!(
367 "Prometheus metrics {}",
368 if new_config.performance.enable_metrics {
369 "enabled"
370 } else {
371 "disabled"
372 }
373 ));
374 }
375
376 if self.current_config.performance.enable_response_compression
377 != new_config.performance.enable_response_compression
378 {
379 changes.push(format!(
380 "Response compression {}",
381 if new_config.performance.enable_response_compression {
382 "enabled"
383 } else {
384 "disabled"
385 }
386 ));
387 }
388
389 let mut restart_required = false;
392
393 if self.current_config.server.host != new_config.server.host {
394 changes.push(format!(
395 "[RESTART REQUIRED] Server host changed: {} -> {}",
396 self.current_config.server.host, new_config.server.host
397 ));
398 restart_required = true;
399 }
400
401 if self.current_config.server.port != new_config.server.port {
402 changes.push(format!(
403 "[RESTART REQUIRED] Server port changed: {} -> {}",
404 self.current_config.server.port, new_config.server.port
405 ));
406 restart_required = true;
407 }
408
409 if self.current_config.server.transport_mode != new_config.server.transport_mode {
410 changes.push(format!(
411 "[RESTART REQUIRED] Transport mode changed: {} -> {}",
412 self.current_config.server.transport_mode, new_config.server.transport_mode
413 ));
414 restart_required = true;
415 }
416
417 if self.current_config.server.max_connections != new_config.server.max_connections {
418 changes.push(format!(
419 "[RESTART REQUIRED] Max connections changed: {} -> {}",
420 self.current_config.server.max_connections, new_config.server.max_connections
421 ));
422 restart_required = true;
423 }
424
425 if restart_required {
426 warn!("Some configuration changes require server restart to take effect");
427 }
428
429 if changes.is_empty() {
430 ConfigChange::NoChange
431 } else {
432 ConfigChange::Changed {
433 changes,
434 new_config: Box::new(new_config.clone()),
435 }
436 }
437 }
438
439 #[must_use]
441 pub fn current_config(&self) -> &AppConfig {
442 &self.current_config
443 }
444
445 pub fn stop(mut self) {
447 let _ = self.watcher.unwatch(&self.config_path);
448 }
449}
450
451#[derive(Debug, Clone)]
453pub enum ConfigChange {
454 NoChange,
456 Changed {
458 changes: Vec<String>,
460 new_config: Box<AppConfig>,
462 },
463}
464
465impl ConfigChange {
466 #[must_use]
468 pub fn is_changed(&self) -> bool {
469 matches!(self, ConfigChange::Changed { .. })
470 }
471
472 #[must_use]
474 pub fn new_config(&self) -> Option<&AppConfig> {
475 match self {
476 ConfigChange::Changed { new_config, .. } => Some(new_config),
477 ConfigChange::NoChange => None,
478 }
479 }
480
481 #[must_use]
483 pub fn changes(&self) -> Option<&[String]> {
484 match self {
485 ConfigChange::Changed { changes, .. } => Some(changes),
486 ConfigChange::NoChange => None,
487 }
488 }
489}
490
491#[cfg(test)]
492mod tests {
493 use super::*;
494 use std::io::Write;
495 use tempfile::NamedTempFile;
496
497 #[test]
498 fn test_config_change_detection_no_change() {
499 let config1 = AppConfig::default();
500 let config2 = AppConfig::default();
501
502 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
504 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
505 temp_file.flush().expect("Failed to flush temp file");
506
507 let temp_path = temp_file.path();
508
509 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
512 .expect("Failed to create reloader");
513
514 let change = reloader.detect_changes(&config2);
515 assert!(matches!(change, ConfigChange::NoChange));
516 }
517
518 #[test]
519 #[cfg(feature = "api-key")]
520 fn test_config_change_detection_api_key_change() {
521 let config1 = AppConfig::default();
522 let mut config2 = AppConfig::default();
523
524 config2.auth.api_key.keys.push("test_key".to_string());
526
527 let mut temp_file = NamedTempFile::new().expect("Failed to create temp file");
529 writeln!(temp_file, "[server]").expect("Failed to write to temp file");
530 temp_file.flush().expect("Failed to flush temp file");
531
532 let temp_path = temp_file.path();
533
534 let reloader = ConfigReloader::new(Arc::from(temp_path.to_path_buf()), config1.clone())
535 .expect("Failed to create reloader");
536
537 let change = reloader.detect_changes(&config2);
538 assert!(matches!(change, ConfigChange::Changed { .. }));
539
540 if let ConfigChange::Changed { changes, .. } = change {
541 assert!(!changes.is_empty());
542 assert!(changes[0].contains("API keys changed"));
543 }
544 }
545
546 #[test]
547 fn test_config_change_is_changed() {
548 assert!(!ConfigChange::NoChange.is_changed());
549
550 let change = ConfigChange::Changed {
551 changes: vec!["test".to_string()],
552 new_config: Box::new(AppConfig::default()),
553 };
554 assert!(change.is_changed());
555 }
556
557 #[test]
558 fn test_config_change_new_config() {
559 let change = ConfigChange::NoChange;
560 assert!(change.new_config().is_none());
561
562 let config = AppConfig::default();
563 let change = ConfigChange::Changed {
564 changes: vec!["test".to_string()],
565 new_config: Box::new(config.clone()),
566 };
567 assert!(change.new_config().is_some());
568 }
569
570 #[test]
571 fn test_config_change_changes() {
572 let change = ConfigChange::NoChange;
573 assert!(change.changes().is_none());
574
575 let change = ConfigChange::Changed {
576 changes: vec!["test".to_string()],
577 new_config: Box::new(AppConfig::default()),
578 };
579 assert!(change.changes().is_some());
580 assert_eq!(change.changes().unwrap().len(), 1);
581 }
582}