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}
29
30impl ConfigReloader {
31    /// Create a new configuration reloader
32    ///
33    /// # Arguments
34    ///
35    /// * `config_path` - Path to the configuration file
36    /// * `current_config` - Current configuration for comparison
37    ///
38    /// # Errors
39    ///
40    /// Returns an error if the watcher cannot be created
41    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        // Create file watcher
48        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        // Watch the configuration file
55        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    /// Check for configuration changes
74    ///
75    /// This method should be called periodically in the event loop.
76    ///
77    /// # Returns
78    ///
79    /// Returns `Some(new_config)` if the configuration has changed and should be reloaded,
80    /// or `None` if no changes were detected.
81    pub fn check_for_changes(&mut self) -> Option<ConfigChange> {
82        // Check for file system events (non-blocking)
83        match self.receiver.try_recv() {
84            Ok(Ok(event)) => {
85                // Only process modification events
86                if matches!(
87                    event.kind,
88                    EventKind::Modify(_) | EventKind::Create(_) | EventKind::Any
89                ) {
90                    // Debounce: only reload once per second
91                    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                    // Try to reload the configuration
100                    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                // No events available
116            }
117            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
118                warn!("File system watcher disconnected");
119            }
120        }
121
122        None
123    }
124
125    /// Reload configuration from file
126    ///
127    /// # Errors
128    ///
129    /// Returns an error if the configuration cannot be loaded or parsed
130    fn reload_config(&mut self) -> Result<ConfigChange, Box<dyn std::error::Error>> {
131        let new_config = AppConfig::from_file(&self.config_path)?;
132
133        // Detect what changed
134        let change = self.detect_changes(&new_config);
135
136        // Update current configuration
137        self.current_config = new_config;
138
139        Ok(change)
140    }
141
142    /// Detect changes between old and new configuration
143    ///
144    /// # Arguments
145    ///
146    /// * `new_config` - New configuration to compare
147    ///
148    /// # Returns
149    ///
150    /// Returns a description of what changed
151    ///
152    /// # Hot Reload Support Scope
153    ///
154    /// This method detects changes in the following configuration items (supports hot reload):
155    /// - API Key authentication configuration (requires api-key feature)
156    /// - OAuth configuration
157    /// - Log level and toggles
158    /// - Cache TTL configuration
159    /// - Rate limiting and concurrent request limits
160    /// - Metrics and compression toggles
161    ///
162    /// Note: Server basic configuration (host, port, etc.) changes will be detected and logged,
163    /// but these configurations require server restart to take effect.
164    #[allow(clippy::too_many_lines)]
165    fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
166        let mut changes: Vec<String> = Vec::new();
167
168        // Check API key changes
169        #[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                // Log added keys
187                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                // Log removed keys
201                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        // Check OAuth configuration changes
242        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        // Check logging configuration changes (all fields support hot-reload)
262        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        // Check cache TTL configuration changes (support hot-reload)
313        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        // Check performance configuration changes (hot-reloadable fields only)
345        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        // Check server configuration changes (require restart)
390        // These are detected for logging purposes but require server restart
391        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    /// Get current configuration
440    #[must_use]
441    pub fn current_config(&self) -> &AppConfig {
442        &self.current_config
443    }
444
445    /// Stop watching for changes
446    pub fn stop(mut self) {
447        let _ = self.watcher.unwatch(&self.config_path);
448    }
449}
450
451/// Configuration change description
452#[derive(Debug, Clone)]
453pub enum ConfigChange {
454    /// No changes detected
455    NoChange,
456    /// Configuration has changed
457    Changed {
458        /// List of changes detected
459        changes: Vec<String>,
460        /// New configuration
461        new_config: Box<AppConfig>,
462    },
463}
464
465impl ConfigChange {
466    /// Check if configuration has changed
467    #[must_use]
468    pub fn is_changed(&self) -> bool {
469        matches!(self, ConfigChange::Changed { .. })
470    }
471
472    /// Get new configuration if changed
473    #[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    /// Get change descriptions
482    #[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        // Create a temporary file for testing
503        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        // Test no change - we create a reloader just to test detect_changes
510        // Note: file watching won't work in tests, but we can test the logic
511        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        // Test API key change
525        config2.auth.api_key.keys.push("test_key".to_string());
526
527        // Create a temporary file for testing
528        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}