Skip to main content

driven/cli/
hook.rs

1//! Hook CLI Commands
2//!
3//! CLI commands for managing agent hooks.
4
5use crate::hooks::{
6    AgentHook, BuildEvent, GitOp, HookAction, HookCondition, HookEngine, HookTrigger, TestFilter,
7    TestStatus,
8};
9use crate::{DrivenError, Result};
10use std::path::{Path, PathBuf};
11
12/// Hook command handler
13pub struct HookCommand;
14
15impl HookCommand {
16    /// List all hooks
17    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    /// Add a new hook
31    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        // Load existing hooks
49        if hooks_dir.exists() {
50            engine.load_hooks(&hooks_dir)?;
51        }
52
53        // Check if hook already exists
54        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        // Parse trigger
62        let trigger = Self::parse_trigger(trigger_type, trigger_value)?;
63
64        // Create action
65        let mut action = HookAction::new(agent, message);
66        if let Some(wf) = workflow {
67            action = action.with_workflow(wf);
68        }
69
70        // Create hook
71        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        // Add condition if provided
78        if let Some(cond) = condition {
79            hook = hook.with_condition(HookCondition::new(cond));
80        }
81
82        // Save hook to file
83        let hook_path = hooks_dir.join(format!("{}.json", id));
84        engine.save_hook(&hook, &hook_path)?;
85
86        // Register hook
87        engine.register_hook(hook)?;
88
89        super::print_success(&format!("Hook '{}' added successfully", id));
90        Ok(())
91    }
92
93    /// Remove a hook
94    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        // Try to find and remove the hook file
102        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    /// Manually trigger a hook
131    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    /// Enable a hook
164    pub fn enable(project_root: &Path, id: &str) -> Result<()> {
165        Self::set_enabled(project_root, id, true)
166    }
167
168    /// Disable a hook
169    pub fn disable(project_root: &Path, id: &str) -> Result<()> {
170        Self::set_enabled(project_root, id, false)
171    }
172
173    /// Set hook enabled state
174    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        // Find the hook file
182        let (hook_path, mut hook) = Self::find_and_load_hook(&hooks_dir, id)?;
183
184        // Update enabled state
185        hook.enabled = enabled;
186
187        // Save back
188        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    /// Show hook details
197    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    /// Find and load a hook from the hooks directory
209    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    /// Parse trigger type and value into HookTrigger
249    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    /// Parse git operation string
290    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    /// Parse build event string
306    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    /// Parse test filter string (format: "status:failed,name:test_*")
320    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    /// Parse test status string
350    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/// Hook information for display
365#[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/// Trigger result for display
444#[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
467/// Print hooks in a formatted table
468pub 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    // Print header
477    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    // Print hooks
488    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
508/// Print detailed hook information
509pub 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        // Add a hook
626        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        // List hooks
641        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        // Add a hook
655        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        // Remove the hook
670        HookCommand::remove(project_root, "test-hook").unwrap();
671
672        // List should be empty
673        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        // Add a hook
683        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        // Disable the hook
698        HookCommand::disable(project_root, "test-hook").unwrap();
699        let hook = HookCommand::show(project_root, "test-hook").unwrap();
700        assert!(!hook.enabled);
701
702        // Enable the hook
703        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        // Add a manual hook
714        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        // Trigger the hook
729        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}