dynpatch_watcher/
config.rs

1//! Configuration hot-reloading utilities
2
3use crate::{FileWatcher, Result, WatchError};
4use arc_swap::ArcSwap;
5use serde::de::DeserializeOwned;
6use std::fs;
7use std::path::{Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10use tracing::{error, info, warn};
11
12/// Trait for hot-reloadable configuration
13pub trait HotConfig: DeserializeOwned + Send + Sync + 'static {
14    /// Validate the configuration after loading
15    fn validate(&self) -> std::result::Result<(), String> {
16        Ok(())
17    }
18}
19
20/// Configuration watcher that automatically reloads config on file changes
21///
22/// Features:
23/// - Automatic reload on file changes
24/// - Debouncing to prevent excessive reloads
25/// - Validation before applying
26/// - Atomic updates with arc-swap
27/// - Error recovery (keeps previous config on failure)
28pub struct ConfigWatcher<T: HotConfig> {
29    config: Arc<ArcSwap<T>>,
30    path: PathBuf,
31    _watcher: FileWatcher,
32    last_reload: Arc<Mutex<Instant>>,
33    debounce_duration: Duration,
34}
35
36impl<T: HotConfig> ConfigWatcher<T> {
37    /// Create a new config watcher with default debounce duration (500ms)
38    pub fn new<P: AsRef<Path>>(path: P) -> Result<Self> {
39        Self::with_debounce(path, Duration::from_millis(500))
40    }
41
42    /// Create a new config watcher with custom debounce duration
43    ///
44    /// Debouncing prevents excessive reloads when a file is modified multiple times
45    /// in quick succession (common with text editors).
46    pub fn with_debounce<P: AsRef<Path>>(path: P, debounce: Duration) -> Result<Self> {
47        let path = path.as_ref().to_path_buf();
48        let initial_config = Self::load_config(&path)?;
49        
50        // Validate initial config
51        initial_config
52            .validate()
53            .map_err(|e| WatchError::ParseFailed(format!("Initial config validation failed: {}", e)))?;
54
55        info!("Initial config loaded and validated from: {:?}", path);
56
57        let config = Arc::new(ArcSwap::new(Arc::new(initial_config)));
58        let config_clone = config.clone();
59        let path_clone = path.clone();
60        let last_reload = Arc::new(Mutex::new(Instant::now()));
61        let last_reload_clone = last_reload.clone();
62        let debounce_clone = debounce;
63
64        let watcher = FileWatcher::new(&path, move |_| {
65            // Debounce: check if enough time has passed since last reload
66            {
67                let mut last = last_reload_clone.lock().unwrap();
68                let now = Instant::now();
69                if now.duration_since(*last) < debounce_clone {
70                    return; // Skip this reload, too soon
71                }
72                *last = now;
73            }
74
75            info!("Config file changed, reloading: {:?}", path_clone);
76            
77            match Self::load_config(&path_clone) {
78                Ok(new_config) => {
79                    // Validate before applying
80                    if let Err(e) = new_config.validate() {
81                        error!("Config validation failed, keeping previous config: {}", e);
82                        return;
83                    }
84                    
85                    config_clone.store(Arc::new(new_config));
86                    info!("Config reloaded and validated successfully");
87                }
88                Err(e) => {
89                    error!("Failed to reload config (keeping previous): {}", e);
90                }
91            }
92        })?;
93
94        Ok(Self {
95            config,
96            path,
97            _watcher: watcher,
98            last_reload,
99            debounce_duration: debounce,
100        })
101    }
102
103    /// Get the current configuration
104    pub fn get(&self) -> Arc<T> {
105        self.config.load_full()
106    }
107
108    /// Manually reload the configuration
109    pub fn reload(&self) -> Result<()> {
110        let new_config = Self::load_config(&self.path)?;
111        new_config
112            .validate()
113            .map_err(|e| WatchError::ParseFailed(e))?;
114        self.config.store(Arc::new(new_config));
115        info!("Config manually reloaded");
116        Ok(())
117    }
118
119    fn load_config(path: &Path) -> Result<T> {
120        let content = fs::read_to_string(path)?;
121        let extension = path.extension().and_then(|e| e.to_str());
122
123        match extension {
124            #[cfg(feature = "json")]
125            Some("json") => serde_json::from_str(&content)
126                .map_err(|e| WatchError::ParseFailed(e.to_string())),
127
128            #[cfg(feature = "toml")]
129            Some("toml") => toml::from_str(&content)
130                .map_err(|e| WatchError::ParseFailed(e.to_string())),
131
132            #[cfg(feature = "yaml")]
133            Some("yaml") | Some("yml") => serde_yaml::from_str(&content)
134                .map_err(|e| WatchError::ParseFailed(e.to_string())),
135
136            _ => Err(WatchError::ParseFailed(format!(
137                "Unsupported file extension: {:?}",
138                extension
139            ))),
140        }
141    }
142
143    pub fn path(&self) -> &Path {
144        &self.path
145    }
146
147    /// Get the debounce duration
148    pub fn debounce_duration(&self) -> Duration {
149        self.debounce_duration
150    }
151
152    /// Get the time since last reload
153    pub fn time_since_last_reload(&self) -> Duration {
154        let last = self.last_reload.lock().unwrap();
155        Instant::now().duration_since(*last)
156    }
157}
158
159/// Watch a configuration file and return a watcher
160pub fn watch<T: HotConfig, P: AsRef<Path>>(path: P) -> Result<ConfigWatcher<T>> {
161    ConfigWatcher::new(path)
162}
163
164#[cfg(test)]
165mod tests {
166    use super::*;
167    use serde::{Deserialize, Serialize};
168    use tempfile::Builder;
169
170    #[derive(Debug, Clone, Deserialize, Serialize, PartialEq)]
171    struct TestConfig {
172        value: i32,
173        name: String,
174    }
175
176    impl HotConfig for TestConfig {}
177
178    #[test]
179    #[cfg(feature = "json")]
180    fn test_config_watcher_json() {
181        let temp = Builder::new()
182            .suffix(".json")
183            .tempfile()
184            .unwrap();
185        
186        let config = TestConfig {
187            value: 42,
188            name: "test".to_string(),
189        };
190        
191        std::fs::write(temp.path(), serde_json::to_string(&config).unwrap()).unwrap();
192
193        let watcher = ConfigWatcher::<TestConfig>::new(temp.path()).unwrap();
194        let loaded = watcher.get();
195        
196        assert_eq!(loaded.value, 42);
197        assert_eq!(loaded.name, "test");
198    }
199}