Skip to main content

crates_docs/
config_reload.rs

1//! Configuration hot-reload functionality
2//!
3//! Provides file watching and configuration reloading capabilities for
4//! runtime configuration updates without server restart.
5
6use 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
14/// Configuration reloader for hot-reload support
15///
16/// Watches configuration file for changes and notifies when reload is needed.
17pub struct ConfigReloader {
18    /// Path to the configuration file
19    config_path: Arc<Path>,
20    /// File system watcher
21    watcher: RecommendedWatcher,
22    /// Event receiver
23    receiver: Receiver<Result<Event, notify::Error>>,
24    /// Current configuration (for comparison)
25    current_config: AppConfig,
26    /// Debounce timer to avoid rapid reloads
27    last_reload: std::time::Instant,
28    /// Set once the file-system watcher channel has disconnected. The watcher
29    /// cannot recover after this, so it is used to stop the polling loop and to
30    /// log the disconnect exactly once instead of every poll.
31    watcher_disconnected: bool,
32}
33
34impl ConfigReloader {
35    /// Create a new configuration reloader
36    ///
37    /// # Arguments
38    ///
39    /// * `config_path` - Path to the configuration file
40    /// * `current_config` - Current configuration for comparison
41    ///
42    /// # Errors
43    ///
44    /// Returns an error if the watcher cannot be created
45    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        // Create file watcher
52        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        // Watch the configuration file
59        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    /// Check for configuration changes
79    ///
80    /// This method should be called periodically in the event loop.
81    ///
82    /// # Returns
83    ///
84    /// Returns `Some(new_config)` if the configuration has changed and should be reloaded,
85    /// or `None` if no changes were detected.
86    pub fn check_for_changes(&mut self) -> Option<ConfigChange> {
87        // Check for file system events (non-blocking)
88        match self.receiver.try_recv() {
89            Ok(Ok(event)) => {
90                // Only process modification events
91                if matches!(
92                    event.kind,
93                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any
94                ) {
95                    // Debounce: only reload once per second
96                    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                    // Try to reload the configuration
105                    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                // No events available
121            }
122            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
123                // The watcher thread is gone and will not come back. Log once
124                // and latch the state so the caller can stop polling rather
125                // than spinning here forever and spamming this warning every
126                // tick.
127                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    /// Returns `true` while the underlying file-system watcher is still
140    /// connected. Once it returns `false`, `check_for_changes` will never
141    /// detect further changes and the polling loop should stop.
142    #[must_use]
143    pub fn is_watcher_alive(&self) -> bool {
144        !self.watcher_disconnected
145    }
146
147    /// Reload configuration from file
148    ///
149    /// # Errors
150    ///
151    /// Returns an error if the configuration cannot be loaded or parsed
152    fn reload_config(&mut self) -> Result<ConfigChange, Box<dyn std::error::Error>> {
153        let new_config = AppConfig::from_file(&self.config_path)?;
154
155        // Detect what changed
156        let change = self.detect_changes(&new_config);
157
158        // Update current configuration
159        self.current_config = new_config;
160
161        Ok(change)
162    }
163
164    /// Detect changes between old and new configuration
165    ///
166    /// # Arguments
167    ///
168    /// * `new_config` - New configuration to compare
169    ///
170    /// # Returns
171    ///
172    /// Returns a description of what changed
173    ///
174    /// # Hot Reload Support Scope
175    ///
176    /// This method detects changes in the following configuration items (supports hot reload):
177    /// - API Key authentication configuration (requires api-key feature)
178    /// - OAuth configuration
179    /// - Log level and toggles
180    /// - Cache TTL configuration
181    /// - Rate limiting and concurrent request limits
182    /// - Metrics and compression toggles
183    ///
184    /// Note: Server basic configuration (host, port, etc.) changes will be detected and logged,
185    /// but these configurations require server restart to take effect.
186    #[allow(clippy::too_many_lines)]
187    fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
188        let mut changes: Vec<String> = Vec::new();
189
190        // Check API key changes
191        #[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                // Log added keys
209                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                // Log removed keys
223                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        // Check OAuth configuration changes
264        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        // Check logging configuration changes (all fields support hot-reload)
284        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        // Check cache TTL configuration changes (support hot-reload)
335        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        // Check performance configuration changes (hot-reloadable fields only)
367        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        // Check server configuration changes (require restart)
412        // These are detected for logging purposes but require server restart
413        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    /// Get current configuration
462    #[must_use]
463    pub fn current_config(&self) -> &AppConfig {
464        &self.current_config
465    }
466
467    /// Stop watching for changes
468    pub fn stop(mut self) {
469        let _ = self.watcher.unwatch(&self.config_path);
470    }
471}
472
473/// Configuration change description
474#[derive(Debug, Clone)]
475pub enum ConfigChange {
476    /// No changes detected
477    NoChange,
478    /// Configuration has changed
479    Changed {
480        /// List of changes detected
481        changes: Vec<String>,
482        /// New configuration
483        new_config: Box<AppConfig>,
484    },
485}
486
487impl ConfigChange {
488    /// Check if configuration has changed
489    #[must_use]
490    pub fn is_changed(&self) -> bool {
491        matches!(self, ConfigChange::Changed { .. })
492    }
493
494    /// Get new configuration if changed
495    #[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    /// Get change descriptions
504    #[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        // A freshly created reloader has a live watcher; the disconnect latch
530        // only flips after the watcher channel actually disconnects.
531        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        // Create a temporary file for testing
540        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        // Test no change - we create a reloader just to test detect_changes
547        // Note: file watching won't work in tests, but we can test the logic
548        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        // Test API key change
562        config2.auth.api_key.keys.push("test_key".to_string());
563
564        // Create a temporary file for testing
565        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}