ricecoder_hooks/config/
loader.rs

1//! Configuration loader for hooks
2//!
3//! Loads hook configurations from YAML files using the ricecoder-storage
4//! PathResolver for cross-platform compatibility. Supports configuration
5//! hierarchy: Runtime → Project → User → Built-in → Fallback.
6
7use crate::error::{HooksError, Result};
8use crate::types::Hook;
9use ricecoder_storage::PathResolver;
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14/// Configuration loader for hooks
15///
16/// Loads hooks from YAML configuration files following a hierarchy:
17/// 1. Runtime configuration (passed programmatically)
18/// 2. Project configuration (.ricecoder/hooks.yaml)
19/// 3. User configuration (~/.ricecoder/hooks.yaml)
20/// 4. Built-in configuration
21/// 5. Fallback (empty configuration)
22pub struct ConfigLoader;
23
24impl ConfigLoader {
25    /// Load hooks from configuration files
26    ///
27    /// Attempts to load hooks from the following locations in order:
28    /// 1. Project configuration (.ricecoder/hooks.yaml)
29    /// 2. User configuration (~/.ricecoder/hooks.yaml)
30    /// 3. Built-in configuration
31    ///
32    /// Returns a map of hook ID to Hook configuration.
33    ///
34    /// # Errors
35    ///
36    /// Returns an error if configuration files are invalid or cannot be read.
37    pub fn load() -> Result<HashMap<String, Hook>> {
38        let mut hooks = HashMap::new();
39
40        // Try to load project configuration first
41        if let Ok(project_hooks) = Self::load_project_config() {
42            hooks.extend(project_hooks);
43        }
44
45        // Try to load user configuration
46        if let Ok(user_hooks) = Self::load_user_config() {
47            // User config overrides project config
48            hooks.extend(user_hooks);
49        }
50
51        // Try to load built-in configuration
52        if let Ok(builtin_hooks) = Self::load_builtin_config() {
53            // Built-in config only adds new hooks, doesn't override
54            for (id, hook) in builtin_hooks {
55                hooks.entry(id).or_insert(hook);
56            }
57        }
58
59        Ok(hooks)
60    }
61
62    /// Load hooks from project configuration
63    ///
64    /// Looks for `.ricecoder/hooks.yaml` in the current directory.
65    fn load_project_config() -> Result<HashMap<String, Hook>> {
66        let project_path = PathBuf::from(".ricecoder/hooks.yaml");
67        Self::load_from_path(&project_path)
68    }
69
70    /// Load hooks from user configuration
71    ///
72    /// Looks for `~/.ricecoder/hooks.yaml` in the user's home directory.
73    fn load_user_config() -> Result<HashMap<String, Hook>> {
74        let global_path = PathResolver::resolve_global_path()
75            .map_err(|e| HooksError::StorageError(e.to_string()))?;
76        let user_config_path = global_path.join("hooks.yaml");
77        Self::load_from_path(&user_config_path)
78    }
79
80    /// Load hooks from built-in configuration
81    ///
82    /// Loads built-in hook templates and defaults.
83    fn load_builtin_config() -> Result<HashMap<String, Hook>> {
84        // For now, return empty map. Built-in templates will be added later.
85        Ok(HashMap::new())
86    }
87
88    /// Load hooks from a specific file path
89    ///
90    /// Reads and parses a YAML configuration file containing hook definitions.
91    ///
92    /// # Errors
93    ///
94    /// Returns an error if the file doesn't exist, cannot be read, or contains
95    /// invalid YAML or hook configuration.
96    fn load_from_path(path: &Path) -> Result<HashMap<String, Hook>> {
97        // If file doesn't exist, return empty map (not an error)
98        if !path.exists() {
99            return Ok(HashMap::new());
100        }
101
102        let content = fs::read_to_string(path)
103            .map_err(|e| HooksError::StorageError(format!("Failed to read config file: {}", e)))?;
104
105        Self::parse_yaml(&content)
106    }
107
108    /// Parse YAML configuration content
109    ///
110    /// Parses YAML content and extracts hook definitions.
111    ///
112    /// Expected YAML format:
113    /// ```yaml
114    /// hooks:
115    ///   - id: hook-id
116    ///     name: Hook Name
117    ///     event: event_type
118    ///     action:
119    ///       type: command
120    ///       command: echo
121    ///       args: ["hello"]
122    ///     enabled: true
123    /// ```
124    fn parse_yaml(content: &str) -> Result<HashMap<String, Hook>> {
125        let value: serde_yaml::Value = serde_yaml::from_str(content)
126            .map_err(|e| HooksError::InvalidConfiguration(format!("Invalid YAML: {}", e)))?;
127
128        let mut hooks = HashMap::new();
129
130        // Extract hooks array from YAML
131        if let Some(hooks_array) = value.get("hooks").and_then(|v| v.as_sequence()) {
132            for hook_value in hooks_array {
133                match serde_yaml::from_value::<Hook>(hook_value.clone()) {
134                    Ok(hook) => {
135                        hooks.insert(hook.id.clone(), hook);
136                    }
137                    Err(e) => {
138                        return Err(HooksError::InvalidConfiguration(format!(
139                            "Failed to parse hook: {}",
140                            e
141                        )));
142                    }
143                }
144            }
145        }
146
147        Ok(hooks)
148    }
149
150    /// Load hooks from a YAML string (for testing)
151    ///
152    /// Parses YAML content and returns hooks.
153    #[cfg(test)]
154    pub fn load_from_string(content: &str) -> Result<HashMap<String, Hook>> {
155        Self::parse_yaml(content)
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_parse_yaml_with_command_action() {
165        let yaml = r#"
166hooks:
167  - id: test-hook
168    name: Test Hook
169    description: A test hook
170    event: file_saved
171    action:
172      type: command
173      command: echo
174      args:
175        - hello
176      timeout_ms: 5000
177      capture_output: true
178    enabled: true
179    tags:
180      - test
181    metadata: {}
182"#;
183
184        let hooks = ConfigLoader::load_from_string(yaml).expect("Should parse YAML");
185        assert_eq!(hooks.len(), 1);
186
187        let hook = hooks.get("test-hook").expect("Should find hook");
188        assert_eq!(hook.name, "Test Hook");
189        assert_eq!(hook.event, "file_saved");
190        assert!(hook.enabled);
191        assert_eq!(hook.tags.len(), 1);
192    }
193
194    #[test]
195    fn test_parse_yaml_multiple_hooks() {
196        let yaml = r#"
197hooks:
198  - id: hook1
199    name: Hook 1
200    event: event1
201    action:
202      type: command
203      command: cmd1
204      args: []
205      timeout_ms: null
206      capture_output: false
207    enabled: true
208    tags: []
209    metadata: {}
210  - id: hook2
211    name: Hook 2
212    event: event2
213    action:
214      type: command
215      command: cmd2
216      args: []
217      timeout_ms: null
218      capture_output: false
219    enabled: false
220    tags: []
221    metadata: {}
222"#;
223
224        let hooks = ConfigLoader::load_from_string(yaml).expect("Should parse YAML");
225        assert_eq!(hooks.len(), 2);
226        assert!(hooks.contains_key("hook1"));
227        assert!(hooks.contains_key("hook2"));
228    }
229
230    #[test]
231    fn test_parse_yaml_empty() {
232        let yaml = "hooks: []";
233        let hooks = ConfigLoader::load_from_string(yaml).expect("Should parse empty YAML");
234        assert_eq!(hooks.len(), 0);
235    }
236
237    #[test]
238    fn test_parse_yaml_invalid() {
239        let yaml = "invalid: yaml: content:";
240        let result = ConfigLoader::load_from_string(yaml);
241        assert!(result.is_err());
242    }
243
244    #[test]
245    fn test_parse_yaml_missing_required_field() {
246        let yaml = r#"
247hooks:
248  - id: test-hook
249    name: Test Hook
250"#;
251        let result = ConfigLoader::load_from_string(yaml);
252        assert!(result.is_err());
253    }
254
255    #[test]
256    fn test_load_from_nonexistent_path() {
257        let path = PathBuf::from("/nonexistent/path/hooks.yaml");
258        let hooks = ConfigLoader::load_from_path(&path).expect("Should return empty map");
259        assert_eq!(hooks.len(), 0);
260    }
261}