Skip to main content

driven/hooks/
mod.rs

1//! Agent Hooks System
2//!
3//! This module provides an event-driven automation system for triggering
4//! agent actions based on various events like file changes, git operations,
5//! build events, and more.
6//!
7//! ## Features
8//!
9//! - File change triggers with glob pattern matching
10//! - Git operation triggers (commit, push, pull, merge, checkout, stash)
11//! - Build and test event triggers
12//! - Manual and scheduled triggers
13//! - Conditional hook execution with expression evaluation
14//! - Hook chaining for sequential execution
15//!
16//! ## Usage
17//!
18//! ```rust,ignore
19//! use driven::hooks::{AgentHook, HookTrigger, HookAction, HookEngine};
20//!
21//! let hook = AgentHook::new("on-save-lint")
22//!     .with_trigger(HookTrigger::FileChange {
23//!         patterns: vec!["**/*.rs".to_string()],
24//!     })
25//!     .with_action(HookAction {
26//!         agent: "reviewer".to_string(),
27//!         workflow: Some("quick-review".to_string()),
28//!         message: "Review the changed file".to_string(),
29//!         context: Default::default(),
30//!     });
31//!
32//! let mut engine = HookEngine::new();
33//! engine.register_hook(hook)?;
34//! engine.start()?;
35//! ```
36
37use crate::{DrivenError, Result};
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41
42/// Agent hook definition
43///
44/// Represents a hook that triggers agent actions based on events.
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentHook {
47    /// Unique identifier for the hook
48    pub id: String,
49    /// Human-readable name
50    pub name: String,
51    /// What triggers this hook
52    pub trigger: HookTrigger,
53    /// Optional condition for filtering
54    pub condition: Option<HookCondition>,
55    /// Action to perform when triggered
56    pub action: HookAction,
57    /// Whether the hook is enabled
58    pub enabled: bool,
59    /// IDs of hooks to trigger after this one completes
60    pub chain: Option<Vec<String>>,
61    /// Priority (lower = higher priority)
62    pub priority: u8,
63}
64
65impl AgentHook {
66    /// Create a new hook with the given ID
67    pub fn new(id: impl Into<String>) -> Self {
68        let id = id.into();
69        Self {
70            name: id.clone(),
71            id,
72            trigger: HookTrigger::Manual {
73                command: "default".to_string(),
74            },
75            condition: None,
76            action: HookAction::default(),
77            enabled: true,
78            chain: None,
79            priority: 100,
80        }
81    }
82
83    /// Set the hook name
84    pub fn with_name(mut self, name: impl Into<String>) -> Self {
85        self.name = name.into();
86        self
87    }
88
89    /// Set the trigger
90    pub fn with_trigger(mut self, trigger: HookTrigger) -> Self {
91        self.trigger = trigger;
92        self
93    }
94
95    /// Set the condition
96    pub fn with_condition(mut self, condition: HookCondition) -> Self {
97        self.condition = Some(condition);
98        self
99    }
100
101    /// Set the action
102    pub fn with_action(mut self, action: HookAction) -> Self {
103        self.action = action;
104        self
105    }
106
107    /// Set enabled state
108    pub fn with_enabled(mut self, enabled: bool) -> Self {
109        self.enabled = enabled;
110        self
111    }
112
113    /// Set the chain of hooks to trigger after
114    pub fn with_chain(mut self, chain: Vec<String>) -> Self {
115        self.chain = Some(chain);
116        self
117    }
118
119    /// Set the priority
120    pub fn with_priority(mut self, priority: u8) -> Self {
121        self.priority = priority;
122        self
123    }
124
125    /// Check if this hook matches a file change event
126    pub fn matches_file_change(&self, path: &Path) -> bool {
127        if !self.enabled {
128            return false;
129        }
130
131        match &self.trigger {
132            HookTrigger::FileChange { patterns } => {
133                let path_str = path.to_string_lossy();
134                patterns
135                    .iter()
136                    .any(|pattern| glob_match(pattern, &path_str))
137            }
138            _ => false,
139        }
140    }
141
142    /// Check if this hook matches a git operation
143    pub fn matches_git_op(&self, op: &GitOp) -> bool {
144        if !self.enabled {
145            return false;
146        }
147
148        match &self.trigger {
149            HookTrigger::GitOperation { operations } => operations.contains(op),
150            _ => false,
151        }
152    }
153
154    /// Check if this hook matches a build event
155    pub fn matches_build_event(&self, event: &BuildEvent) -> bool {
156        if !self.enabled {
157            return false;
158        }
159
160        match &self.trigger {
161            HookTrigger::BuildEvent { events } => events.contains(event),
162            _ => false,
163        }
164    }
165
166    /// Check if this hook matches a test result
167    pub fn matches_test_result(&self, result: &TestResult) -> bool {
168        if !self.enabled {
169            return false;
170        }
171
172        match &self.trigger {
173            HookTrigger::TestResult { filter } => filter.matches(result),
174            _ => false,
175        }
176    }
177
178    /// Check if this hook matches a manual command
179    pub fn matches_manual(&self, command: &str) -> bool {
180        if !self.enabled {
181            return false;
182        }
183
184        match &self.trigger {
185            HookTrigger::Manual { command: cmd } => cmd == command,
186            _ => false,
187        }
188    }
189
190    /// Evaluate the condition against a context
191    pub fn evaluate_condition(&self, context: &HookContext) -> bool {
192        match &self.condition {
193            Some(condition) => condition.evaluate(context),
194            None => true, // No condition means always pass
195        }
196    }
197}
198
199/// Hook trigger types
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201#[serde(tag = "type", rename_all = "snake_case")]
202pub enum HookTrigger {
203    /// Triggered when files matching patterns change
204    FileChange {
205        /// Glob patterns to match
206        patterns: Vec<String>,
207    },
208    /// Triggered on git operations
209    GitOperation {
210        /// Git operations to watch
211        operations: Vec<GitOp>,
212    },
213    /// Triggered on build events
214    BuildEvent {
215        /// Build events to watch
216        events: Vec<BuildEvent>,
217    },
218    /// Triggered on test results
219    TestResult {
220        /// Filter for test results
221        filter: TestFilter,
222    },
223    /// Triggered manually via command
224    Manual {
225        /// Command name
226        command: String,
227    },
228    /// Triggered on a schedule
229    Scheduled {
230        /// Cron expression
231        cron: String,
232    },
233}
234
235/// Git operations that can trigger hooks
236#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
237#[serde(rename_all = "snake_case")]
238pub enum GitOp {
239    /// Git commit
240    Commit,
241    /// Git push
242    Push,
243    /// Git pull
244    Pull,
245    /// Git merge
246    Merge,
247    /// Git checkout
248    Checkout,
249    /// Git stash
250    Stash,
251}
252
253/// Build events that can trigger hooks
254#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
255#[serde(rename_all = "snake_case")]
256pub enum BuildEvent {
257    /// Build started
258    Start,
259    /// Build succeeded
260    Success,
261    /// Build failed
262    Failure,
263    /// Build completed with warnings
264    Warning,
265}
266
267/// Filter for test results
268#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct TestFilter {
270    /// Match on test status
271    pub status: Option<TestStatus>,
272    /// Match on test name pattern
273    pub name_pattern: Option<String>,
274    /// Match on test file pattern
275    pub file_pattern: Option<String>,
276}
277
278impl TestFilter {
279    /// Create a new test filter
280    pub fn new() -> Self {
281        Self {
282            status: None,
283            name_pattern: None,
284            file_pattern: None,
285        }
286    }
287
288    /// Filter by status
289    pub fn with_status(mut self, status: TestStatus) -> Self {
290        self.status = Some(status);
291        self
292    }
293
294    /// Filter by name pattern
295    pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
296        self.name_pattern = Some(pattern.into());
297        self
298    }
299
300    /// Filter by file pattern
301    pub fn with_file_pattern(mut self, pattern: impl Into<String>) -> Self {
302        self.file_pattern = Some(pattern.into());
303        self
304    }
305
306    /// Check if a test result matches this filter
307    pub fn matches(&self, result: &TestResult) -> bool {
308        // Check status
309        if let Some(status) = &self.status {
310            if result.status != *status {
311                return false;
312            }
313        }
314
315        // Check name pattern
316        if let Some(pattern) = &self.name_pattern {
317            if !glob_match(pattern, &result.name) {
318                return false;
319            }
320        }
321
322        // Check file pattern
323        if let Some(pattern) = &self.file_pattern {
324            if let Some(file) = &result.file {
325                if !glob_match(pattern, &file.to_string_lossy()) {
326                    return false;
327                }
328            } else {
329                return false;
330            }
331        }
332
333        true
334    }
335}
336
337impl Default for TestFilter {
338    fn default() -> Self {
339        Self::new()
340    }
341}
342
343/// Test status
344#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
345#[serde(rename_all = "snake_case")]
346pub enum TestStatus {
347    /// Test passed
348    Passed,
349    /// Test failed
350    Failed,
351    /// Test was skipped
352    Skipped,
353    /// Test timed out
354    Timeout,
355}
356
357/// Test result information
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct TestResult {
360    /// Test name
361    pub name: String,
362    /// Test status
363    pub status: TestStatus,
364    /// Test file (if known)
365    pub file: Option<PathBuf>,
366    /// Test duration in milliseconds
367    pub duration_ms: Option<u64>,
368    /// Error message (if failed)
369    pub error: Option<String>,
370}
371
372/// Hook condition for filtering
373#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct HookCondition {
375    /// Expression to evaluate (e.g., "file.ext == 'rs' && file.path.contains('src')")
376    pub expression: String,
377}
378
379impl HookCondition {
380    /// Create a new condition
381    pub fn new(expression: impl Into<String>) -> Self {
382        Self {
383            expression: expression.into(),
384        }
385    }
386
387    /// Evaluate the condition against a context
388    pub fn evaluate(&self, context: &HookContext) -> bool {
389        // Simple expression evaluation
390        // Supports: file.ext, file.path, file.name, file.size
391        // Operators: ==, !=, contains, starts_with, ends_with, &&, ||
392
393        let expr = self.expression.trim();
394
395        // Handle && (AND)
396        if let Some(pos) = expr.find("&&") {
397            let left = &expr[..pos].trim();
398            let right = &expr[pos + 2..].trim();
399            let left_cond = HookCondition::new(*left);
400            let right_cond = HookCondition::new(*right);
401            return left_cond.evaluate(context) && right_cond.evaluate(context);
402        }
403
404        // Handle || (OR)
405        if let Some(pos) = expr.find("||") {
406            let left = &expr[..pos].trim();
407            let right = &expr[pos + 2..].trim();
408            let left_cond = HookCondition::new(*left);
409            let right_cond = HookCondition::new(*right);
410            return left_cond.evaluate(context) || right_cond.evaluate(context);
411        }
412
413        // Handle simple comparisons
414        self.evaluate_simple(expr, context)
415    }
416
417    fn evaluate_simple(&self, expr: &str, context: &HookContext) -> bool {
418        // file.ext == 'rs'
419        if let Some(pos) = expr.find("==") {
420            let left = expr[..pos].trim();
421            let right = expr[pos + 2..].trim().trim_matches('\'').trim_matches('"');
422            return self.get_value(left, context) == Some(right.to_string());
423        }
424
425        // file.ext != 'rs'
426        if let Some(pos) = expr.find("!=") {
427            let left = expr[..pos].trim();
428            let right = expr[pos + 2..].trim().trim_matches('\'').trim_matches('"');
429            return self.get_value(left, context) != Some(right.to_string());
430        }
431
432        // file.path.contains('src')
433        if expr.contains(".contains(") {
434            if let Some(start) = expr.find(".contains(") {
435                let field = expr[..start].trim();
436                let arg_start = start + 10;
437                if let Some(arg_end) = expr[arg_start..].find(')') {
438                    let arg = expr[arg_start..arg_start + arg_end]
439                        .trim()
440                        .trim_matches('\'')
441                        .trim_matches('"');
442                    if let Some(value) = self.get_value(field, context) {
443                        return value.contains(arg);
444                    }
445                }
446            }
447            return false;
448        }
449
450        // file.path.starts_with('src')
451        if expr.contains(".starts_with(") {
452            if let Some(start) = expr.find(".starts_with(") {
453                let field = expr[..start].trim();
454                let arg_start = start + 13;
455                if let Some(arg_end) = expr[arg_start..].find(')') {
456                    let arg = expr[arg_start..arg_start + arg_end]
457                        .trim()
458                        .trim_matches('\'')
459                        .trim_matches('"');
460                    if let Some(value) = self.get_value(field, context) {
461                        return value.starts_with(arg);
462                    }
463                }
464            }
465            return false;
466        }
467
468        // file.path.ends_with('.rs')
469        if expr.contains(".ends_with(") {
470            if let Some(start) = expr.find(".ends_with(") {
471                let field = expr[..start].trim();
472                let arg_start = start + 11;
473                if let Some(arg_end) = expr[arg_start..].find(')') {
474                    let arg = expr[arg_start..arg_start + arg_end]
475                        .trim()
476                        .trim_matches('\'')
477                        .trim_matches('"');
478                    if let Some(value) = self.get_value(field, context) {
479                        return value.ends_with(arg);
480                    }
481                }
482            }
483            return false;
484        }
485
486        // Unknown expression - default to false
487        false
488    }
489
490    fn get_value(&self, field: &str, context: &HookContext) -> Option<String> {
491        match field {
492            "file.ext" => context.file_ext.clone(),
493            "file.path" => context
494                .file_path
495                .as_ref()
496                .map(|p| p.to_string_lossy().to_string()),
497            "file.name" => context.file_name.clone(),
498            "file.size" => context.file_size.map(|s| s.to_string()),
499            _ => context.variables.get(field).cloned(),
500        }
501    }
502}
503
504/// Context for hook condition evaluation
505#[derive(Debug, Clone, Default)]
506pub struct HookContext {
507    /// File extension (without dot)
508    pub file_ext: Option<String>,
509    /// File path
510    pub file_path: Option<PathBuf>,
511    /// File name
512    pub file_name: Option<String>,
513    /// File size in bytes
514    pub file_size: Option<u64>,
515    /// Additional variables
516    pub variables: HashMap<String, String>,
517}
518
519impl HookContext {
520    /// Create a new empty context
521    pub fn new() -> Self {
522        Self::default()
523    }
524
525    /// Create a context from a file path
526    pub fn from_path(path: &Path) -> Self {
527        let file_ext = path.extension().map(|e| e.to_string_lossy().to_string());
528        let file_name = path.file_name().map(|n| n.to_string_lossy().to_string());
529        let file_size = std::fs::metadata(path).ok().map(|m| m.len());
530
531        Self {
532            file_ext,
533            file_path: Some(path.to_path_buf()),
534            file_name,
535            file_size,
536            variables: HashMap::new(),
537        }
538    }
539
540    /// Set a variable
541    pub fn set_variable(&mut self, key: impl Into<String>, value: impl Into<String>) {
542        self.variables.insert(key.into(), value.into());
543    }
544
545    /// Get a variable
546    pub fn get_variable(&self, key: &str) -> Option<&String> {
547        self.variables.get(key)
548    }
549}
550
551/// Action to perform when a hook is triggered
552#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
553pub struct HookAction {
554    /// Agent to invoke
555    pub agent: String,
556    /// Optional workflow to run
557    pub workflow: Option<String>,
558    /// Message to send to the agent
559    pub message: String,
560    /// Additional context variables
561    pub context: HashMap<String, String>,
562}
563
564impl Default for HookAction {
565    fn default() -> Self {
566        Self {
567            agent: "default".to_string(),
568            workflow: None,
569            message: String::new(),
570            context: HashMap::new(),
571        }
572    }
573}
574
575impl HookAction {
576    /// Create a new action
577    pub fn new(agent: impl Into<String>, message: impl Into<String>) -> Self {
578        Self {
579            agent: agent.into(),
580            workflow: None,
581            message: message.into(),
582            context: HashMap::new(),
583        }
584    }
585
586    /// Set the workflow
587    pub fn with_workflow(mut self, workflow: impl Into<String>) -> Self {
588        self.workflow = Some(workflow.into());
589        self
590    }
591
592    /// Add a context variable
593    pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
594        self.context.insert(key.into(), value.into());
595        self
596    }
597}
598
599/// Result of hook execution
600#[derive(Debug, Clone)]
601pub struct HookExecutionResult {
602    /// Hook ID
603    pub hook_id: String,
604    /// Whether execution succeeded
605    pub success: bool,
606    /// Output from the agent
607    pub output: Option<String>,
608    /// Error message if failed
609    pub error: Option<String>,
610    /// Duration in milliseconds
611    pub duration_ms: u64,
612    /// Chained hooks that were triggered
613    pub chained: Vec<String>,
614}
615
616/// Hook engine for managing and executing hooks
617pub struct HookEngine {
618    /// Registered hooks
619    hooks: Vec<AgentHook>,
620    /// Hooks directory
621    hooks_dir: PathBuf,
622    /// Whether the engine is running
623    running: bool,
624}
625
626impl HookEngine {
627    /// Create a new hook engine
628    pub fn new() -> Self {
629        Self {
630            hooks: Vec::new(),
631            hooks_dir: PathBuf::from(".driven/hooks"),
632            running: false,
633        }
634    }
635
636    /// Create a hook engine with a custom hooks directory
637    pub fn with_hooks_dir(hooks_dir: impl Into<PathBuf>) -> Self {
638        Self {
639            hooks: Vec::new(),
640            hooks_dir: hooks_dir.into(),
641            running: false,
642        }
643    }
644
645    /// Register a hook
646    pub fn register_hook(&mut self, hook: AgentHook) -> Result<()> {
647        // Check for duplicate ID
648        if self.hooks.iter().any(|h| h.id == hook.id) {
649            return Err(DrivenError::Config(format!(
650                "Hook with ID '{}' already exists",
651                hook.id
652            )));
653        }
654
655        self.hooks.push(hook);
656
657        // Sort by priority
658        self.hooks.sort_by_key(|h| h.priority);
659
660        Ok(())
661    }
662
663    /// Unregister a hook by ID
664    pub fn unregister_hook(&mut self, id: &str) -> Result<AgentHook> {
665        let pos = self
666            .hooks
667            .iter()
668            .position(|h| h.id == id)
669            .ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
670
671        Ok(self.hooks.remove(pos))
672    }
673
674    /// Get a hook by ID
675    pub fn get_hook(&self, id: &str) -> Option<&AgentHook> {
676        self.hooks.iter().find(|h| h.id == id)
677    }
678
679    /// Get a mutable reference to a hook by ID
680    pub fn get_hook_mut(&mut self, id: &str) -> Option<&mut AgentHook> {
681        self.hooks.iter_mut().find(|h| h.id == id)
682    }
683
684    /// List all hooks
685    pub fn list_hooks(&self) -> &[AgentHook] {
686        &self.hooks
687    }
688
689    /// Enable a hook
690    pub fn enable_hook(&mut self, id: &str) -> Result<()> {
691        let hook = self
692            .get_hook_mut(id)
693            .ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
694        hook.enabled = true;
695        Ok(())
696    }
697
698    /// Disable a hook
699    pub fn disable_hook(&mut self, id: &str) -> Result<()> {
700        let hook = self
701            .get_hook_mut(id)
702            .ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
703        hook.enabled = false;
704        Ok(())
705    }
706
707    /// Find hooks that match a file change
708    pub fn find_file_change_hooks(&self, path: &Path) -> Vec<&AgentHook> {
709        self.hooks
710            .iter()
711            .filter(|h| h.matches_file_change(path))
712            .collect()
713    }
714
715    /// Find hooks that match a git operation
716    pub fn find_git_op_hooks(&self, op: &GitOp) -> Vec<&AgentHook> {
717        self.hooks.iter().filter(|h| h.matches_git_op(op)).collect()
718    }
719
720    /// Find hooks that match a build event
721    pub fn find_build_event_hooks(&self, event: &BuildEvent) -> Vec<&AgentHook> {
722        self.hooks
723            .iter()
724            .filter(|h| h.matches_build_event(event))
725            .collect()
726    }
727
728    /// Find hooks that match a test result
729    pub fn find_test_result_hooks(&self, result: &TestResult) -> Vec<&AgentHook> {
730        self.hooks
731            .iter()
732            .filter(|h| h.matches_test_result(result))
733            .collect()
734    }
735
736    /// Find hooks that match a manual command
737    pub fn find_manual_hooks(&self, command: &str) -> Vec<&AgentHook> {
738        self.hooks
739            .iter()
740            .filter(|h| h.matches_manual(command))
741            .collect()
742    }
743
744    /// Trigger hooks for a file change event
745    pub fn trigger_file_change(&self, path: &Path) -> Vec<HookExecutionResult> {
746        let context = HookContext::from_path(path);
747        let hooks = self.find_file_change_hooks(path);
748
749        hooks
750            .iter()
751            .filter(|h| h.evaluate_condition(&context))
752            .map(|h| self.execute_hook(h, &context))
753            .collect()
754    }
755
756    /// Trigger hooks for a git operation
757    pub fn trigger_git_op(&self, op: &GitOp) -> Vec<HookExecutionResult> {
758        let context = HookContext::new();
759        let hooks = self.find_git_op_hooks(op);
760
761        hooks
762            .iter()
763            .filter(|h| h.evaluate_condition(&context))
764            .map(|h| self.execute_hook(h, &context))
765            .collect()
766    }
767
768    /// Trigger hooks for a build event
769    pub fn trigger_build_event(&self, event: &BuildEvent) -> Vec<HookExecutionResult> {
770        let context = HookContext::new();
771        let hooks = self.find_build_event_hooks(event);
772
773        hooks
774            .iter()
775            .filter(|h| h.evaluate_condition(&context))
776            .map(|h| self.execute_hook(h, &context))
777            .collect()
778    }
779
780    /// Trigger hooks for a test result
781    pub fn trigger_test_result(&self, result: &TestResult) -> Vec<HookExecutionResult> {
782        let mut context = HookContext::new();
783        context.set_variable("test.name", &result.name);
784        context.set_variable("test.status", format!("{:?}", result.status));
785        if let Some(file) = &result.file {
786            context.file_path = Some(file.clone());
787        }
788
789        let hooks = self.find_test_result_hooks(result);
790
791        hooks
792            .iter()
793            .filter(|h| h.evaluate_condition(&context))
794            .map(|h| self.execute_hook(h, &context))
795            .collect()
796    }
797
798    /// Trigger a manual hook
799    pub fn trigger_manual(&self, command: &str) -> Vec<HookExecutionResult> {
800        let context = HookContext::new();
801        let hooks = self.find_manual_hooks(command);
802
803        hooks
804            .iter()
805            .filter(|h| h.evaluate_condition(&context))
806            .map(|h| self.execute_hook(h, &context))
807            .collect()
808    }
809
810    /// Execute a single hook
811    fn execute_hook(&self, hook: &AgentHook, _context: &HookContext) -> HookExecutionResult {
812        let start = std::time::Instant::now();
813
814        // TODO: Actually execute the hook action
815        // For now, just return a placeholder result
816        let duration_ms = start.elapsed().as_millis() as u64;
817
818        // Handle chaining
819        let chained = hook.chain.clone().unwrap_or_default();
820
821        HookExecutionResult {
822            hook_id: hook.id.clone(),
823            success: true,
824            output: Some(format!("Hook '{}' executed", hook.name)),
825            error: None,
826            duration_ms,
827            chained,
828        }
829    }
830
831    /// Load hooks from the hooks directory
832    pub fn load_hooks(&mut self, path: &Path) -> Result<usize> {
833        if !path.exists() {
834            return Ok(0);
835        }
836
837        let mut count = 0;
838
839        for entry in std::fs::read_dir(path).map_err(DrivenError::Io)? {
840            let entry = entry.map_err(DrivenError::Io)?;
841            let path = entry.path();
842
843            if path
844                .extension()
845                .is_some_and(|e| e == "json" || e == "yaml" || e == "yml")
846            {
847                match self.load_hook_file(&path) {
848                    Ok(hook) => {
849                        if let Err(e) = self.register_hook(hook) {
850                            tracing::warn!("Failed to register hook from {:?}: {}", path, e);
851                        } else {
852                            count += 1;
853                        }
854                    }
855                    Err(e) => {
856                        tracing::warn!("Failed to load hook from {:?}: {}", path, e);
857                    }
858                }
859            }
860        }
861
862        Ok(count)
863    }
864
865    /// Load a single hook from a file
866    fn load_hook_file(&self, path: &Path) -> Result<AgentHook> {
867        let content = std::fs::read_to_string(path).map_err(DrivenError::Io)?;
868
869        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
870
871        match ext {
872            "json" => serde_json::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string())),
873            "yaml" | "yml" => {
874                serde_yaml::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string()))
875            }
876            _ => Err(DrivenError::Parse(format!(
877                "Unknown file extension: {}",
878                ext
879            ))),
880        }
881    }
882
883    /// Save a hook to a file
884    pub fn save_hook(&self, hook: &AgentHook, path: &Path) -> Result<()> {
885        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
886
887        let content = match ext {
888            "json" => serde_json::to_string_pretty(hook)
889                .map_err(|e| DrivenError::Format(e.to_string()))?,
890            "yaml" | "yml" => {
891                serde_yaml::to_string(hook).map_err(|e| DrivenError::Format(e.to_string()))?
892            }
893            _ => {
894                return Err(DrivenError::Format(format!(
895                    "Unknown file extension: {}",
896                    ext
897                )));
898            }
899        };
900
901        // Ensure parent directory exists
902        if let Some(parent) = path.parent() {
903            std::fs::create_dir_all(parent).map_err(DrivenError::Io)?;
904        }
905
906        std::fs::write(path, content).map_err(DrivenError::Io)?;
907
908        Ok(())
909    }
910
911    /// Get the hooks directory
912    pub fn hooks_dir(&self) -> &Path {
913        &self.hooks_dir
914    }
915
916    /// Check if the engine is running
917    pub fn is_running(&self) -> bool {
918        self.running
919    }
920
921    /// Start the hook engine
922    pub fn start(&mut self) -> Result<()> {
923        if self.running {
924            return Err(DrivenError::Config(
925                "Hook engine already running".to_string(),
926            ));
927        }
928
929        // Load hooks from directory
930        self.load_hooks(&self.hooks_dir.clone())?;
931
932        self.running = true;
933        Ok(())
934    }
935
936    /// Stop the hook engine
937    pub fn stop(&mut self) {
938        self.running = false;
939    }
940}
941
942impl Default for HookEngine {
943    fn default() -> Self {
944        Self::new()
945    }
946}
947
948/// Simple glob pattern matching
949fn glob_match(pattern: &str, text: &str) -> bool {
950    // Handle ** (match any path)
951    if pattern.contains("**") {
952        let parts: Vec<&str> = pattern.split("**").collect();
953        if parts.len() == 2 {
954            let prefix = parts[0].trim_end_matches('/');
955            let suffix = parts[1].trim_start_matches('/');
956
957            if !prefix.is_empty() && !text.starts_with(prefix) {
958                return false;
959            }
960            if !suffix.is_empty() && !glob_match(suffix, text.rsplit('/').next().unwrap_or(text)) {
961                return false;
962            }
963            return true;
964        }
965    }
966
967    // Handle * (match any characters except /)
968    if pattern.contains('*') && !pattern.contains("**") {
969        let parts: Vec<&str> = pattern.split('*').collect();
970        let mut pos = 0;
971
972        for (i, part) in parts.iter().enumerate() {
973            if part.is_empty() {
974                continue;
975            }
976
977            if i == 0 {
978                // First part must be at the start
979                if !text.starts_with(part) {
980                    return false;
981                }
982                pos = part.len();
983            } else if i == parts.len() - 1 {
984                // Last part must be at the end
985                if !text.ends_with(part) {
986                    return false;
987                }
988            } else {
989                // Middle parts must exist somewhere
990                if let Some(found) = text[pos..].find(part) {
991                    pos += found + part.len();
992                } else {
993                    return false;
994                }
995            }
996        }
997
998        return true;
999    }
1000
1001    // Exact match
1002    pattern == text
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::*;
1008
1009    #[test]
1010    fn test_agent_hook_creation() {
1011        let hook = AgentHook::new("test-hook")
1012            .with_name("Test Hook")
1013            .with_trigger(HookTrigger::FileChange {
1014                patterns: vec!["**/*.rs".to_string()],
1015            })
1016            .with_action(HookAction::new("reviewer", "Review this file"))
1017            .with_priority(50);
1018
1019        assert_eq!(hook.id, "test-hook");
1020        assert_eq!(hook.name, "Test Hook");
1021        assert!(hook.enabled);
1022        assert_eq!(hook.priority, 50);
1023    }
1024
1025    #[test]
1026    fn test_file_change_matching() {
1027        let hook = AgentHook::new("rust-hook").with_trigger(HookTrigger::FileChange {
1028            patterns: vec!["**/*.rs".to_string()],
1029        });
1030
1031        assert!(hook.matches_file_change(Path::new("src/main.rs")));
1032        assert!(hook.matches_file_change(Path::new("lib.rs")));
1033        assert!(!hook.matches_file_change(Path::new("src/main.py")));
1034    }
1035
1036    #[test]
1037    fn test_git_op_matching() {
1038        let hook = AgentHook::new("git-hook").with_trigger(HookTrigger::GitOperation {
1039            operations: vec![GitOp::Commit, GitOp::Push],
1040        });
1041
1042        assert!(hook.matches_git_op(&GitOp::Commit));
1043        assert!(hook.matches_git_op(&GitOp::Push));
1044        assert!(!hook.matches_git_op(&GitOp::Pull));
1045    }
1046
1047    #[test]
1048    fn test_build_event_matching() {
1049        let hook = AgentHook::new("build-hook").with_trigger(HookTrigger::BuildEvent {
1050            events: vec![BuildEvent::Failure, BuildEvent::Warning],
1051        });
1052
1053        assert!(hook.matches_build_event(&BuildEvent::Failure));
1054        assert!(hook.matches_build_event(&BuildEvent::Warning));
1055        assert!(!hook.matches_build_event(&BuildEvent::Success));
1056    }
1057
1058    #[test]
1059    fn test_test_filter() {
1060        let filter = TestFilter::new()
1061            .with_status(TestStatus::Failed)
1062            .with_name_pattern("test_*");
1063
1064        let passing_result = TestResult {
1065            name: "test_something".to_string(),
1066            status: TestStatus::Failed,
1067            file: None,
1068            duration_ms: Some(100),
1069            error: Some("assertion failed".to_string()),
1070        };
1071
1072        let non_matching_result = TestResult {
1073            name: "test_something".to_string(),
1074            status: TestStatus::Passed,
1075            file: None,
1076            duration_ms: Some(50),
1077            error: None,
1078        };
1079
1080        assert!(filter.matches(&passing_result));
1081        assert!(!filter.matches(&non_matching_result));
1082    }
1083
1084    #[test]
1085    fn test_hook_condition_simple() {
1086        let condition = HookCondition::new("file.ext == 'rs'");
1087
1088        let mut context = HookContext::new();
1089        context.file_ext = Some("rs".to_string());
1090
1091        assert!(condition.evaluate(&context));
1092
1093        context.file_ext = Some("py".to_string());
1094        assert!(!condition.evaluate(&context));
1095    }
1096
1097    #[test]
1098    fn test_hook_condition_and() {
1099        let condition = HookCondition::new("file.ext == 'rs' && file.path.contains('src')");
1100
1101        let mut context = HookContext::new();
1102        context.file_ext = Some("rs".to_string());
1103        context.file_path = Some(PathBuf::from("src/main.rs"));
1104
1105        assert!(condition.evaluate(&context));
1106
1107        context.file_path = Some(PathBuf::from("tests/main.rs"));
1108        assert!(!condition.evaluate(&context));
1109    }
1110
1111    #[test]
1112    fn test_hook_condition_or() {
1113        let condition = HookCondition::new("file.ext == 'rs' || file.ext == 'py'");
1114
1115        let mut context = HookContext::new();
1116        context.file_ext = Some("rs".to_string());
1117        assert!(condition.evaluate(&context));
1118
1119        context.file_ext = Some("py".to_string());
1120        assert!(condition.evaluate(&context));
1121
1122        context.file_ext = Some("js".to_string());
1123        assert!(!condition.evaluate(&context));
1124    }
1125
1126    #[test]
1127    fn test_hook_condition_contains() {
1128        let condition = HookCondition::new("file.path.contains('src')");
1129
1130        let mut context = HookContext::new();
1131        context.file_path = Some(PathBuf::from("src/lib.rs"));
1132        assert!(condition.evaluate(&context));
1133
1134        context.file_path = Some(PathBuf::from("tests/lib.rs"));
1135        assert!(!condition.evaluate(&context));
1136    }
1137
1138    #[test]
1139    fn test_hook_condition_starts_with() {
1140        let condition = HookCondition::new("file.path.starts_with('src')");
1141
1142        let mut context = HookContext::new();
1143        context.file_path = Some(PathBuf::from("src/lib.rs"));
1144        assert!(condition.evaluate(&context));
1145
1146        context.file_path = Some(PathBuf::from("tests/lib.rs"));
1147        assert!(!condition.evaluate(&context));
1148    }
1149
1150    #[test]
1151    fn test_hook_condition_ends_with() {
1152        let condition = HookCondition::new("file.path.ends_with('.rs')");
1153
1154        let mut context = HookContext::new();
1155        context.file_path = Some(PathBuf::from("src/lib.rs"));
1156        assert!(condition.evaluate(&context));
1157
1158        context.file_path = Some(PathBuf::from("src/lib.py"));
1159        assert!(!condition.evaluate(&context));
1160    }
1161
1162    #[test]
1163    fn test_hook_engine_register() {
1164        let mut engine = HookEngine::new();
1165
1166        let hook = AgentHook::new("test-hook");
1167        engine.register_hook(hook).unwrap();
1168
1169        assert_eq!(engine.list_hooks().len(), 1);
1170        assert!(engine.get_hook("test-hook").is_some());
1171    }
1172
1173    #[test]
1174    fn test_hook_engine_duplicate_id() {
1175        let mut engine = HookEngine::new();
1176
1177        let hook1 = AgentHook::new("test-hook");
1178        let hook2 = AgentHook::new("test-hook");
1179
1180        engine.register_hook(hook1).unwrap();
1181        assert!(engine.register_hook(hook2).is_err());
1182    }
1183
1184    #[test]
1185    fn test_hook_engine_unregister() {
1186        let mut engine = HookEngine::new();
1187
1188        let hook = AgentHook::new("test-hook");
1189        engine.register_hook(hook).unwrap();
1190
1191        let removed = engine.unregister_hook("test-hook").unwrap();
1192        assert_eq!(removed.id, "test-hook");
1193        assert!(engine.get_hook("test-hook").is_none());
1194    }
1195
1196    #[test]
1197    fn test_hook_engine_enable_disable() {
1198        let mut engine = HookEngine::new();
1199
1200        let hook = AgentHook::new("test-hook");
1201        engine.register_hook(hook).unwrap();
1202
1203        engine.disable_hook("test-hook").unwrap();
1204        assert!(!engine.get_hook("test-hook").unwrap().enabled);
1205
1206        engine.enable_hook("test-hook").unwrap();
1207        assert!(engine.get_hook("test-hook").unwrap().enabled);
1208    }
1209
1210    #[test]
1211    fn test_hook_engine_find_file_change_hooks() {
1212        let mut engine = HookEngine::new();
1213
1214        let rust_hook = AgentHook::new("rust-hook").with_trigger(HookTrigger::FileChange {
1215            patterns: vec!["**/*.rs".to_string()],
1216        });
1217
1218        let python_hook = AgentHook::new("python-hook").with_trigger(HookTrigger::FileChange {
1219            patterns: vec!["**/*.py".to_string()],
1220        });
1221
1222        engine.register_hook(rust_hook).unwrap();
1223        engine.register_hook(python_hook).unwrap();
1224
1225        let hooks = engine.find_file_change_hooks(Path::new("src/main.rs"));
1226        assert_eq!(hooks.len(), 1);
1227        assert_eq!(hooks[0].id, "rust-hook");
1228    }
1229
1230    #[test]
1231    fn test_hook_engine_priority_ordering() {
1232        let mut engine = HookEngine::new();
1233
1234        let low_priority = AgentHook::new("low").with_priority(200);
1235        let high_priority = AgentHook::new("high").with_priority(50);
1236        let medium_priority = AgentHook::new("medium").with_priority(100);
1237
1238        engine.register_hook(low_priority).unwrap();
1239        engine.register_hook(high_priority).unwrap();
1240        engine.register_hook(medium_priority).unwrap();
1241
1242        let hooks = engine.list_hooks();
1243        assert_eq!(hooks[0].id, "high");
1244        assert_eq!(hooks[1].id, "medium");
1245        assert_eq!(hooks[2].id, "low");
1246    }
1247
1248    #[test]
1249    fn test_glob_match_star() {
1250        assert!(glob_match("*.rs", "main.rs"));
1251        assert!(glob_match("*.rs", "lib.rs"));
1252        assert!(!glob_match("*.rs", "main.py"));
1253        assert!(glob_match("test_*", "test_something"));
1254        assert!(!glob_match("test_*", "something_test"));
1255    }
1256
1257    #[test]
1258    fn test_glob_match_double_star() {
1259        assert!(glob_match("**/*.rs", "src/main.rs"));
1260        assert!(glob_match("**/*.rs", "src/lib/mod.rs"));
1261        assert!(glob_match("**/*.rs", "main.rs"));
1262        assert!(!glob_match("**/*.rs", "main.py"));
1263    }
1264
1265    #[test]
1266    fn test_glob_match_exact() {
1267        assert!(glob_match("main.rs", "main.rs"));
1268        assert!(!glob_match("main.rs", "lib.rs"));
1269    }
1270
1271    #[test]
1272    fn test_hook_action() {
1273        let action = HookAction::new("reviewer", "Review this code")
1274            .with_workflow("quick-review")
1275            .with_context("file", "main.rs");
1276
1277        assert_eq!(action.agent, "reviewer");
1278        assert_eq!(action.workflow, Some("quick-review".to_string()));
1279        assert_eq!(action.message, "Review this code");
1280        assert_eq!(action.context.get("file"), Some(&"main.rs".to_string()));
1281    }
1282
1283    #[test]
1284    fn test_hook_context_from_path() {
1285        let context = HookContext::from_path(Path::new("src/main.rs"));
1286
1287        assert_eq!(context.file_ext, Some("rs".to_string()));
1288        assert_eq!(context.file_name, Some("main.rs".to_string()));
1289        assert!(context.file_path.is_some());
1290    }
1291
1292    #[test]
1293    fn test_manual_hook_matching() {
1294        let hook = AgentHook::new("manual-hook").with_trigger(HookTrigger::Manual {
1295            command: "lint".to_string(),
1296        });
1297
1298        assert!(hook.matches_manual("lint"));
1299        assert!(!hook.matches_manual("test"));
1300    }
1301
1302    #[test]
1303    fn test_disabled_hook_no_match() {
1304        let hook = AgentHook::new("disabled-hook")
1305            .with_trigger(HookTrigger::FileChange {
1306                patterns: vec!["**/*.rs".to_string()],
1307            })
1308            .with_enabled(false);
1309
1310        assert!(!hook.matches_file_change(Path::new("src/main.rs")));
1311    }
1312
1313    #[test]
1314    fn test_hook_with_chain() {
1315        let hook = AgentHook::new("chained-hook")
1316            .with_chain(vec!["hook2".to_string(), "hook3".to_string()]);
1317
1318        assert_eq!(
1319            hook.chain,
1320            Some(vec!["hook2".to_string(), "hook3".to_string()])
1321        );
1322    }
1323}
1324
1325#[cfg(test)]
1326mod property_tests {
1327    use super::*;
1328    use proptest::prelude::*;
1329
1330    /// Generate an arbitrary file extension
1331    fn arb_extension() -> impl Strategy<Value = String> {
1332        prop_oneof![
1333            Just("rs".to_string()),
1334            Just("py".to_string()),
1335            Just("js".to_string()),
1336            Just("ts".to_string()),
1337            Just("md".to_string()),
1338            Just("json".to_string()),
1339            Just("yaml".to_string()),
1340        ]
1341    }
1342
1343    /// Generate an arbitrary file path
1344    fn arb_file_path() -> impl Strategy<Value = PathBuf> {
1345        (
1346            prop_oneof![Just("src"), Just("tests"), Just("lib"), Just("bin"),],
1347            "[a-z_]+",
1348            arb_extension(),
1349        )
1350            .prop_map(|(dir, name, ext)| PathBuf::from(format!("{}/{}.{}", dir, name, ext)))
1351    }
1352
1353    /// Generate an arbitrary GitOp
1354    fn arb_git_op() -> impl Strategy<Value = GitOp> {
1355        prop_oneof![
1356            Just(GitOp::Commit),
1357            Just(GitOp::Push),
1358            Just(GitOp::Pull),
1359            Just(GitOp::Merge),
1360            Just(GitOp::Checkout),
1361            Just(GitOp::Stash),
1362        ]
1363    }
1364
1365    /// Generate an arbitrary BuildEvent
1366    fn arb_build_event() -> impl Strategy<Value = BuildEvent> {
1367        prop_oneof![
1368            Just(BuildEvent::Start),
1369            Just(BuildEvent::Success),
1370            Just(BuildEvent::Failure),
1371            Just(BuildEvent::Warning),
1372        ]
1373    }
1374
1375    /// Generate an arbitrary TestStatus
1376    fn arb_test_status() -> impl Strategy<Value = TestStatus> {
1377        prop_oneof![
1378            Just(TestStatus::Passed),
1379            Just(TestStatus::Failed),
1380            Just(TestStatus::Skipped),
1381            Just(TestStatus::Timeout),
1382        ]
1383    }
1384
1385    proptest! {
1386        #![proptest_config(ProptestConfig::with_cases(100))]
1387
1388        /// Property 8: Event-Based Hook Triggering
1389        /// *For any* file change, git operation, build event, or test result that matches
1390        /// a hook's trigger pattern, the hook SHALL be triggered exactly once.
1391        /// **Validates: Requirements 3.1, 3.2, 3.3, 3.4**
1392        #[test]
1393        fn prop_file_change_triggers_matching_hooks(
1394            path in arb_file_path(),
1395            ext in arb_extension(),
1396        ) {
1397            let mut engine = HookEngine::new();
1398
1399            // Create a hook that matches the extension
1400            let pattern = format!("**/*.{}", ext);
1401            let hook = AgentHook::new("test-hook")
1402                .with_trigger(HookTrigger::FileChange {
1403                    patterns: vec![pattern],
1404                });
1405            engine.register_hook(hook).unwrap();
1406
1407            // Create a path with the matching extension
1408            let test_path = PathBuf::from(format!("src/test.{}", ext));
1409
1410            // Find matching hooks
1411            let hooks = engine.find_file_change_hooks(&test_path);
1412
1413            // Should find exactly one hook
1414            prop_assert_eq!(hooks.len(), 1);
1415            prop_assert_eq!(&hooks[0].id, "test-hook");
1416        }
1417
1418        /// Property 8b: Git operation triggers matching hooks
1419        #[test]
1420        fn prop_git_op_triggers_matching_hooks(
1421            op in arb_git_op(),
1422        ) {
1423            let mut engine = HookEngine::new();
1424
1425            // Create a hook that matches the operation
1426            let hook = AgentHook::new("git-hook")
1427                .with_trigger(HookTrigger::GitOperation {
1428                    operations: vec![op],
1429                });
1430            engine.register_hook(hook).unwrap();
1431
1432            // Find matching hooks
1433            let hooks = engine.find_git_op_hooks(&op);
1434
1435            // Should find exactly one hook
1436            prop_assert_eq!(hooks.len(), 1);
1437            prop_assert_eq!(&hooks[0].id, "git-hook");
1438        }
1439
1440        /// Property 8c: Build event triggers matching hooks
1441        #[test]
1442        fn prop_build_event_triggers_matching_hooks(
1443            event in arb_build_event(),
1444        ) {
1445            let mut engine = HookEngine::new();
1446
1447            // Create a hook that matches the event
1448            let hook = AgentHook::new("build-hook")
1449                .with_trigger(HookTrigger::BuildEvent {
1450                    events: vec![event],
1451                });
1452            engine.register_hook(hook).unwrap();
1453
1454            // Find matching hooks
1455            let hooks = engine.find_build_event_hooks(&event);
1456
1457            // Should find exactly one hook
1458            prop_assert_eq!(hooks.len(), 1);
1459            prop_assert_eq!(&hooks[0].id, "build-hook");
1460        }
1461
1462        /// Property 8d: Test result triggers matching hooks
1463        #[test]
1464        fn prop_test_result_triggers_matching_hooks(
1465            status in arb_test_status(),
1466            name in "[a-z_]+",
1467        ) {
1468            let mut engine = HookEngine::new();
1469
1470            // Create a hook that matches the status
1471            let hook = AgentHook::new("test-hook")
1472                .with_trigger(HookTrigger::TestResult {
1473                    filter: TestFilter::new().with_status(status),
1474                });
1475            engine.register_hook(hook).unwrap();
1476
1477            // Create a test result
1478            let result = TestResult {
1479                name: name.clone(),
1480                status,
1481                file: None,
1482                duration_ms: Some(100),
1483                error: None,
1484            };
1485
1486            // Find matching hooks
1487            let hooks = engine.find_test_result_hooks(&result);
1488
1489            // Should find exactly one hook
1490            prop_assert_eq!(hooks.len(), 1);
1491            prop_assert_eq!(&hooks[0].id, "test-hook");
1492        }
1493
1494        /// Property 3: Hook Trigger Execution
1495        /// *For any* configured hook with a matching trigger event, the hook's action
1496        /// SHALL be executed exactly once per trigger occurrence.
1497        /// **Validates: Requirements 3.5, 3.6, 3.7, 3.8, 3.9**
1498        #[test]
1499        fn prop_hook_trigger_execution_once(
1500            ext in arb_extension(),
1501            num_triggers in 1usize..5,
1502        ) {
1503            let mut engine = HookEngine::new();
1504
1505            // Create a hook that matches the extension
1506            let pattern = format!("**/*.{}", ext);
1507            let hook = AgentHook::new("exec-hook")
1508                .with_trigger(HookTrigger::FileChange {
1509                    patterns: vec![pattern],
1510                })
1511                .with_action(HookAction::new("test-agent", "Test message"));
1512            engine.register_hook(hook).unwrap();
1513
1514            // Trigger multiple times with matching files
1515            let mut total_executions = 0;
1516            for i in 0..num_triggers {
1517                let test_path = PathBuf::from(format!("src/file{}.{}", i, ext));
1518                let results = engine.trigger_file_change(&test_path);
1519                total_executions += results.len();
1520            }
1521
1522            // Each trigger should execute exactly once
1523            prop_assert_eq!(total_executions, num_triggers,
1524                "Expected {} executions, got {}", num_triggers, total_executions);
1525        }
1526
1527        /// Property 3b: Manual hook trigger execution
1528        /// *For any* manual hook, triggering it SHALL execute exactly once.
1529        /// **Validates: Requirements 3.6, 3.9**
1530        #[test]
1531        fn prop_manual_hook_trigger_execution(
1532            command in "[a-z_]+",
1533        ) {
1534            let mut engine = HookEngine::new();
1535
1536            // Create a manual hook
1537            let hook = AgentHook::new("manual-hook")
1538                .with_trigger(HookTrigger::Manual {
1539                    command: command.clone(),
1540                })
1541                .with_action(HookAction::new("test-agent", "Manual trigger"));
1542            engine.register_hook(hook).unwrap();
1543
1544            // Trigger the manual hook
1545            let results = engine.trigger_manual(&command);
1546
1547            // Should execute exactly once
1548            prop_assert_eq!(results.len(), 1,
1549                "Manual hook should execute exactly once, got {}", results.len());
1550            prop_assert!(results[0].success,
1551                "Manual hook execution should succeed");
1552        }
1553
1554        /// Property 3c: Disabled hooks should not execute
1555        /// *For any* disabled hook, triggering it SHALL NOT execute.
1556        /// **Validates: Requirements 3.3, 3.4**
1557        #[test]
1558        fn prop_disabled_hook_no_execution(
1559            ext in arb_extension(),
1560        ) {
1561            let mut engine = HookEngine::new();
1562
1563            // Create a disabled hook
1564            let pattern = format!("**/*.{}", ext);
1565            let hook = AgentHook::new("disabled-hook")
1566                .with_trigger(HookTrigger::FileChange {
1567                    patterns: vec![pattern],
1568                })
1569                .with_enabled(false);
1570            engine.register_hook(hook).unwrap();
1571
1572            // Try to trigger
1573            let test_path = PathBuf::from(format!("src/test.{}", ext));
1574            let results = engine.trigger_file_change(&test_path);
1575
1576            // Should not execute
1577            prop_assert_eq!(results.len(), 0,
1578                "Disabled hook should not execute, got {} executions", results.len());
1579        }        /// Property 9: Hook Condition Filtering
1580        /// *For any* hook with a condition, the hook action SHALL execute if and only if
1581        /// the condition evaluates to true.
1582        /// **Validates: Requirements 3.8**
1583        #[test]
1584        fn prop_condition_filtering(
1585            ext in arb_extension(),
1586            path_contains in prop_oneof![Just("src"), Just("tests"), Just("lib")],
1587        ) {
1588            // Create a condition that checks extension
1589            let condition = HookCondition::new(format!("file.ext == '{}'", ext));
1590
1591            // Create a context with matching extension
1592            let mut context = HookContext::new();
1593            context.file_ext = Some(ext.clone());
1594
1595            // Condition should evaluate to true
1596            prop_assert!(condition.evaluate(&context));
1597
1598            // Create a context with non-matching extension
1599            let mut context2 = HookContext::new();
1600            context2.file_ext = Some("different".to_string());
1601
1602            // Condition should evaluate to false
1603            prop_assert!(!condition.evaluate(&context2));
1604        }
1605
1606        /// Property 9b: Compound condition filtering (AND)
1607        #[test]
1608        fn prop_compound_condition_and(
1609            ext in arb_extension(),
1610            dir in prop_oneof![Just("src"), Just("tests"), Just("lib")],
1611        ) {
1612            // Create a compound condition
1613            let condition = HookCondition::new(
1614                format!("file.ext == '{}' && file.path.contains('{}')", ext, dir)
1615            );
1616
1617            // Create a context that matches both
1618            let mut context = HookContext::new();
1619            context.file_ext = Some(ext.clone());
1620            context.file_path = Some(PathBuf::from(format!("{}/test.{}", dir, ext)));
1621
1622            // Condition should evaluate to true
1623            prop_assert!(condition.evaluate(&context));
1624
1625            // Create a context that matches only one
1626            let mut context2 = HookContext::new();
1627            context2.file_ext = Some(ext.clone());
1628            context2.file_path = Some(PathBuf::from("other/test.rs"));
1629
1630            // Condition should evaluate to false (AND requires both)
1631            prop_assert!(!condition.evaluate(&context2));
1632        }
1633
1634        /// Property 9c: Compound condition filtering (OR)
1635        #[test]
1636        fn prop_compound_condition_or(
1637            ext1 in arb_extension(),
1638            ext2 in arb_extension(),
1639        ) {
1640            // Create a compound condition
1641            let condition = HookCondition::new(
1642                format!("file.ext == '{}' || file.ext == '{}'", ext1, ext2)
1643            );
1644
1645            // Create a context that matches first
1646            let mut context1 = HookContext::new();
1647            context1.file_ext = Some(ext1.clone());
1648
1649            // Condition should evaluate to true
1650            prop_assert!(condition.evaluate(&context1));
1651
1652            // Create a context that matches second
1653            let mut context2 = HookContext::new();
1654            context2.file_ext = Some(ext2.clone());
1655
1656            // Condition should evaluate to true
1657            prop_assert!(condition.evaluate(&context2));
1658
1659            // Create a context that matches neither
1660            let mut context3 = HookContext::new();
1661            context3.file_ext = Some("nomatch".to_string());
1662
1663            // Condition should evaluate to false (unless ext1 or ext2 is "nomatch")
1664            if ext1 != "nomatch" && ext2 != "nomatch" {
1665                prop_assert!(!condition.evaluate(&context3));
1666            }
1667        }
1668
1669        /// Property 10: Hook Chaining Execution Order
1670        /// *For any* hook chain, hooks SHALL execute in the specified order.
1671        /// **Validates: Requirements 3.9**
1672        #[test]
1673        fn prop_hook_chaining_order(
1674            chain_ids in prop::collection::vec("[a-z]+", 1..5),
1675        ) {
1676            let mut engine = HookEngine::new();
1677
1678            // Create the main hook with a chain
1679            let main_hook = AgentHook::new("main")
1680                .with_trigger(HookTrigger::Manual { command: "test".to_string() })
1681                .with_chain(chain_ids.clone());
1682            engine.register_hook(main_hook).unwrap();
1683
1684            // Create the chained hooks
1685            for id in &chain_ids {
1686                let hook = AgentHook::new(id.clone())
1687                    .with_trigger(HookTrigger::Manual { command: id.clone() });
1688                let _ = engine.register_hook(hook); // Ignore duplicates
1689            }
1690
1691            // Trigger the main hook
1692            let results = engine.trigger_manual("test");
1693
1694            // Should have triggered the main hook
1695            prop_assert_eq!(results.len(), 1);
1696
1697            // The result should contain the chain
1698            prop_assert_eq!(&results[0].chained, &chain_ids);
1699        }
1700
1701        /// Property 11: Hook Configuration Persistence
1702        /// *For any* hook configuration, saving and loading SHALL produce an equivalent configuration.
1703        /// **Validates: Requirements 3.10**
1704        #[test]
1705        fn prop_hook_persistence_roundtrip(
1706            id in "[a-z_]+",
1707            name in "[a-zA-Z ]+",
1708            ext in arb_extension(),
1709            enabled in any::<bool>(),
1710            priority in 0u8..255,
1711        ) {
1712            let hook = AgentHook::new(id.clone())
1713                .with_name(name.clone())
1714                .with_trigger(HookTrigger::FileChange {
1715                    patterns: vec![format!("**/*.{}", ext)],
1716                })
1717                .with_enabled(enabled)
1718                .with_priority(priority);
1719
1720            // Serialize to JSON
1721            let json = serde_json::to_string(&hook).expect("Should serialize");
1722
1723            // Deserialize back
1724            let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
1725
1726            // Verify fields match
1727            prop_assert_eq!(loaded.id, id);
1728            prop_assert_eq!(loaded.name, name);
1729            prop_assert_eq!(loaded.enabled, enabled);
1730            prop_assert_eq!(loaded.priority, priority);
1731
1732            // Verify trigger matches
1733            match &loaded.trigger {
1734                HookTrigger::FileChange { patterns } => {
1735                    prop_assert_eq!(patterns.len(), 1);
1736                    prop_assert!(patterns[0].ends_with(&ext));
1737                }
1738                _ => prop_assert!(false, "Wrong trigger type"),
1739            }
1740        }
1741
1742        /// Property 2: Hook State Consistency
1743        /// *For any* hook, enabling then disabling (or vice versa) SHALL result in the hook
1744        /// being in the final requested state, and the state SHALL persist across operations.
1745        /// **Validates: Requirements 3.3, 3.4, 3.10**
1746        #[test]
1747        fn prop_hook_state_consistency(
1748            id in "[a-z_]+",
1749            initial_enabled in any::<bool>(),
1750            operations in prop::collection::vec(any::<bool>(), 1..10),
1751        ) {
1752            let mut engine = HookEngine::new();
1753
1754            // Create a hook with initial state
1755            let hook = AgentHook::new(id.clone())
1756                .with_enabled(initial_enabled);
1757            engine.register_hook(hook).unwrap();
1758
1759            // Apply a sequence of enable/disable operations
1760            let mut expected_state = initial_enabled;
1761            for enable in &operations {
1762                if *enable {
1763                    engine.enable_hook(&id).unwrap();
1764                    expected_state = true;
1765                } else {
1766                    engine.disable_hook(&id).unwrap();
1767                    expected_state = false;
1768                }
1769
1770                // Verify state after each operation
1771                let hook = engine.get_hook(&id).unwrap();
1772                prop_assert_eq!(hook.enabled, expected_state,
1773                    "Hook state mismatch after operation: expected {}, got {}",
1774                    expected_state, hook.enabled);
1775            }
1776
1777            // Final state should match the last operation
1778            let final_hook = engine.get_hook(&id).unwrap();
1779            prop_assert_eq!(final_hook.enabled, expected_state);
1780        }
1781
1782        /// Property 2b: Hook state persists through serialization
1783        /// *For any* hook state change, the state SHALL persist through save/load cycle.
1784        /// **Validates: Requirements 3.3, 3.4, 3.10**
1785        #[test]
1786        fn prop_hook_state_persistence(
1787            id in "[a-z_]+",
1788            name in "[a-zA-Z ]+",
1789            enabled_sequence in prop::collection::vec(any::<bool>(), 1..5),
1790        ) {
1791            // Start with a hook
1792            let mut hook = AgentHook::new(id.clone())
1793                .with_name(name.clone())
1794                .with_enabled(true);
1795
1796            // Apply state changes and verify persistence after each
1797            for enabled in enabled_sequence {
1798                hook.enabled = enabled;
1799
1800                // Serialize
1801                let json = serde_json::to_string(&hook).expect("Should serialize");
1802
1803                // Deserialize
1804                let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
1805
1806                // State should persist
1807                prop_assert_eq!(loaded.enabled, enabled,
1808                    "Hook enabled state did not persist: expected {}, got {}",
1809                    enabled, loaded.enabled);
1810                prop_assert_eq!(loaded.id, id.clone());
1811                prop_assert_eq!(loaded.name, name.clone());
1812            }
1813        }
1814    }
1815}