1use crate::error::{HooksError, Result};
7use crate::types::{Action, AiPromptAction, ChainAction, CommandAction, Hook, ToolCallAction};
8
9pub struct ConfigValidator;
18
19impl ConfigValidator {
20 pub fn validate_hook(hook: &Hook) -> Result<()> {
26 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 Self::validate_event_name(&hook.event)?;
41
42 Self::validate_action(&hook.action)?;
44
45 if let Some(condition) = &hook.condition {
47 Self::validate_condition(condition)?;
48 }
49
50 Ok(())
51 }
52
53 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 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 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 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 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 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 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 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 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 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 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 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 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}