ricecoder_hooks/config/
reloader.rs

1//! Configuration reloading for hooks
2//!
3//! Provides functionality to detect configuration changes and reload hooks
4//! without restarting the application. Preserves hook state (enabled/disabled)
5//! during reload.
6
7use crate::error::{HooksError, Result};
8use crate::types::Hook;
9use std::collections::HashMap;
10use std::fs;
11use std::path::{Path, PathBuf};
12use std::time::SystemTime;
13
14/// Configuration reloader for detecting and applying configuration changes
15///
16/// Monitors configuration files for changes and reloads hooks when changes
17/// are detected. Preserves hook state (enabled/disabled) during reload.
18pub struct ConfigReloader {
19    /// Last known modification time of project config
20    project_config_mtime: Option<SystemTime>,
21
22    /// Last known modification time of user config
23    user_config_mtime: Option<SystemTime>,
24
25    /// Current hook state (enabled/disabled)
26    hook_state: HashMap<String, bool>,
27}
28
29impl ConfigReloader {
30    /// Create a new configuration reloader
31    pub fn new() -> Self {
32        Self {
33            project_config_mtime: None,
34            user_config_mtime: None,
35            hook_state: HashMap::new(),
36        }
37    }
38
39    /// Check if configuration has changed
40    ///
41    /// Returns true if any configuration file has been modified since the last check.
42    pub fn has_changed(&mut self) -> Result<bool> {
43        let project_path = PathBuf::from(".ricecoder/hooks.yaml");
44        let project_mtime = Self::get_file_mtime(&project_path)?;
45
46        let user_path = Self::get_user_config_path()?;
47        let user_mtime = Self::get_file_mtime(&user_path)?;
48
49        let changed =
50            project_mtime != self.project_config_mtime || user_mtime != self.user_config_mtime;
51
52        if changed {
53            self.project_config_mtime = project_mtime;
54            self.user_config_mtime = user_mtime;
55        }
56
57        Ok(changed)
58    }
59
60    /// Save hook state before reload
61    ///
62    /// Saves the enabled/disabled state of all hooks so it can be restored
63    /// after reloading configuration.
64    pub fn save_hook_state(&mut self, hooks: &HashMap<String, Hook>) {
65        self.hook_state.clear();
66        for (id, hook) in hooks {
67            self.hook_state.insert(id.clone(), hook.enabled);
68        }
69    }
70
71    /// Restore hook state after reload
72    ///
73    /// Restores the enabled/disabled state of hooks after reloading configuration.
74    /// Hooks that were disabled before reload remain disabled, and hooks that were
75    /// enabled remain enabled.
76    pub fn restore_hook_state(&self, hooks: &mut HashMap<String, Hook>) {
77        for (id, hook) in hooks.iter_mut() {
78            if let Some(&enabled) = self.hook_state.get(id) {
79                hook.enabled = enabled;
80            }
81        }
82    }
83
84    /// Get the modification time of a file
85    ///
86    /// Returns None if the file doesn't exist.
87    fn get_file_mtime(path: &Path) -> Result<Option<SystemTime>> {
88        if !path.exists() {
89            return Ok(None);
90        }
91
92        let metadata = fs::metadata(path)
93            .map_err(|e| HooksError::StorageError(format!("Failed to get file metadata: {}", e)))?;
94
95        let mtime = metadata.modified().map_err(|e| {
96            HooksError::StorageError(format!("Failed to get modification time: {}", e))
97        })?;
98
99        Ok(Some(mtime))
100    }
101
102    /// Get the user configuration path
103    fn get_user_config_path() -> Result<PathBuf> {
104        use ricecoder_storage::PathResolver;
105
106        let global_path = PathResolver::resolve_global_path()
107            .map_err(|e| HooksError::StorageError(e.to_string()))?;
108        Ok(global_path.join("hooks.yaml"))
109    }
110}
111
112impl Default for ConfigReloader {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121    use std::io::Write;
122    use std::thread;
123    use std::time::Duration;
124    use tempfile::NamedTempFile;
125
126    #[test]
127    fn test_new_reloader() {
128        let reloader = ConfigReloader::new();
129        assert!(reloader.project_config_mtime.is_none());
130        assert!(reloader.user_config_mtime.is_none());
131        assert_eq!(reloader.hook_state.len(), 0);
132    }
133
134    #[test]
135    fn test_save_and_restore_hook_state() {
136        let mut reloader = ConfigReloader::new();
137
138        // Create test hooks
139        let mut hooks = HashMap::new();
140        hooks.insert(
141            "hook1".to_string(),
142            Hook {
143                id: "hook1".to_string(),
144                name: "Hook 1".to_string(),
145                description: None,
146                event: "event1".to_string(),
147                action: crate::types::Action::Command(crate::types::CommandAction {
148                    command: "cmd".to_string(),
149                    args: vec![],
150                    timeout_ms: None,
151                    capture_output: false,
152                }),
153                enabled: true,
154                tags: vec![],
155                metadata: serde_json::json!({}),
156                condition: None,
157            },
158        );
159
160        hooks.insert(
161            "hook2".to_string(),
162            Hook {
163                id: "hook2".to_string(),
164                name: "Hook 2".to_string(),
165                description: None,
166                event: "event2".to_string(),
167                action: crate::types::Action::Command(crate::types::CommandAction {
168                    command: "cmd".to_string(),
169                    args: vec![],
170                    timeout_ms: None,
171                    capture_output: false,
172                }),
173                enabled: false,
174                tags: vec![],
175                metadata: serde_json::json!({}),
176                condition: None,
177            },
178        );
179
180        // Save state
181        reloader.save_hook_state(&hooks);
182        assert_eq!(reloader.hook_state.len(), 2);
183        assert_eq!(reloader.hook_state.get("hook1"), Some(&true));
184        assert_eq!(reloader.hook_state.get("hook2"), Some(&false));
185
186        // Modify hooks
187        hooks.get_mut("hook1").unwrap().enabled = false;
188        hooks.get_mut("hook2").unwrap().enabled = true;
189
190        // Restore state
191        reloader.restore_hook_state(&mut hooks);
192        assert!(hooks.get("hook1").unwrap().enabled);
193        assert!(!hooks.get("hook2").unwrap().enabled);
194    }
195
196    #[test]
197    fn test_get_file_mtime_nonexistent() {
198        let path = PathBuf::from("/nonexistent/path/file.yaml");
199        let mtime = ConfigReloader::get_file_mtime(&path).expect("Should not error");
200        assert!(mtime.is_none());
201    }
202
203    #[test]
204    fn test_get_file_mtime_existing() {
205        let temp_file = NamedTempFile::new().expect("Should create temp file");
206        let path = temp_file.path();
207
208        let mtime = ConfigReloader::get_file_mtime(path).expect("Should get mtime");
209        assert!(mtime.is_some());
210    }
211
212    #[test]
213    fn test_has_changed_no_files() {
214        let mut reloader = ConfigReloader::new();
215        let changed = reloader.has_changed().expect("Should check for changes");
216        // Should return false since no files exist
217        assert!(!changed);
218    }
219
220    #[test]
221    fn test_has_changed_detects_modification() {
222        let mut reloader = ConfigReloader::new();
223
224        // Create a temporary file
225        let mut temp_file = NamedTempFile::new().expect("Should create temp file");
226        let path = temp_file.path().to_path_buf();
227
228        // Write initial content
229        temp_file.write_all(b"initial").expect("Should write");
230        temp_file.flush().expect("Should flush");
231
232        // First check should detect the file
233        let _changed1 = reloader.has_changed().expect("Should check");
234
235        // Wait a bit to ensure time difference
236        thread::sleep(Duration::from_millis(100));
237
238        // Modify the file
239        temp_file.write_all(b" modified").expect("Should write");
240        temp_file.flush().expect("Should flush");
241
242        // Second check should detect the modification
243        let _changed2 = reloader.has_changed().expect("Should check");
244
245        // Clean up
246        drop(temp_file);
247        drop(path);
248
249        // Note: This test may be flaky on some systems due to filesystem timestamp resolution
250        // In practice, the reloader would be used with actual config files
251    }
252}