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    fn detect_changes(&self, new_config: &AppConfig) -> ConfigChange {
152        let mut changes: Vec<String> = Vec::new();
153
154        // Check API key changes
155        #[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                // Log added keys
173                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                // Log removed keys
187                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        // Check server configuration changes
228        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        // Check cache configuration changes
243        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    /// Get current configuration
261    #[must_use]
262    pub fn current_config(&self) -> &AppConfig {
263        &self.current_config
264    }
265
266    /// Stop watching for changes
267    pub fn stop(mut self) {
268        let _ = self.watcher.unwatch(&self.config_path);
269    }
270}
271
272/// Configuration change description
273#[derive(Debug, Clone)]
274pub enum ConfigChange {
275    /// No changes detected
276    NoChange,
277    /// Configuration has changed
278    Changed {
279        /// List of changes detected
280        changes: Vec<String>,
281        /// New configuration
282        new_config: Box<AppConfig>,
283    },
284}
285
286impl ConfigChange {
287    /// Check if configuration has changed
288    #[must_use]
289    pub fn is_changed(&self) -> bool {
290        matches!(self, ConfigChange::Changed { .. })
291    }
292
293    /// Get new configuration if changed
294    #[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    /// Get change descriptions
303    #[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        // Create a temporary file for testing
324        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        // Test no change - we create a reloader just to test detect_changes
331        // Note: file watching won't work in tests, but we can test the logic
332        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        // Test API key change
346        config2.auth.api_key.keys.push("test_key".to_string());
347
348        // Create a temporary file for testing
349        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}