1use crate::hooks::{
6 AgentHook, BuildEvent, GitOp, HookAction, HookCondition, HookEngine, HookTrigger, TestFilter,
7 TestStatus,
8};
9use crate::{DrivenError, Result};
10use std::path::{Path, PathBuf};
11
12pub struct HookCommand;
14
15impl HookCommand {
16 pub fn list(project_root: &Path) -> Result<Vec<HookInfo>> {
18 let hooks_dir = project_root.join(".driven/hooks");
19 let mut engine = HookEngine::with_hooks_dir(&hooks_dir);
20
21 if hooks_dir.exists() {
22 engine.load_hooks(&hooks_dir)?;
23 }
24
25 let hooks: Vec<HookInfo> = engine.list_hooks().iter().map(HookInfo::from).collect();
26
27 Ok(hooks)
28 }
29
30 pub fn add(
32 project_root: &Path,
33 id: &str,
34 name: Option<&str>,
35 trigger_type: &str,
36 trigger_value: &str,
37 agent: &str,
38 message: &str,
39 workflow: Option<&str>,
40 condition: Option<&str>,
41 enabled: bool,
42 ) -> Result<()> {
43 let hooks_dir = project_root.join(".driven/hooks");
44 std::fs::create_dir_all(&hooks_dir).map_err(DrivenError::Io)?;
45
46 let mut engine = HookEngine::with_hooks_dir(&hooks_dir);
47
48 if hooks_dir.exists() {
50 engine.load_hooks(&hooks_dir)?;
51 }
52
53 if engine.get_hook(id).is_some() {
55 return Err(DrivenError::Config(format!(
56 "Hook with ID '{}' already exists",
57 id
58 )));
59 }
60
61 let trigger = Self::parse_trigger(trigger_type, trigger_value)?;
63
64 let mut action = HookAction::new(agent, message);
66 if let Some(wf) = workflow {
67 action = action.with_workflow(wf);
68 }
69
70 let mut hook = AgentHook::new(id)
72 .with_name(name.unwrap_or(id))
73 .with_trigger(trigger)
74 .with_action(action)
75 .with_enabled(enabled);
76
77 if let Some(cond) = condition {
79 hook = hook.with_condition(HookCondition::new(cond));
80 }
81
82 let hook_path = hooks_dir.join(format!("{}.json", id));
84 engine.save_hook(&hook, &hook_path)?;
85
86 engine.register_hook(hook)?;
88
89 super::print_success(&format!("Hook '{}' added successfully", id));
90 Ok(())
91 }
92
93 pub fn remove(project_root: &Path, id: &str) -> Result<()> {
95 let hooks_dir = project_root.join(".driven/hooks");
96
97 if !hooks_dir.exists() {
98 return Err(DrivenError::Config("No hooks directory found".to_string()));
99 }
100
101 let json_path = hooks_dir.join(format!("{}.json", id));
103 let yaml_path = hooks_dir.join(format!("{}.yaml", id));
104 let yml_path = hooks_dir.join(format!("{}.yml", id));
105
106 let removed = if json_path.exists() {
107 std::fs::remove_file(&json_path).map_err(DrivenError::Io)?;
108 true
109 } else if yaml_path.exists() {
110 std::fs::remove_file(&yaml_path).map_err(DrivenError::Io)?;
111 true
112 } else if yml_path.exists() {
113 std::fs::remove_file(&yml_path).map_err(DrivenError::Io)?;
114 true
115 } else {
116 false
117 };
118
119 if removed {
120 super::print_success(&format!("Hook '{}' removed successfully", id));
121 Ok(())
122 } else {
123 Err(DrivenError::Config(format!(
124 "Hook with ID '{}' not found",
125 id
126 )))
127 }
128 }
129
130 pub fn trigger(project_root: &Path, command: &str) -> Result<Vec<TriggerResult>> {
132 let hooks_dir = project_root.join(".driven/hooks");
133 let mut engine = HookEngine::with_hooks_dir(&hooks_dir);
134
135 if hooks_dir.exists() {
136 engine.load_hooks(&hooks_dir)?;
137 }
138
139 let results = engine.trigger_manual(command);
140
141 if results.is_empty() {
142 super::print_warning(&format!("No hooks found for command '{}'", command));
143 } else {
144 for result in &results {
145 if result.success {
146 super::print_success(&format!(
147 "Hook '{}' executed successfully ({}ms)",
148 result.hook_id, result.duration_ms
149 ));
150 } else {
151 super::print_error(&format!(
152 "Hook '{}' failed: {}",
153 result.hook_id,
154 result.error.as_deref().unwrap_or("Unknown error")
155 ));
156 }
157 }
158 }
159
160 Ok(results.into_iter().map(TriggerResult::from).collect())
161 }
162
163 pub fn enable(project_root: &Path, id: &str) -> Result<()> {
165 Self::set_enabled(project_root, id, true)
166 }
167
168 pub fn disable(project_root: &Path, id: &str) -> Result<()> {
170 Self::set_enabled(project_root, id, false)
171 }
172
173 fn set_enabled(project_root: &Path, id: &str, enabled: bool) -> Result<()> {
175 let hooks_dir = project_root.join(".driven/hooks");
176
177 if !hooks_dir.exists() {
178 return Err(DrivenError::Config("No hooks directory found".to_string()));
179 }
180
181 let (hook_path, mut hook) = Self::find_and_load_hook(&hooks_dir, id)?;
183
184 hook.enabled = enabled;
186
187 let engine = HookEngine::new();
189 engine.save_hook(&hook, &hook_path)?;
190
191 let action = if enabled { "enabled" } else { "disabled" };
192 super::print_success(&format!("Hook '{}' {}", id, action));
193 Ok(())
194 }
195
196 pub fn show(project_root: &Path, id: &str) -> Result<HookInfo> {
198 let hooks_dir = project_root.join(".driven/hooks");
199
200 if !hooks_dir.exists() {
201 return Err(DrivenError::Config("No hooks directory found".to_string()));
202 }
203
204 let (_, hook) = Self::find_and_load_hook(&hooks_dir, id)?;
205 Ok(HookInfo::from(&hook))
206 }
207
208 fn find_and_load_hook(hooks_dir: &Path, id: &str) -> Result<(PathBuf, AgentHook)> {
210 let json_path = hooks_dir.join(format!("{}.json", id));
211 let yaml_path = hooks_dir.join(format!("{}.yaml", id));
212 let yml_path = hooks_dir.join(format!("{}.yml", id));
213
214 let hook_path = if json_path.exists() {
215 json_path
216 } else if yaml_path.exists() {
217 yaml_path
218 } else if yml_path.exists() {
219 yml_path
220 } else {
221 return Err(DrivenError::Config(format!(
222 "Hook with ID '{}' not found",
223 id
224 )));
225 };
226
227 let content = std::fs::read_to_string(&hook_path).map_err(DrivenError::Io)?;
228 let ext = hook_path.extension().and_then(|e| e.to_str()).unwrap_or("");
229
230 let hook: AgentHook = match ext {
231 "json" => {
232 serde_json::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string()))?
233 }
234 "yaml" | "yml" => {
235 serde_yaml::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string()))?
236 }
237 _ => {
238 return Err(DrivenError::Parse(format!(
239 "Unknown file extension: {}",
240 ext
241 )));
242 }
243 };
244
245 Ok((hook_path, hook))
246 }
247
248 fn parse_trigger(trigger_type: &str, trigger_value: &str) -> Result<HookTrigger> {
250 match trigger_type.to_lowercase().as_str() {
251 "file" | "file_change" | "filechange" => {
252 let patterns: Vec<String> = trigger_value
253 .split(',')
254 .map(|s| s.trim().to_string())
255 .collect();
256 Ok(HookTrigger::FileChange { patterns })
257 }
258 "git" | "git_operation" | "gitoperation" => {
259 let operations: Vec<GitOp> = trigger_value
260 .split(',')
261 .map(|s| Self::parse_git_op(s.trim()))
262 .collect::<Result<Vec<_>>>()?;
263 Ok(HookTrigger::GitOperation { operations })
264 }
265 "build" | "build_event" | "buildevent" => {
266 let events: Vec<BuildEvent> = trigger_value
267 .split(',')
268 .map(|s| Self::parse_build_event(s.trim()))
269 .collect::<Result<Vec<_>>>()?;
270 Ok(HookTrigger::BuildEvent { events })
271 }
272 "test" | "test_result" | "testresult" => {
273 let filter = Self::parse_test_filter(trigger_value)?;
274 Ok(HookTrigger::TestResult { filter })
275 }
276 "manual" => Ok(HookTrigger::Manual {
277 command: trigger_value.to_string(),
278 }),
279 "scheduled" | "cron" => Ok(HookTrigger::Scheduled {
280 cron: trigger_value.to_string(),
281 }),
282 _ => Err(DrivenError::Config(format!(
283 "Unknown trigger type: {}. Valid types: file, git, build, test, manual, scheduled",
284 trigger_type
285 ))),
286 }
287 }
288
289 fn parse_git_op(s: &str) -> Result<GitOp> {
291 match s.to_lowercase().as_str() {
292 "commit" => Ok(GitOp::Commit),
293 "push" => Ok(GitOp::Push),
294 "pull" => Ok(GitOp::Pull),
295 "merge" => Ok(GitOp::Merge),
296 "checkout" => Ok(GitOp::Checkout),
297 "stash" => Ok(GitOp::Stash),
298 _ => Err(DrivenError::Config(format!(
299 "Unknown git operation: {}. Valid: commit, push, pull, merge, checkout, stash",
300 s
301 ))),
302 }
303 }
304
305 fn parse_build_event(s: &str) -> Result<BuildEvent> {
307 match s.to_lowercase().as_str() {
308 "start" => Ok(BuildEvent::Start),
309 "success" => Ok(BuildEvent::Success),
310 "failure" | "fail" => Ok(BuildEvent::Failure),
311 "warning" | "warn" => Ok(BuildEvent::Warning),
312 _ => Err(DrivenError::Config(format!(
313 "Unknown build event: {}. Valid: start, success, failure, warning",
314 s
315 ))),
316 }
317 }
318
319 fn parse_test_filter(s: &str) -> Result<TestFilter> {
321 let mut filter = TestFilter::new();
322
323 for part in s.split(',') {
324 let part = part.trim();
325 if let Some((key, value)) = part.split_once(':') {
326 match key.trim().to_lowercase().as_str() {
327 "status" => {
328 filter.status = Some(Self::parse_test_status(value.trim())?);
329 }
330 "name" => {
331 filter.name_pattern = Some(value.trim().to_string());
332 }
333 "file" => {
334 filter.file_pattern = Some(value.trim().to_string());
335 }
336 _ => {
337 return Err(DrivenError::Config(format!(
338 "Unknown test filter key: {}. Valid: status, name, file",
339 key
340 )));
341 }
342 }
343 }
344 }
345
346 Ok(filter)
347 }
348
349 fn parse_test_status(s: &str) -> Result<TestStatus> {
351 match s.to_lowercase().as_str() {
352 "passed" | "pass" => Ok(TestStatus::Passed),
353 "failed" | "fail" => Ok(TestStatus::Failed),
354 "skipped" | "skip" => Ok(TestStatus::Skipped),
355 "timeout" => Ok(TestStatus::Timeout),
356 _ => Err(DrivenError::Config(format!(
357 "Unknown test status: {}. Valid: passed, failed, skipped, timeout",
358 s
359 ))),
360 }
361 }
362}
363
364#[derive(Debug, Clone)]
366pub struct HookInfo {
367 pub id: String,
368 pub name: String,
369 pub trigger_type: String,
370 pub trigger_value: String,
371 pub agent: String,
372 pub workflow: Option<String>,
373 pub enabled: bool,
374 pub priority: u8,
375 pub has_condition: bool,
376 pub chain_count: usize,
377}
378
379impl From<&AgentHook> for HookInfo {
380 fn from(hook: &AgentHook) -> Self {
381 let (trigger_type, trigger_value) = match &hook.trigger {
382 HookTrigger::FileChange { patterns } => {
383 ("file_change".to_string(), patterns.join(", "))
384 }
385 HookTrigger::GitOperation { operations } => {
386 let ops: Vec<&str> = operations
387 .iter()
388 .map(|o| match o {
389 GitOp::Commit => "commit",
390 GitOp::Push => "push",
391 GitOp::Pull => "pull",
392 GitOp::Merge => "merge",
393 GitOp::Checkout => "checkout",
394 GitOp::Stash => "stash",
395 })
396 .collect();
397 ("git_operation".to_string(), ops.join(", "))
398 }
399 HookTrigger::BuildEvent { events } => {
400 let evts: Vec<&str> = events
401 .iter()
402 .map(|e| match e {
403 BuildEvent::Start => "start",
404 BuildEvent::Success => "success",
405 BuildEvent::Failure => "failure",
406 BuildEvent::Warning => "warning",
407 })
408 .collect();
409 ("build_event".to_string(), evts.join(", "))
410 }
411 HookTrigger::TestResult { filter } => {
412 let mut parts = Vec::new();
413 if let Some(status) = &filter.status {
414 parts.push(format!("status:{:?}", status).to_lowercase());
415 }
416 if let Some(name) = &filter.name_pattern {
417 parts.push(format!("name:{}", name));
418 }
419 if let Some(file) = &filter.file_pattern {
420 parts.push(format!("file:{}", file));
421 }
422 ("test_result".to_string(), parts.join(", "))
423 }
424 HookTrigger::Manual { command } => ("manual".to_string(), command.clone()),
425 HookTrigger::Scheduled { cron } => ("scheduled".to_string(), cron.clone()),
426 };
427
428 Self {
429 id: hook.id.clone(),
430 name: hook.name.clone(),
431 trigger_type,
432 trigger_value,
433 agent: hook.action.agent.clone(),
434 workflow: hook.action.workflow.clone(),
435 enabled: hook.enabled,
436 priority: hook.priority,
437 has_condition: hook.condition.is_some(),
438 chain_count: hook.chain.as_ref().map_or(0, |c| c.len()),
439 }
440 }
441}
442
443#[derive(Debug, Clone)]
445pub struct TriggerResult {
446 pub hook_id: String,
447 pub success: bool,
448 pub output: Option<String>,
449 pub error: Option<String>,
450 pub duration_ms: u64,
451 pub chained: Vec<String>,
452}
453
454impl From<crate::hooks::HookExecutionResult> for TriggerResult {
455 fn from(result: crate::hooks::HookExecutionResult) -> Self {
456 Self {
457 hook_id: result.hook_id,
458 success: result.success,
459 output: result.output,
460 error: result.error,
461 duration_ms: result.duration_ms,
462 chained: result.chained,
463 }
464 }
465}
466
467pub fn print_hooks_table(hooks: &[HookInfo]) {
469 use console::style;
470
471 if hooks.is_empty() {
472 println!("No hooks configured.");
473 return;
474 }
475
476 println!(
478 "{:<20} {:<15} {:<30} {:<10} {:<8}",
479 style("ID").bold(),
480 style("Trigger").bold(),
481 style("Agent/Workflow").bold(),
482 style("Enabled").bold(),
483 style("Priority").bold(),
484 );
485 println!("{}", "-".repeat(85));
486
487 for hook in hooks {
489 let enabled = if hook.enabled {
490 style("✓").green().to_string()
491 } else {
492 style("✗").red().to_string()
493 };
494
495 let agent_workflow = if let Some(wf) = &hook.workflow {
496 format!("{}/{}", hook.agent, wf)
497 } else {
498 hook.agent.clone()
499 };
500
501 println!(
502 "{:<20} {:<15} {:<30} {:<10} {:<8}",
503 hook.id, hook.trigger_type, agent_workflow, enabled, hook.priority,
504 );
505 }
506}
507
508pub fn print_hook_details(hook: &HookInfo) {
510 use console::style;
511
512 println!("{}", style("Hook Details").bold().underlined());
513 println!();
514 println!(" {}: {}", style("ID").bold(), hook.id);
515 println!(" {}: {}", style("Name").bold(), hook.name);
516 println!(" {}: {}", style("Trigger Type").bold(), hook.trigger_type);
517 println!(
518 " {}: {}",
519 style("Trigger Value").bold(),
520 hook.trigger_value
521 );
522 println!(" {}: {}", style("Agent").bold(), hook.agent);
523 if let Some(wf) = &hook.workflow {
524 println!(" {}: {}", style("Workflow").bold(), wf);
525 }
526 println!(
527 " {}: {}",
528 style("Enabled").bold(),
529 if hook.enabled { "Yes" } else { "No" }
530 );
531 println!(" {}: {}", style("Priority").bold(), hook.priority);
532 println!(
533 " {}: {}",
534 style("Has Condition").bold(),
535 if hook.has_condition { "Yes" } else { "No" }
536 );
537 if hook.chain_count > 0 {
538 println!(" {}: {} hooks", style("Chain").bold(), hook.chain_count);
539 }
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use tempfile::TempDir;
546
547 #[test]
548 fn test_parse_trigger_file_change() {
549 let trigger = HookCommand::parse_trigger("file", "**/*.rs, **/*.py").unwrap();
550 match trigger {
551 HookTrigger::FileChange { patterns } => {
552 assert_eq!(patterns.len(), 2);
553 assert!(patterns.contains(&"**/*.rs".to_string()));
554 assert!(patterns.contains(&"**/*.py".to_string()));
555 }
556 _ => panic!("Wrong trigger type"),
557 }
558 }
559
560 #[test]
561 fn test_parse_trigger_git() {
562 let trigger = HookCommand::parse_trigger("git", "commit, push").unwrap();
563 match trigger {
564 HookTrigger::GitOperation { operations } => {
565 assert_eq!(operations.len(), 2);
566 assert!(operations.contains(&GitOp::Commit));
567 assert!(operations.contains(&GitOp::Push));
568 }
569 _ => panic!("Wrong trigger type"),
570 }
571 }
572
573 #[test]
574 fn test_parse_trigger_build() {
575 let trigger = HookCommand::parse_trigger("build", "failure, warning").unwrap();
576 match trigger {
577 HookTrigger::BuildEvent { events } => {
578 assert_eq!(events.len(), 2);
579 assert!(events.contains(&BuildEvent::Failure));
580 assert!(events.contains(&BuildEvent::Warning));
581 }
582 _ => panic!("Wrong trigger type"),
583 }
584 }
585
586 #[test]
587 fn test_parse_trigger_test() {
588 let trigger = HookCommand::parse_trigger("test", "status:failed, name:test_*").unwrap();
589 match trigger {
590 HookTrigger::TestResult { filter } => {
591 assert_eq!(filter.status, Some(TestStatus::Failed));
592 assert_eq!(filter.name_pattern, Some("test_*".to_string()));
593 }
594 _ => panic!("Wrong trigger type"),
595 }
596 }
597
598 #[test]
599 fn test_parse_trigger_manual() {
600 let trigger = HookCommand::parse_trigger("manual", "lint").unwrap();
601 match trigger {
602 HookTrigger::Manual { command } => {
603 assert_eq!(command, "lint");
604 }
605 _ => panic!("Wrong trigger type"),
606 }
607 }
608
609 #[test]
610 fn test_parse_trigger_scheduled() {
611 let trigger = HookCommand::parse_trigger("scheduled", "0 * * * *").unwrap();
612 match trigger {
613 HookTrigger::Scheduled { cron } => {
614 assert_eq!(cron, "0 * * * *");
615 }
616 _ => panic!("Wrong trigger type"),
617 }
618 }
619
620 #[test]
621 fn test_add_and_list_hooks() {
622 let temp_dir = TempDir::new().unwrap();
623 let project_root = temp_dir.path();
624
625 HookCommand::add(
627 project_root,
628 "test-hook",
629 Some("Test Hook"),
630 "manual",
631 "test",
632 "reviewer",
633 "Review the code",
634 Some("quick-review"),
635 None,
636 true,
637 )
638 .unwrap();
639
640 let hooks = HookCommand::list(project_root).unwrap();
642 assert_eq!(hooks.len(), 1);
643 assert_eq!(hooks[0].id, "test-hook");
644 assert_eq!(hooks[0].name, "Test Hook");
645 assert_eq!(hooks[0].agent, "reviewer");
646 assert_eq!(hooks[0].workflow, Some("quick-review".to_string()));
647 }
648
649 #[test]
650 fn test_remove_hook() {
651 let temp_dir = TempDir::new().unwrap();
652 let project_root = temp_dir.path();
653
654 HookCommand::add(
656 project_root,
657 "test-hook",
658 None,
659 "manual",
660 "test",
661 "reviewer",
662 "Review",
663 None,
664 None,
665 true,
666 )
667 .unwrap();
668
669 HookCommand::remove(project_root, "test-hook").unwrap();
671
672 let hooks = HookCommand::list(project_root).unwrap();
674 assert!(hooks.is_empty());
675 }
676
677 #[test]
678 fn test_enable_disable_hook() {
679 let temp_dir = TempDir::new().unwrap();
680 let project_root = temp_dir.path();
681
682 HookCommand::add(
684 project_root,
685 "test-hook",
686 None,
687 "manual",
688 "test",
689 "reviewer",
690 "Review",
691 None,
692 None,
693 true,
694 )
695 .unwrap();
696
697 HookCommand::disable(project_root, "test-hook").unwrap();
699 let hook = HookCommand::show(project_root, "test-hook").unwrap();
700 assert!(!hook.enabled);
701
702 HookCommand::enable(project_root, "test-hook").unwrap();
704 let hook = HookCommand::show(project_root, "test-hook").unwrap();
705 assert!(hook.enabled);
706 }
707
708 #[test]
709 fn test_trigger_manual_hook() {
710 let temp_dir = TempDir::new().unwrap();
711 let project_root = temp_dir.path();
712
713 HookCommand::add(
715 project_root,
716 "lint-hook",
717 None,
718 "manual",
719 "lint",
720 "reviewer",
721 "Lint the code",
722 None,
723 None,
724 true,
725 )
726 .unwrap();
727
728 let results = HookCommand::trigger(project_root, "lint").unwrap();
730 assert_eq!(results.len(), 1);
731 assert!(results[0].success);
732 assert_eq!(results[0].hook_id, "lint-hook");
733 }
734}