ricecoder_hooks/config/
validator.rs

1//! Configuration validation for hooks
2//!
3//! Validates hook configurations to ensure they are well-formed and contain
4//! all required fields with correct types.
5
6use crate::error::{HooksError, Result};
7use crate::types::{Action, AiPromptAction, ChainAction, CommandAction, Hook, ToolCallAction};
8
9/// Configuration validator for hooks
10///
11/// Validates hook configurations to ensure they meet requirements:
12/// - All required fields are present
13/// - Field types are correct
14/// - Event names are non-empty and valid
15/// - Action configurations are valid
16/// - Condition expressions are valid (if present)
17pub struct ConfigValidator;
18
19impl ConfigValidator {
20    /// Validate a single hook configuration
21    ///
22    /// # Errors
23    ///
24    /// Returns an error if the hook configuration is invalid.
25    pub fn validate_hook(hook: &Hook) -> Result<()> {
26        // Validate required fields
27        if hook.id.is_empty() {
28            return Err(HooksError::InvalidConfiguration(
29                "Hook ID cannot be empty".to_string(),
30            ));
31        }
32
33        if hook.name.is_empty() {
34            return Err(HooksError::InvalidConfiguration(
35                "Hook name cannot be empty".to_string(),
36            ));
37        }
38
39        // Validate event name
40        Self::validate_event_name(&hook.event)?;
41
42        // Validate action
43        Self::validate_action(&hook.action)?;
44
45        // Validate condition if present
46        if let Some(condition) = &hook.condition {
47            Self::validate_condition(condition)?;
48        }
49
50        Ok(())
51    }
52
53    /// Validate event name
54    ///
55    /// Event names must be non-empty and follow a valid format.
56    fn validate_event_name(event: &str) -> Result<()> {
57        if event.is_empty() {
58            return Err(HooksError::InvalidConfiguration(
59                "Event name cannot be empty".to_string(),
60            ));
61        }
62
63        // Event names should be lowercase with underscores
64        if !event.chars().all(|c| c.is_ascii_lowercase() || c == '_') {
65            return Err(HooksError::InvalidConfiguration(format!(
66                "Invalid event name format: '{}'. Event names must be lowercase with underscores.",
67                event
68            )));
69        }
70
71        Ok(())
72    }
73
74    /// Validate action configuration
75    ///
76    /// Validates that the action is properly configured based on its type.
77    fn validate_action(action: &Action) -> Result<()> {
78        match action {
79            Action::Command(cmd) => Self::validate_command_action(cmd),
80            Action::ToolCall(tool) => Self::validate_tool_call_action(tool),
81            Action::AiPrompt(prompt) => Self::validate_ai_prompt_action(prompt),
82            Action::Chain(chain) => Self::validate_chain_action(chain),
83        }
84    }
85
86    /// Validate command action
87    fn validate_command_action(action: &CommandAction) -> Result<()> {
88        if action.command.is_empty() {
89            return Err(HooksError::InvalidConfiguration(
90                "Command action: command cannot be empty".to_string(),
91            ));
92        }
93
94        // Validate timeout if present
95        if let Some(timeout) = action.timeout_ms {
96            if timeout == 0 {
97                return Err(HooksError::InvalidConfiguration(
98                    "Command action: timeout must be greater than 0".to_string(),
99                ));
100            }
101        }
102
103        Ok(())
104    }
105
106    /// Validate tool call action
107    fn validate_tool_call_action(action: &ToolCallAction) -> Result<()> {
108        if action.tool_name.is_empty() {
109            return Err(HooksError::InvalidConfiguration(
110                "Tool call action: tool_name cannot be empty".to_string(),
111            ));
112        }
113
114        if action.tool_path.is_empty() {
115            return Err(HooksError::InvalidConfiguration(
116                "Tool call action: tool_path cannot be empty".to_string(),
117            ));
118        }
119
120        // Validate timeout if present
121        if let Some(timeout) = action.timeout_ms {
122            if timeout == 0 {
123                return Err(HooksError::InvalidConfiguration(
124                    "Tool call action: timeout must be greater than 0".to_string(),
125                ));
126            }
127        }
128
129        Ok(())
130    }
131
132    /// Validate AI prompt action
133    fn validate_ai_prompt_action(action: &AiPromptAction) -> Result<()> {
134        if action.prompt_template.is_empty() {
135            return Err(HooksError::InvalidConfiguration(
136                "AI prompt action: prompt_template cannot be empty".to_string(),
137            ));
138        }
139
140        // Validate temperature if present
141        if let Some(temp) = action.temperature {
142            if !(0.0..=2.0).contains(&temp) {
143                return Err(HooksError::InvalidConfiguration(
144                    "AI prompt action: temperature must be between 0.0 and 2.0".to_string(),
145                ));
146            }
147        }
148
149        // Validate max_tokens if present
150        if let Some(tokens) = action.max_tokens {
151            if tokens == 0 {
152                return Err(HooksError::InvalidConfiguration(
153                    "AI prompt action: max_tokens must be greater than 0".to_string(),
154                ));
155            }
156        }
157
158        Ok(())
159    }
160
161    /// Validate chain action
162    fn validate_chain_action(action: &ChainAction) -> Result<()> {
163        if action.hook_ids.is_empty() {
164            return Err(HooksError::InvalidConfiguration(
165                "Chain action: hook_ids cannot be empty".to_string(),
166            ));
167        }
168
169        // Check for duplicate hook IDs
170        let mut seen = std::collections::HashSet::new();
171        for id in &action.hook_ids {
172            if !seen.insert(id) {
173                return Err(HooksError::InvalidConfiguration(format!(
174                    "Chain action: duplicate hook ID '{}'",
175                    id
176                )));
177            }
178        }
179
180        Ok(())
181    }
182
183    /// Validate condition expression
184    fn validate_condition(condition: &crate::types::Condition) -> Result<()> {
185        if condition.expression.is_empty() {
186            return Err(HooksError::InvalidConfiguration(
187                "Condition: expression cannot be empty".to_string(),
188            ));
189        }
190
191        if condition.context_keys.is_empty() {
192            return Err(HooksError::InvalidConfiguration(
193                "Condition: context_keys cannot be empty".to_string(),
194            ));
195        }
196
197        Ok(())
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use crate::types::{CommandAction, Condition};
205    use serde_json::json;
206
207    fn create_test_hook() -> Hook {
208        Hook {
209            id: "test-hook".to_string(),
210            name: "Test Hook".to_string(),
211            description: None,
212            event: "file_saved".to_string(),
213            action: Action::Command(CommandAction {
214                command: "echo".to_string(),
215                args: vec![],
216                timeout_ms: None,
217                capture_output: false,
218            }),
219            enabled: true,
220            tags: vec![],
221            metadata: json!({}),
222            condition: None,
223        }
224    }
225
226    #[test]
227    fn test_validate_hook_valid() {
228        let hook = create_test_hook();
229        assert!(ConfigValidator::validate_hook(&hook).is_ok());
230    }
231
232    #[test]
233    fn test_validate_hook_empty_id() {
234        let mut hook = create_test_hook();
235        hook.id = String::new();
236        assert!(ConfigValidator::validate_hook(&hook).is_err());
237    }
238
239    #[test]
240    fn test_validate_hook_empty_name() {
241        let mut hook = create_test_hook();
242        hook.name = String::new();
243        assert!(ConfigValidator::validate_hook(&hook).is_err());
244    }
245
246    #[test]
247    fn test_validate_event_name_valid() {
248        assert!(ConfigValidator::validate_event_name("file_saved").is_ok());
249        assert!(ConfigValidator::validate_event_name("test_passed").is_ok());
250        assert!(ConfigValidator::validate_event_name("event").is_ok());
251    }
252
253    #[test]
254    fn test_validate_event_name_empty() {
255        assert!(ConfigValidator::validate_event_name("").is_err());
256    }
257
258    #[test]
259    fn test_validate_event_name_invalid_format() {
260        assert!(ConfigValidator::validate_event_name("FileSaved").is_err());
261        assert!(ConfigValidator::validate_event_name("file-saved").is_err());
262        assert!(ConfigValidator::validate_event_name("file.saved").is_err());
263    }
264
265    #[test]
266    fn test_validate_command_action_valid() {
267        let action = CommandAction {
268            command: "echo".to_string(),
269            args: vec!["hello".to_string()],
270            timeout_ms: Some(5000),
271            capture_output: true,
272        };
273        assert!(ConfigValidator::validate_command_action(&action).is_ok());
274    }
275
276    #[test]
277    fn test_validate_command_action_empty_command() {
278        let action = CommandAction {
279            command: String::new(),
280            args: vec![],
281            timeout_ms: None,
282            capture_output: false,
283        };
284        assert!(ConfigValidator::validate_command_action(&action).is_err());
285    }
286
287    #[test]
288    fn test_validate_command_action_zero_timeout() {
289        let action = CommandAction {
290            command: "echo".to_string(),
291            args: vec![],
292            timeout_ms: Some(0),
293            capture_output: false,
294        };
295        assert!(ConfigValidator::validate_command_action(&action).is_err());
296    }
297
298    #[test]
299    fn test_validate_tool_call_action_valid() {
300        let action = ToolCallAction {
301            tool_name: "formatter".to_string(),
302            tool_path: "/usr/bin/prettier".to_string(),
303            parameters: crate::types::ParameterBindings {
304                bindings: std::collections::HashMap::new(),
305            },
306            timeout_ms: Some(5000),
307        };
308        assert!(ConfigValidator::validate_tool_call_action(&action).is_ok());
309    }
310
311    #[test]
312    fn test_validate_tool_call_action_empty_name() {
313        let action = ToolCallAction {
314            tool_name: String::new(),
315            tool_path: "/usr/bin/prettier".to_string(),
316            parameters: crate::types::ParameterBindings {
317                bindings: std::collections::HashMap::new(),
318            },
319            timeout_ms: None,
320        };
321        assert!(ConfigValidator::validate_tool_call_action(&action).is_err());
322    }
323
324    #[test]
325    fn test_validate_tool_call_action_empty_path() {
326        let action = ToolCallAction {
327            tool_name: "formatter".to_string(),
328            tool_path: String::new(),
329            parameters: crate::types::ParameterBindings {
330                bindings: std::collections::HashMap::new(),
331            },
332            timeout_ms: None,
333        };
334        assert!(ConfigValidator::validate_tool_call_action(&action).is_err());
335    }
336
337    #[test]
338    fn test_validate_ai_prompt_action_valid() {
339        let action = AiPromptAction {
340            prompt_template: "Format this code: {{code}}".to_string(),
341            variables: std::collections::HashMap::new(),
342            model: Some("gpt-4".to_string()),
343            temperature: Some(0.7),
344            max_tokens: Some(2000),
345            stream: true,
346        };
347        assert!(ConfigValidator::validate_ai_prompt_action(&action).is_ok());
348    }
349
350    #[test]
351    fn test_validate_ai_prompt_action_empty_template() {
352        let action = AiPromptAction {
353            prompt_template: String::new(),
354            variables: std::collections::HashMap::new(),
355            model: None,
356            temperature: None,
357            max_tokens: None,
358            stream: false,
359        };
360        assert!(ConfigValidator::validate_ai_prompt_action(&action).is_err());
361    }
362
363    #[test]
364    fn test_validate_ai_prompt_action_invalid_temperature() {
365        let action = AiPromptAction {
366            prompt_template: "Format this code".to_string(),
367            variables: std::collections::HashMap::new(),
368            model: None,
369            temperature: Some(3.0),
370            max_tokens: None,
371            stream: false,
372        };
373        assert!(ConfigValidator::validate_ai_prompt_action(&action).is_err());
374    }
375
376    #[test]
377    fn test_validate_ai_prompt_action_zero_max_tokens() {
378        let action = AiPromptAction {
379            prompt_template: "Format this code".to_string(),
380            variables: std::collections::HashMap::new(),
381            model: None,
382            temperature: None,
383            max_tokens: Some(0),
384            stream: false,
385        };
386        assert!(ConfigValidator::validate_ai_prompt_action(&action).is_err());
387    }
388
389    #[test]
390    fn test_validate_chain_action_valid() {
391        let action = ChainAction {
392            hook_ids: vec!["hook1".to_string(), "hook2".to_string()],
393            pass_output: true,
394        };
395        assert!(ConfigValidator::validate_chain_action(&action).is_ok());
396    }
397
398    #[test]
399    fn test_validate_chain_action_empty_ids() {
400        let action = ChainAction {
401            hook_ids: vec![],
402            pass_output: false,
403        };
404        assert!(ConfigValidator::validate_chain_action(&action).is_err());
405    }
406
407    #[test]
408    fn test_validate_chain_action_duplicate_ids() {
409        let action = ChainAction {
410            hook_ids: vec!["hook1".to_string(), "hook1".to_string()],
411            pass_output: false,
412        };
413        assert!(ConfigValidator::validate_chain_action(&action).is_err());
414    }
415
416    #[test]
417    fn test_validate_condition_valid() {
418        let condition = Condition {
419            expression: "file_path.ends_with('.rs')".to_string(),
420            context_keys: vec!["file_path".to_string()],
421        };
422        assert!(ConfigValidator::validate_condition(&condition).is_ok());
423    }
424
425    #[test]
426    fn test_validate_condition_empty_expression() {
427        let condition = Condition {
428            expression: String::new(),
429            context_keys: vec!["file_path".to_string()],
430        };
431        assert!(ConfigValidator::validate_condition(&condition).is_err());
432    }
433
434    #[test]
435    fn test_validate_condition_empty_context_keys() {
436        let condition = Condition {
437            expression: "file_path.ends_with('.rs')".to_string(),
438            context_keys: vec![],
439        };
440        assert!(ConfigValidator::validate_condition(&condition).is_err());
441    }
442
443    #[test]
444    fn test_validate_hook_with_condition() {
445        let mut hook = create_test_hook();
446        hook.condition = Some(Condition {
447            expression: "file_path.ends_with('.rs')".to_string(),
448            context_keys: vec!["file_path".to_string()],
449        });
450        assert!(ConfigValidator::validate_hook(&hook).is_ok());
451    }
452}