1use crate::{DrivenError, Result};
38use serde::{Deserialize, Serialize};
39use std::collections::HashMap;
40use std::path::{Path, PathBuf};
41
42#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct AgentHook {
47 pub id: String,
49 pub name: String,
51 pub trigger: HookTrigger,
53 pub condition: Option<HookCondition>,
55 pub action: HookAction,
57 pub enabled: bool,
59 pub chain: Option<Vec<String>>,
61 pub priority: u8,
63}
64
65impl AgentHook {
66 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 pub fn with_name(mut self, name: impl Into<String>) -> Self {
85 self.name = name.into();
86 self
87 }
88
89 pub fn with_trigger(mut self, trigger: HookTrigger) -> Self {
91 self.trigger = trigger;
92 self
93 }
94
95 pub fn with_condition(mut self, condition: HookCondition) -> Self {
97 self.condition = Some(condition);
98 self
99 }
100
101 pub fn with_action(mut self, action: HookAction) -> Self {
103 self.action = action;
104 self
105 }
106
107 pub fn with_enabled(mut self, enabled: bool) -> Self {
109 self.enabled = enabled;
110 self
111 }
112
113 pub fn with_chain(mut self, chain: Vec<String>) -> Self {
115 self.chain = Some(chain);
116 self
117 }
118
119 pub fn with_priority(mut self, priority: u8) -> Self {
121 self.priority = priority;
122 self
123 }
124
125 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 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 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 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 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 pub fn evaluate_condition(&self, context: &HookContext) -> bool {
192 match &self.condition {
193 Some(condition) => condition.evaluate(context),
194 None => true, }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201#[serde(tag = "type", rename_all = "snake_case")]
202pub enum HookTrigger {
203 FileChange {
205 patterns: Vec<String>,
207 },
208 GitOperation {
210 operations: Vec<GitOp>,
212 },
213 BuildEvent {
215 events: Vec<BuildEvent>,
217 },
218 TestResult {
220 filter: TestFilter,
222 },
223 Manual {
225 command: String,
227 },
228 Scheduled {
230 cron: String,
232 },
233}
234
235#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
237#[serde(rename_all = "snake_case")]
238pub enum GitOp {
239 Commit,
241 Push,
243 Pull,
245 Merge,
247 Checkout,
249 Stash,
251}
252
253#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
255#[serde(rename_all = "snake_case")]
256pub enum BuildEvent {
257 Start,
259 Success,
261 Failure,
263 Warning,
265}
266
267#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
269pub struct TestFilter {
270 pub status: Option<TestStatus>,
272 pub name_pattern: Option<String>,
274 pub file_pattern: Option<String>,
276}
277
278impl TestFilter {
279 pub fn new() -> Self {
281 Self {
282 status: None,
283 name_pattern: None,
284 file_pattern: None,
285 }
286 }
287
288 pub fn with_status(mut self, status: TestStatus) -> Self {
290 self.status = Some(status);
291 self
292 }
293
294 pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
296 self.name_pattern = Some(pattern.into());
297 self
298 }
299
300 pub fn with_file_pattern(mut self, pattern: impl Into<String>) -> Self {
302 self.file_pattern = Some(pattern.into());
303 self
304 }
305
306 pub fn matches(&self, result: &TestResult) -> bool {
308 if let Some(status) = &self.status {
310 if result.status != *status {
311 return false;
312 }
313 }
314
315 if let Some(pattern) = &self.name_pattern {
317 if !glob_match(pattern, &result.name) {
318 return false;
319 }
320 }
321
322 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#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
345#[serde(rename_all = "snake_case")]
346pub enum TestStatus {
347 Passed,
349 Failed,
351 Skipped,
353 Timeout,
355}
356
357#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct TestResult {
360 pub name: String,
362 pub status: TestStatus,
364 pub file: Option<PathBuf>,
366 pub duration_ms: Option<u64>,
368 pub error: Option<String>,
370}
371
372#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
374pub struct HookCondition {
375 pub expression: String,
377}
378
379impl HookCondition {
380 pub fn new(expression: impl Into<String>) -> Self {
382 Self {
383 expression: expression.into(),
384 }
385 }
386
387 pub fn evaluate(&self, context: &HookContext) -> bool {
389 let expr = self.expression.trim();
394
395 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 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 self.evaluate_simple(expr, context)
415 }
416
417 fn evaluate_simple(&self, expr: &str, context: &HookContext) -> bool {
418 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 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 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 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 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 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#[derive(Debug, Clone, Default)]
506pub struct HookContext {
507 pub file_ext: Option<String>,
509 pub file_path: Option<PathBuf>,
511 pub file_name: Option<String>,
513 pub file_size: Option<u64>,
515 pub variables: HashMap<String, String>,
517}
518
519impl HookContext {
520 pub fn new() -> Self {
522 Self::default()
523 }
524
525 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 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 pub fn get_variable(&self, key: &str) -> Option<&String> {
547 self.variables.get(key)
548 }
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
553pub struct HookAction {
554 pub agent: String,
556 pub workflow: Option<String>,
558 pub message: String,
560 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 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 pub fn with_workflow(mut self, workflow: impl Into<String>) -> Self {
588 self.workflow = Some(workflow.into());
589 self
590 }
591
592 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#[derive(Debug, Clone)]
601pub struct HookExecutionResult {
602 pub hook_id: String,
604 pub success: bool,
606 pub output: Option<String>,
608 pub error: Option<String>,
610 pub duration_ms: u64,
612 pub chained: Vec<String>,
614}
615
616pub struct HookEngine {
618 hooks: Vec<AgentHook>,
620 hooks_dir: PathBuf,
622 running: bool,
624}
625
626impl HookEngine {
627 pub fn new() -> Self {
629 Self {
630 hooks: Vec::new(),
631 hooks_dir: PathBuf::from(".driven/hooks"),
632 running: false,
633 }
634 }
635
636 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 pub fn register_hook(&mut self, hook: AgentHook) -> Result<()> {
647 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 self.hooks.sort_by_key(|h| h.priority);
659
660 Ok(())
661 }
662
663 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 pub fn get_hook(&self, id: &str) -> Option<&AgentHook> {
676 self.hooks.iter().find(|h| h.id == id)
677 }
678
679 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 pub fn list_hooks(&self) -> &[AgentHook] {
686 &self.hooks
687 }
688
689 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 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 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 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 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 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 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 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 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 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 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 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 fn execute_hook(&self, hook: &AgentHook, _context: &HookContext) -> HookExecutionResult {
812 let start = std::time::Instant::now();
813
814 let duration_ms = start.elapsed().as_millis() as u64;
817
818 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 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 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 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 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 pub fn hooks_dir(&self) -> &Path {
913 &self.hooks_dir
914 }
915
916 pub fn is_running(&self) -> bool {
918 self.running
919 }
920
921 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 self.load_hooks(&self.hooks_dir.clone())?;
931
932 self.running = true;
933 Ok(())
934 }
935
936 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
948fn glob_match(pattern: &str, text: &str) -> bool {
950 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 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 if !text.starts_with(part) {
980 return false;
981 }
982 pos = part.len();
983 } else if i == parts.len() - 1 {
984 if !text.ends_with(part) {
986 return false;
987 }
988 } else {
989 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 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 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 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 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 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 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 #[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 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 let test_path = PathBuf::from(format!("src/test.{}", ext));
1409
1410 let hooks = engine.find_file_change_hooks(&test_path);
1412
1413 prop_assert_eq!(hooks.len(), 1);
1415 prop_assert_eq!(&hooks[0].id, "test-hook");
1416 }
1417
1418 #[test]
1420 fn prop_git_op_triggers_matching_hooks(
1421 op in arb_git_op(),
1422 ) {
1423 let mut engine = HookEngine::new();
1424
1425 let hook = AgentHook::new("git-hook")
1427 .with_trigger(HookTrigger::GitOperation {
1428 operations: vec![op],
1429 });
1430 engine.register_hook(hook).unwrap();
1431
1432 let hooks = engine.find_git_op_hooks(&op);
1434
1435 prop_assert_eq!(hooks.len(), 1);
1437 prop_assert_eq!(&hooks[0].id, "git-hook");
1438 }
1439
1440 #[test]
1442 fn prop_build_event_triggers_matching_hooks(
1443 event in arb_build_event(),
1444 ) {
1445 let mut engine = HookEngine::new();
1446
1447 let hook = AgentHook::new("build-hook")
1449 .with_trigger(HookTrigger::BuildEvent {
1450 events: vec![event],
1451 });
1452 engine.register_hook(hook).unwrap();
1453
1454 let hooks = engine.find_build_event_hooks(&event);
1456
1457 prop_assert_eq!(hooks.len(), 1);
1459 prop_assert_eq!(&hooks[0].id, "build-hook");
1460 }
1461
1462 #[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 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 let result = TestResult {
1479 name: name.clone(),
1480 status,
1481 file: None,
1482 duration_ms: Some(100),
1483 error: None,
1484 };
1485
1486 let hooks = engine.find_test_result_hooks(&result);
1488
1489 prop_assert_eq!(hooks.len(), 1);
1491 prop_assert_eq!(&hooks[0].id, "test-hook");
1492 }
1493
1494 #[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 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 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 prop_assert_eq!(total_executions, num_triggers,
1524 "Expected {} executions, got {}", num_triggers, total_executions);
1525 }
1526
1527 #[test]
1531 fn prop_manual_hook_trigger_execution(
1532 command in "[a-z_]+",
1533 ) {
1534 let mut engine = HookEngine::new();
1535
1536 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 let results = engine.trigger_manual(&command);
1546
1547 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 #[test]
1558 fn prop_disabled_hook_no_execution(
1559 ext in arb_extension(),
1560 ) {
1561 let mut engine = HookEngine::new();
1562
1563 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 let test_path = PathBuf::from(format!("src/test.{}", ext));
1574 let results = engine.trigger_file_change(&test_path);
1575
1576 prop_assert_eq!(results.len(), 0,
1578 "Disabled hook should not execute, got {} executions", results.len());
1579 } #[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 let condition = HookCondition::new(format!("file.ext == '{}'", ext));
1590
1591 let mut context = HookContext::new();
1593 context.file_ext = Some(ext.clone());
1594
1595 prop_assert!(condition.evaluate(&context));
1597
1598 let mut context2 = HookContext::new();
1600 context2.file_ext = Some("different".to_string());
1601
1602 prop_assert!(!condition.evaluate(&context2));
1604 }
1605
1606 #[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 let condition = HookCondition::new(
1614 format!("file.ext == '{}' && file.path.contains('{}')", ext, dir)
1615 );
1616
1617 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 prop_assert!(condition.evaluate(&context));
1624
1625 let mut context2 = HookContext::new();
1627 context2.file_ext = Some(ext.clone());
1628 context2.file_path = Some(PathBuf::from("other/test.rs"));
1629
1630 prop_assert!(!condition.evaluate(&context2));
1632 }
1633
1634 #[test]
1636 fn prop_compound_condition_or(
1637 ext1 in arb_extension(),
1638 ext2 in arb_extension(),
1639 ) {
1640 let condition = HookCondition::new(
1642 format!("file.ext == '{}' || file.ext == '{}'", ext1, ext2)
1643 );
1644
1645 let mut context1 = HookContext::new();
1647 context1.file_ext = Some(ext1.clone());
1648
1649 prop_assert!(condition.evaluate(&context1));
1651
1652 let mut context2 = HookContext::new();
1654 context2.file_ext = Some(ext2.clone());
1655
1656 prop_assert!(condition.evaluate(&context2));
1658
1659 let mut context3 = HookContext::new();
1661 context3.file_ext = Some("nomatch".to_string());
1662
1663 if ext1 != "nomatch" && ext2 != "nomatch" {
1665 prop_assert!(!condition.evaluate(&context3));
1666 }
1667 }
1668
1669 #[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 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 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); }
1690
1691 let results = engine.trigger_manual("test");
1693
1694 prop_assert_eq!(results.len(), 1);
1696
1697 prop_assert_eq!(&results[0].chained, &chain_ids);
1699 }
1700
1701 #[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 let json = serde_json::to_string(&hook).expect("Should serialize");
1722
1723 let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
1725
1726 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 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 #[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 let hook = AgentHook::new(id.clone())
1756 .with_enabled(initial_enabled);
1757 engine.register_hook(hook).unwrap();
1758
1759 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 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 let final_hook = engine.get_hook(&id).unwrap();
1779 prop_assert_eq!(final_hook.enabled, expected_state);
1780 }
1781
1782 #[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 let mut hook = AgentHook::new(id.clone())
1793 .with_name(name.clone())
1794 .with_enabled(true);
1795
1796 for enabled in enabled_sequence {
1798 hook.enabled = enabled;
1799
1800 let json = serde_json::to_string(&hook).expect("Should serialize");
1802
1803 let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
1805
1806 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}