use crate::{DrivenError, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentHook {
pub id: String,
pub name: String,
pub trigger: HookTrigger,
pub condition: Option<HookCondition>,
pub action: HookAction,
pub enabled: bool,
pub chain: Option<Vec<String>>,
pub priority: u8,
}
impl AgentHook {
pub fn new(id: impl Into<String>) -> Self {
let id = id.into();
Self {
name: id.clone(),
id,
trigger: HookTrigger::Manual {
command: "default".to_string(),
},
condition: None,
action: HookAction::default(),
enabled: true,
chain: None,
priority: 100,
}
}
pub fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = name.into();
self
}
pub fn with_trigger(mut self, trigger: HookTrigger) -> Self {
self.trigger = trigger;
self
}
pub fn with_condition(mut self, condition: HookCondition) -> Self {
self.condition = Some(condition);
self
}
pub fn with_action(mut self, action: HookAction) -> Self {
self.action = action;
self
}
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn with_chain(mut self, chain: Vec<String>) -> Self {
self.chain = Some(chain);
self
}
pub fn with_priority(mut self, priority: u8) -> Self {
self.priority = priority;
self
}
pub fn matches_file_change(&self, path: &Path) -> bool {
if !self.enabled {
return false;
}
match &self.trigger {
HookTrigger::FileChange { patterns } => {
let path_str = path.to_string_lossy();
patterns
.iter()
.any(|pattern| glob_match(pattern, &path_str))
}
_ => false,
}
}
pub fn matches_git_op(&self, op: &GitOp) -> bool {
if !self.enabled {
return false;
}
match &self.trigger {
HookTrigger::GitOperation { operations } => operations.contains(op),
_ => false,
}
}
pub fn matches_build_event(&self, event: &BuildEvent) -> bool {
if !self.enabled {
return false;
}
match &self.trigger {
HookTrigger::BuildEvent { events } => events.contains(event),
_ => false,
}
}
pub fn matches_test_result(&self, result: &TestResult) -> bool {
if !self.enabled {
return false;
}
match &self.trigger {
HookTrigger::TestResult { filter } => filter.matches(result),
_ => false,
}
}
pub fn matches_manual(&self, command: &str) -> bool {
if !self.enabled {
return false;
}
match &self.trigger {
HookTrigger::Manual { command: cmd } => cmd == command,
_ => false,
}
}
pub fn evaluate_condition(&self, context: &HookContext) -> bool {
match &self.condition {
Some(condition) => condition.evaluate(context),
None => true, }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum HookTrigger {
FileChange {
patterns: Vec<String>,
},
GitOperation {
operations: Vec<GitOp>,
},
BuildEvent {
events: Vec<BuildEvent>,
},
TestResult {
filter: TestFilter,
},
Manual {
command: String,
},
Scheduled {
cron: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum GitOp {
Commit,
Push,
Pull,
Merge,
Checkout,
Stash,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Hash)]
#[serde(rename_all = "snake_case")]
pub enum BuildEvent {
Start,
Success,
Failure,
Warning,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct TestFilter {
pub status: Option<TestStatus>,
pub name_pattern: Option<String>,
pub file_pattern: Option<String>,
}
impl TestFilter {
pub fn new() -> Self {
Self {
status: None,
name_pattern: None,
file_pattern: None,
}
}
pub fn with_status(mut self, status: TestStatus) -> Self {
self.status = Some(status);
self
}
pub fn with_name_pattern(mut self, pattern: impl Into<String>) -> Self {
self.name_pattern = Some(pattern.into());
self
}
pub fn with_file_pattern(mut self, pattern: impl Into<String>) -> Self {
self.file_pattern = Some(pattern.into());
self
}
pub fn matches(&self, result: &TestResult) -> bool {
if let Some(status) = &self.status {
if result.status != *status {
return false;
}
}
if let Some(pattern) = &self.name_pattern {
if !glob_match(pattern, &result.name) {
return false;
}
}
if let Some(pattern) = &self.file_pattern {
if let Some(file) = &result.file {
if !glob_match(pattern, &file.to_string_lossy()) {
return false;
}
} else {
return false;
}
}
true
}
}
impl Default for TestFilter {
fn default() -> Self {
Self::new()
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TestStatus {
Passed,
Failed,
Skipped,
Timeout,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub name: String,
pub status: TestStatus,
pub file: Option<PathBuf>,
pub duration_ms: Option<u64>,
pub error: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HookCondition {
pub expression: String,
}
impl HookCondition {
pub fn new(expression: impl Into<String>) -> Self {
Self {
expression: expression.into(),
}
}
pub fn evaluate(&self, context: &HookContext) -> bool {
let expr = self.expression.trim();
if let Some(pos) = expr.find("&&") {
let left = &expr[..pos].trim();
let right = &expr[pos + 2..].trim();
let left_cond = HookCondition::new(*left);
let right_cond = HookCondition::new(*right);
return left_cond.evaluate(context) && right_cond.evaluate(context);
}
if let Some(pos) = expr.find("||") {
let left = &expr[..pos].trim();
let right = &expr[pos + 2..].trim();
let left_cond = HookCondition::new(*left);
let right_cond = HookCondition::new(*right);
return left_cond.evaluate(context) || right_cond.evaluate(context);
}
self.evaluate_simple(expr, context)
}
fn evaluate_simple(&self, expr: &str, context: &HookContext) -> bool {
if let Some(pos) = expr.find("==") {
let left = expr[..pos].trim();
let right = expr[pos + 2..].trim().trim_matches('\'').trim_matches('"');
return self.get_value(left, context) == Some(right.to_string());
}
if let Some(pos) = expr.find("!=") {
let left = expr[..pos].trim();
let right = expr[pos + 2..].trim().trim_matches('\'').trim_matches('"');
return self.get_value(left, context) != Some(right.to_string());
}
if expr.contains(".contains(") {
if let Some(start) = expr.find(".contains(") {
let field = expr[..start].trim();
let arg_start = start + 10;
if let Some(arg_end) = expr[arg_start..].find(')') {
let arg = expr[arg_start..arg_start + arg_end]
.trim()
.trim_matches('\'')
.trim_matches('"');
if let Some(value) = self.get_value(field, context) {
return value.contains(arg);
}
}
}
return false;
}
if expr.contains(".starts_with(") {
if let Some(start) = expr.find(".starts_with(") {
let field = expr[..start].trim();
let arg_start = start + 13;
if let Some(arg_end) = expr[arg_start..].find(')') {
let arg = expr[arg_start..arg_start + arg_end]
.trim()
.trim_matches('\'')
.trim_matches('"');
if let Some(value) = self.get_value(field, context) {
return value.starts_with(arg);
}
}
}
return false;
}
if expr.contains(".ends_with(") {
if let Some(start) = expr.find(".ends_with(") {
let field = expr[..start].trim();
let arg_start = start + 11;
if let Some(arg_end) = expr[arg_start..].find(')') {
let arg = expr[arg_start..arg_start + arg_end]
.trim()
.trim_matches('\'')
.trim_matches('"');
if let Some(value) = self.get_value(field, context) {
return value.ends_with(arg);
}
}
}
return false;
}
false
}
fn get_value(&self, field: &str, context: &HookContext) -> Option<String> {
match field {
"file.ext" => context.file_ext.clone(),
"file.path" => context
.file_path
.as_ref()
.map(|p| p.to_string_lossy().to_string()),
"file.name" => context.file_name.clone(),
"file.size" => context.file_size.map(|s| s.to_string()),
_ => context.variables.get(field).cloned(),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct HookContext {
pub file_ext: Option<String>,
pub file_path: Option<PathBuf>,
pub file_name: Option<String>,
pub file_size: Option<u64>,
pub variables: HashMap<String, String>,
}
impl HookContext {
pub fn new() -> Self {
Self::default()
}
pub fn from_path(path: &Path) -> Self {
let file_ext = path.extension().map(|e| e.to_string_lossy().to_string());
let file_name = path.file_name().map(|n| n.to_string_lossy().to_string());
let file_size = std::fs::metadata(path).ok().map(|m| m.len());
Self {
file_ext,
file_path: Some(path.to_path_buf()),
file_name,
file_size,
variables: HashMap::new(),
}
}
pub fn set_variable(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.variables.insert(key.into(), value.into());
}
pub fn get_variable(&self, key: &str) -> Option<&String> {
self.variables.get(key)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub struct HookAction {
pub agent: String,
pub workflow: Option<String>,
pub message: String,
pub context: HashMap<String, String>,
}
impl Default for HookAction {
fn default() -> Self {
Self {
agent: "default".to_string(),
workflow: None,
message: String::new(),
context: HashMap::new(),
}
}
}
impl HookAction {
pub fn new(agent: impl Into<String>, message: impl Into<String>) -> Self {
Self {
agent: agent.into(),
workflow: None,
message: message.into(),
context: HashMap::new(),
}
}
pub fn with_workflow(mut self, workflow: impl Into<String>) -> Self {
self.workflow = Some(workflow.into());
self
}
pub fn with_context(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.context.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone)]
pub struct HookExecutionResult {
pub hook_id: String,
pub success: bool,
pub output: Option<String>,
pub error: Option<String>,
pub duration_ms: u64,
pub chained: Vec<String>,
}
pub struct HookEngine {
hooks: Vec<AgentHook>,
hooks_dir: PathBuf,
running: bool,
}
impl HookEngine {
pub fn new() -> Self {
Self {
hooks: Vec::new(),
hooks_dir: PathBuf::from(".driven/hooks"),
running: false,
}
}
pub fn with_hooks_dir(hooks_dir: impl Into<PathBuf>) -> Self {
Self {
hooks: Vec::new(),
hooks_dir: hooks_dir.into(),
running: false,
}
}
pub fn register_hook(&mut self, hook: AgentHook) -> Result<()> {
if self.hooks.iter().any(|h| h.id == hook.id) {
return Err(DrivenError::Config(format!(
"Hook with ID '{}' already exists",
hook.id
)));
}
self.hooks.push(hook);
self.hooks.sort_by_key(|h| h.priority);
Ok(())
}
pub fn unregister_hook(&mut self, id: &str) -> Result<AgentHook> {
let pos = self
.hooks
.iter()
.position(|h| h.id == id)
.ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
Ok(self.hooks.remove(pos))
}
pub fn get_hook(&self, id: &str) -> Option<&AgentHook> {
self.hooks.iter().find(|h| h.id == id)
}
pub fn get_hook_mut(&mut self, id: &str) -> Option<&mut AgentHook> {
self.hooks.iter_mut().find(|h| h.id == id)
}
pub fn list_hooks(&self) -> &[AgentHook] {
&self.hooks
}
pub fn enable_hook(&mut self, id: &str) -> Result<()> {
let hook = self
.get_hook_mut(id)
.ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
hook.enabled = true;
Ok(())
}
pub fn disable_hook(&mut self, id: &str) -> Result<()> {
let hook = self
.get_hook_mut(id)
.ok_or_else(|| DrivenError::Config(format!("Hook with ID '{}' not found", id)))?;
hook.enabled = false;
Ok(())
}
pub fn find_file_change_hooks(&self, path: &Path) -> Vec<&AgentHook> {
self.hooks
.iter()
.filter(|h| h.matches_file_change(path))
.collect()
}
pub fn find_git_op_hooks(&self, op: &GitOp) -> Vec<&AgentHook> {
self.hooks.iter().filter(|h| h.matches_git_op(op)).collect()
}
pub fn find_build_event_hooks(&self, event: &BuildEvent) -> Vec<&AgentHook> {
self.hooks
.iter()
.filter(|h| h.matches_build_event(event))
.collect()
}
pub fn find_test_result_hooks(&self, result: &TestResult) -> Vec<&AgentHook> {
self.hooks
.iter()
.filter(|h| h.matches_test_result(result))
.collect()
}
pub fn find_manual_hooks(&self, command: &str) -> Vec<&AgentHook> {
self.hooks
.iter()
.filter(|h| h.matches_manual(command))
.collect()
}
pub fn trigger_file_change(&self, path: &Path) -> Vec<HookExecutionResult> {
let context = HookContext::from_path(path);
let hooks = self.find_file_change_hooks(path);
hooks
.iter()
.filter(|h| h.evaluate_condition(&context))
.map(|h| self.execute_hook(h, &context))
.collect()
}
pub fn trigger_git_op(&self, op: &GitOp) -> Vec<HookExecutionResult> {
let context = HookContext::new();
let hooks = self.find_git_op_hooks(op);
hooks
.iter()
.filter(|h| h.evaluate_condition(&context))
.map(|h| self.execute_hook(h, &context))
.collect()
}
pub fn trigger_build_event(&self, event: &BuildEvent) -> Vec<HookExecutionResult> {
let context = HookContext::new();
let hooks = self.find_build_event_hooks(event);
hooks
.iter()
.filter(|h| h.evaluate_condition(&context))
.map(|h| self.execute_hook(h, &context))
.collect()
}
pub fn trigger_test_result(&self, result: &TestResult) -> Vec<HookExecutionResult> {
let mut context = HookContext::new();
context.set_variable("test.name", &result.name);
context.set_variable("test.status", format!("{:?}", result.status));
if let Some(file) = &result.file {
context.file_path = Some(file.clone());
}
let hooks = self.find_test_result_hooks(result);
hooks
.iter()
.filter(|h| h.evaluate_condition(&context))
.map(|h| self.execute_hook(h, &context))
.collect()
}
pub fn trigger_manual(&self, command: &str) -> Vec<HookExecutionResult> {
let context = HookContext::new();
let hooks = self.find_manual_hooks(command);
hooks
.iter()
.filter(|h| h.evaluate_condition(&context))
.map(|h| self.execute_hook(h, &context))
.collect()
}
fn execute_hook(&self, hook: &AgentHook, _context: &HookContext) -> HookExecutionResult {
let start = std::time::Instant::now();
let duration_ms = start.elapsed().as_millis() as u64;
let chained = hook.chain.clone().unwrap_or_default();
HookExecutionResult {
hook_id: hook.id.clone(),
success: true,
output: Some(format!("Hook '{}' executed", hook.name)),
error: None,
duration_ms,
chained,
}
}
pub fn load_hooks(&mut self, path: &Path) -> Result<usize> {
if !path.exists() {
return Ok(0);
}
let mut count = 0;
for entry in std::fs::read_dir(path).map_err(DrivenError::Io)? {
let entry = entry.map_err(DrivenError::Io)?;
let path = entry.path();
if path
.extension()
.is_some_and(|e| e == "json" || e == "yaml" || e == "yml")
{
match self.load_hook_file(&path) {
Ok(hook) => {
if let Err(e) = self.register_hook(hook) {
tracing::warn!("Failed to register hook from {:?}: {}", path, e);
} else {
count += 1;
}
}
Err(e) => {
tracing::warn!("Failed to load hook from {:?}: {}", path, e);
}
}
}
}
Ok(count)
}
fn load_hook_file(&self, path: &Path) -> Result<AgentHook> {
let content = std::fs::read_to_string(path).map_err(DrivenError::Io)?;
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
match ext {
"json" => serde_json::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string())),
"yaml" | "yml" => {
serde_yaml::from_str(&content).map_err(|e| DrivenError::Parse(e.to_string()))
}
_ => Err(DrivenError::Parse(format!(
"Unknown file extension: {}",
ext
))),
}
}
pub fn save_hook(&self, hook: &AgentHook, path: &Path) -> Result<()> {
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("json");
let content = match ext {
"json" => serde_json::to_string_pretty(hook)
.map_err(|e| DrivenError::Format(e.to_string()))?,
"yaml" | "yml" => {
serde_yaml::to_string(hook).map_err(|e| DrivenError::Format(e.to_string()))?
}
_ => {
return Err(DrivenError::Format(format!(
"Unknown file extension: {}",
ext
)));
}
};
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(DrivenError::Io)?;
}
std::fs::write(path, content).map_err(DrivenError::Io)?;
Ok(())
}
pub fn hooks_dir(&self) -> &Path {
&self.hooks_dir
}
pub fn is_running(&self) -> bool {
self.running
}
pub fn start(&mut self) -> Result<()> {
if self.running {
return Err(DrivenError::Config(
"Hook engine already running".to_string(),
));
}
self.load_hooks(&self.hooks_dir.clone())?;
self.running = true;
Ok(())
}
pub fn stop(&mut self) {
self.running = false;
}
}
impl Default for HookEngine {
fn default() -> Self {
Self::new()
}
}
fn glob_match(pattern: &str, text: &str) -> bool {
if pattern.contains("**") {
let parts: Vec<&str> = pattern.split("**").collect();
if parts.len() == 2 {
let prefix = parts[0].trim_end_matches('/');
let suffix = parts[1].trim_start_matches('/');
if !prefix.is_empty() && !text.starts_with(prefix) {
return false;
}
if !suffix.is_empty() && !glob_match(suffix, text.rsplit('/').next().unwrap_or(text)) {
return false;
}
return true;
}
}
if pattern.contains('*') && !pattern.contains("**") {
let parts: Vec<&str> = pattern.split('*').collect();
let mut pos = 0;
for (i, part) in parts.iter().enumerate() {
if part.is_empty() {
continue;
}
if i == 0 {
if !text.starts_with(part) {
return false;
}
pos = part.len();
} else if i == parts.len() - 1 {
if !text.ends_with(part) {
return false;
}
} else {
if let Some(found) = text[pos..].find(part) {
pos += found + part.len();
} else {
return false;
}
}
}
return true;
}
pattern == text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_agent_hook_creation() {
let hook = AgentHook::new("test-hook")
.with_name("Test Hook")
.with_trigger(HookTrigger::FileChange {
patterns: vec!["**/*.rs".to_string()],
})
.with_action(HookAction::new("reviewer", "Review this file"))
.with_priority(50);
assert_eq!(hook.id, "test-hook");
assert_eq!(hook.name, "Test Hook");
assert!(hook.enabled);
assert_eq!(hook.priority, 50);
}
#[test]
fn test_file_change_matching() {
let hook = AgentHook::new("rust-hook").with_trigger(HookTrigger::FileChange {
patterns: vec!["**/*.rs".to_string()],
});
assert!(hook.matches_file_change(Path::new("src/main.rs")));
assert!(hook.matches_file_change(Path::new("lib.rs")));
assert!(!hook.matches_file_change(Path::new("src/main.py")));
}
#[test]
fn test_git_op_matching() {
let hook = AgentHook::new("git-hook").with_trigger(HookTrigger::GitOperation {
operations: vec![GitOp::Commit, GitOp::Push],
});
assert!(hook.matches_git_op(&GitOp::Commit));
assert!(hook.matches_git_op(&GitOp::Push));
assert!(!hook.matches_git_op(&GitOp::Pull));
}
#[test]
fn test_build_event_matching() {
let hook = AgentHook::new("build-hook").with_trigger(HookTrigger::BuildEvent {
events: vec![BuildEvent::Failure, BuildEvent::Warning],
});
assert!(hook.matches_build_event(&BuildEvent::Failure));
assert!(hook.matches_build_event(&BuildEvent::Warning));
assert!(!hook.matches_build_event(&BuildEvent::Success));
}
#[test]
fn test_test_filter() {
let filter = TestFilter::new()
.with_status(TestStatus::Failed)
.with_name_pattern("test_*");
let passing_result = TestResult {
name: "test_something".to_string(),
status: TestStatus::Failed,
file: None,
duration_ms: Some(100),
error: Some("assertion failed".to_string()),
};
let non_matching_result = TestResult {
name: "test_something".to_string(),
status: TestStatus::Passed,
file: None,
duration_ms: Some(50),
error: None,
};
assert!(filter.matches(&passing_result));
assert!(!filter.matches(&non_matching_result));
}
#[test]
fn test_hook_condition_simple() {
let condition = HookCondition::new("file.ext == 'rs'");
let mut context = HookContext::new();
context.file_ext = Some("rs".to_string());
assert!(condition.evaluate(&context));
context.file_ext = Some("py".to_string());
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_condition_and() {
let condition = HookCondition::new("file.ext == 'rs' && file.path.contains('src')");
let mut context = HookContext::new();
context.file_ext = Some("rs".to_string());
context.file_path = Some(PathBuf::from("src/main.rs"));
assert!(condition.evaluate(&context));
context.file_path = Some(PathBuf::from("tests/main.rs"));
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_condition_or() {
let condition = HookCondition::new("file.ext == 'rs' || file.ext == 'py'");
let mut context = HookContext::new();
context.file_ext = Some("rs".to_string());
assert!(condition.evaluate(&context));
context.file_ext = Some("py".to_string());
assert!(condition.evaluate(&context));
context.file_ext = Some("js".to_string());
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_condition_contains() {
let condition = HookCondition::new("file.path.contains('src')");
let mut context = HookContext::new();
context.file_path = Some(PathBuf::from("src/lib.rs"));
assert!(condition.evaluate(&context));
context.file_path = Some(PathBuf::from("tests/lib.rs"));
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_condition_starts_with() {
let condition = HookCondition::new("file.path.starts_with('src')");
let mut context = HookContext::new();
context.file_path = Some(PathBuf::from("src/lib.rs"));
assert!(condition.evaluate(&context));
context.file_path = Some(PathBuf::from("tests/lib.rs"));
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_condition_ends_with() {
let condition = HookCondition::new("file.path.ends_with('.rs')");
let mut context = HookContext::new();
context.file_path = Some(PathBuf::from("src/lib.rs"));
assert!(condition.evaluate(&context));
context.file_path = Some(PathBuf::from("src/lib.py"));
assert!(!condition.evaluate(&context));
}
#[test]
fn test_hook_engine_register() {
let mut engine = HookEngine::new();
let hook = AgentHook::new("test-hook");
engine.register_hook(hook).unwrap();
assert_eq!(engine.list_hooks().len(), 1);
assert!(engine.get_hook("test-hook").is_some());
}
#[test]
fn test_hook_engine_duplicate_id() {
let mut engine = HookEngine::new();
let hook1 = AgentHook::new("test-hook");
let hook2 = AgentHook::new("test-hook");
engine.register_hook(hook1).unwrap();
assert!(engine.register_hook(hook2).is_err());
}
#[test]
fn test_hook_engine_unregister() {
let mut engine = HookEngine::new();
let hook = AgentHook::new("test-hook");
engine.register_hook(hook).unwrap();
let removed = engine.unregister_hook("test-hook").unwrap();
assert_eq!(removed.id, "test-hook");
assert!(engine.get_hook("test-hook").is_none());
}
#[test]
fn test_hook_engine_enable_disable() {
let mut engine = HookEngine::new();
let hook = AgentHook::new("test-hook");
engine.register_hook(hook).unwrap();
engine.disable_hook("test-hook").unwrap();
assert!(!engine.get_hook("test-hook").unwrap().enabled);
engine.enable_hook("test-hook").unwrap();
assert!(engine.get_hook("test-hook").unwrap().enabled);
}
#[test]
fn test_hook_engine_find_file_change_hooks() {
let mut engine = HookEngine::new();
let rust_hook = AgentHook::new("rust-hook").with_trigger(HookTrigger::FileChange {
patterns: vec!["**/*.rs".to_string()],
});
let python_hook = AgentHook::new("python-hook").with_trigger(HookTrigger::FileChange {
patterns: vec!["**/*.py".to_string()],
});
engine.register_hook(rust_hook).unwrap();
engine.register_hook(python_hook).unwrap();
let hooks = engine.find_file_change_hooks(Path::new("src/main.rs"));
assert_eq!(hooks.len(), 1);
assert_eq!(hooks[0].id, "rust-hook");
}
#[test]
fn test_hook_engine_priority_ordering() {
let mut engine = HookEngine::new();
let low_priority = AgentHook::new("low").with_priority(200);
let high_priority = AgentHook::new("high").with_priority(50);
let medium_priority = AgentHook::new("medium").with_priority(100);
engine.register_hook(low_priority).unwrap();
engine.register_hook(high_priority).unwrap();
engine.register_hook(medium_priority).unwrap();
let hooks = engine.list_hooks();
assert_eq!(hooks[0].id, "high");
assert_eq!(hooks[1].id, "medium");
assert_eq!(hooks[2].id, "low");
}
#[test]
fn test_glob_match_star() {
assert!(glob_match("*.rs", "main.rs"));
assert!(glob_match("*.rs", "lib.rs"));
assert!(!glob_match("*.rs", "main.py"));
assert!(glob_match("test_*", "test_something"));
assert!(!glob_match("test_*", "something_test"));
}
#[test]
fn test_glob_match_double_star() {
assert!(glob_match("**/*.rs", "src/main.rs"));
assert!(glob_match("**/*.rs", "src/lib/mod.rs"));
assert!(glob_match("**/*.rs", "main.rs"));
assert!(!glob_match("**/*.rs", "main.py"));
}
#[test]
fn test_glob_match_exact() {
assert!(glob_match("main.rs", "main.rs"));
assert!(!glob_match("main.rs", "lib.rs"));
}
#[test]
fn test_hook_action() {
let action = HookAction::new("reviewer", "Review this code")
.with_workflow("quick-review")
.with_context("file", "main.rs");
assert_eq!(action.agent, "reviewer");
assert_eq!(action.workflow, Some("quick-review".to_string()));
assert_eq!(action.message, "Review this code");
assert_eq!(action.context.get("file"), Some(&"main.rs".to_string()));
}
#[test]
fn test_hook_context_from_path() {
let context = HookContext::from_path(Path::new("src/main.rs"));
assert_eq!(context.file_ext, Some("rs".to_string()));
assert_eq!(context.file_name, Some("main.rs".to_string()));
assert!(context.file_path.is_some());
}
#[test]
fn test_manual_hook_matching() {
let hook = AgentHook::new("manual-hook").with_trigger(HookTrigger::Manual {
command: "lint".to_string(),
});
assert!(hook.matches_manual("lint"));
assert!(!hook.matches_manual("test"));
}
#[test]
fn test_disabled_hook_no_match() {
let hook = AgentHook::new("disabled-hook")
.with_trigger(HookTrigger::FileChange {
patterns: vec!["**/*.rs".to_string()],
})
.with_enabled(false);
assert!(!hook.matches_file_change(Path::new("src/main.rs")));
}
#[test]
fn test_hook_with_chain() {
let hook = AgentHook::new("chained-hook")
.with_chain(vec!["hook2".to_string(), "hook3".to_string()]);
assert_eq!(
hook.chain,
Some(vec!["hook2".to_string(), "hook3".to_string()])
);
}
}
#[cfg(test)]
mod property_tests {
use super::*;
use proptest::prelude::*;
fn arb_extension() -> impl Strategy<Value = String> {
prop_oneof![
Just("rs".to_string()),
Just("py".to_string()),
Just("js".to_string()),
Just("ts".to_string()),
Just("md".to_string()),
Just("json".to_string()),
Just("yaml".to_string()),
]
}
fn arb_file_path() -> impl Strategy<Value = PathBuf> {
(
prop_oneof![Just("src"), Just("tests"), Just("lib"), Just("bin"),],
"[a-z_]+",
arb_extension(),
)
.prop_map(|(dir, name, ext)| PathBuf::from(format!("{}/{}.{}", dir, name, ext)))
}
fn arb_git_op() -> impl Strategy<Value = GitOp> {
prop_oneof![
Just(GitOp::Commit),
Just(GitOp::Push),
Just(GitOp::Pull),
Just(GitOp::Merge),
Just(GitOp::Checkout),
Just(GitOp::Stash),
]
}
fn arb_build_event() -> impl Strategy<Value = BuildEvent> {
prop_oneof![
Just(BuildEvent::Start),
Just(BuildEvent::Success),
Just(BuildEvent::Failure),
Just(BuildEvent::Warning),
]
}
fn arb_test_status() -> impl Strategy<Value = TestStatus> {
prop_oneof![
Just(TestStatus::Passed),
Just(TestStatus::Failed),
Just(TestStatus::Skipped),
Just(TestStatus::Timeout),
]
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(100))]
#[test]
fn prop_file_change_triggers_matching_hooks(
path in arb_file_path(),
ext in arb_extension(),
) {
let mut engine = HookEngine::new();
let pattern = format!("**/*.{}", ext);
let hook = AgentHook::new("test-hook")
.with_trigger(HookTrigger::FileChange {
patterns: vec![pattern],
});
engine.register_hook(hook).unwrap();
let test_path = PathBuf::from(format!("src/test.{}", ext));
let hooks = engine.find_file_change_hooks(&test_path);
prop_assert_eq!(hooks.len(), 1);
prop_assert_eq!(&hooks[0].id, "test-hook");
}
#[test]
fn prop_git_op_triggers_matching_hooks(
op in arb_git_op(),
) {
let mut engine = HookEngine::new();
let hook = AgentHook::new("git-hook")
.with_trigger(HookTrigger::GitOperation {
operations: vec![op],
});
engine.register_hook(hook).unwrap();
let hooks = engine.find_git_op_hooks(&op);
prop_assert_eq!(hooks.len(), 1);
prop_assert_eq!(&hooks[0].id, "git-hook");
}
#[test]
fn prop_build_event_triggers_matching_hooks(
event in arb_build_event(),
) {
let mut engine = HookEngine::new();
let hook = AgentHook::new("build-hook")
.with_trigger(HookTrigger::BuildEvent {
events: vec![event],
});
engine.register_hook(hook).unwrap();
let hooks = engine.find_build_event_hooks(&event);
prop_assert_eq!(hooks.len(), 1);
prop_assert_eq!(&hooks[0].id, "build-hook");
}
#[test]
fn prop_test_result_triggers_matching_hooks(
status in arb_test_status(),
name in "[a-z_]+",
) {
let mut engine = HookEngine::new();
let hook = AgentHook::new("test-hook")
.with_trigger(HookTrigger::TestResult {
filter: TestFilter::new().with_status(status),
});
engine.register_hook(hook).unwrap();
let result = TestResult {
name: name.clone(),
status,
file: None,
duration_ms: Some(100),
error: None,
};
let hooks = engine.find_test_result_hooks(&result);
prop_assert_eq!(hooks.len(), 1);
prop_assert_eq!(&hooks[0].id, "test-hook");
}
#[test]
fn prop_hook_trigger_execution_once(
ext in arb_extension(),
num_triggers in 1usize..5,
) {
let mut engine = HookEngine::new();
let pattern = format!("**/*.{}", ext);
let hook = AgentHook::new("exec-hook")
.with_trigger(HookTrigger::FileChange {
patterns: vec![pattern],
})
.with_action(HookAction::new("test-agent", "Test message"));
engine.register_hook(hook).unwrap();
let mut total_executions = 0;
for i in 0..num_triggers {
let test_path = PathBuf::from(format!("src/file{}.{}", i, ext));
let results = engine.trigger_file_change(&test_path);
total_executions += results.len();
}
prop_assert_eq!(total_executions, num_triggers,
"Expected {} executions, got {}", num_triggers, total_executions);
}
#[test]
fn prop_manual_hook_trigger_execution(
command in "[a-z_]+",
) {
let mut engine = HookEngine::new();
let hook = AgentHook::new("manual-hook")
.with_trigger(HookTrigger::Manual {
command: command.clone(),
})
.with_action(HookAction::new("test-agent", "Manual trigger"));
engine.register_hook(hook).unwrap();
let results = engine.trigger_manual(&command);
prop_assert_eq!(results.len(), 1,
"Manual hook should execute exactly once, got {}", results.len());
prop_assert!(results[0].success,
"Manual hook execution should succeed");
}
#[test]
fn prop_disabled_hook_no_execution(
ext in arb_extension(),
) {
let mut engine = HookEngine::new();
let pattern = format!("**/*.{}", ext);
let hook = AgentHook::new("disabled-hook")
.with_trigger(HookTrigger::FileChange {
patterns: vec![pattern],
})
.with_enabled(false);
engine.register_hook(hook).unwrap();
let test_path = PathBuf::from(format!("src/test.{}", ext));
let results = engine.trigger_file_change(&test_path);
prop_assert_eq!(results.len(), 0,
"Disabled hook should not execute, got {} executions", results.len());
} #[test]
fn prop_condition_filtering(
ext in arb_extension(),
path_contains in prop_oneof![Just("src"), Just("tests"), Just("lib")],
) {
let condition = HookCondition::new(format!("file.ext == '{}'", ext));
let mut context = HookContext::new();
context.file_ext = Some(ext.clone());
prop_assert!(condition.evaluate(&context));
let mut context2 = HookContext::new();
context2.file_ext = Some("different".to_string());
prop_assert!(!condition.evaluate(&context2));
}
#[test]
fn prop_compound_condition_and(
ext in arb_extension(),
dir in prop_oneof![Just("src"), Just("tests"), Just("lib")],
) {
let condition = HookCondition::new(
format!("file.ext == '{}' && file.path.contains('{}')", ext, dir)
);
let mut context = HookContext::new();
context.file_ext = Some(ext.clone());
context.file_path = Some(PathBuf::from(format!("{}/test.{}", dir, ext)));
prop_assert!(condition.evaluate(&context));
let mut context2 = HookContext::new();
context2.file_ext = Some(ext.clone());
context2.file_path = Some(PathBuf::from("other/test.rs"));
prop_assert!(!condition.evaluate(&context2));
}
#[test]
fn prop_compound_condition_or(
ext1 in arb_extension(),
ext2 in arb_extension(),
) {
let condition = HookCondition::new(
format!("file.ext == '{}' || file.ext == '{}'", ext1, ext2)
);
let mut context1 = HookContext::new();
context1.file_ext = Some(ext1.clone());
prop_assert!(condition.evaluate(&context1));
let mut context2 = HookContext::new();
context2.file_ext = Some(ext2.clone());
prop_assert!(condition.evaluate(&context2));
let mut context3 = HookContext::new();
context3.file_ext = Some("nomatch".to_string());
if ext1 != "nomatch" && ext2 != "nomatch" {
prop_assert!(!condition.evaluate(&context3));
}
}
#[test]
fn prop_hook_chaining_order(
chain_ids in prop::collection::vec("[a-z]+", 1..5),
) {
let mut engine = HookEngine::new();
let main_hook = AgentHook::new("main")
.with_trigger(HookTrigger::Manual { command: "test".to_string() })
.with_chain(chain_ids.clone());
engine.register_hook(main_hook).unwrap();
for id in &chain_ids {
let hook = AgentHook::new(id.clone())
.with_trigger(HookTrigger::Manual { command: id.clone() });
let _ = engine.register_hook(hook); }
let results = engine.trigger_manual("test");
prop_assert_eq!(results.len(), 1);
prop_assert_eq!(&results[0].chained, &chain_ids);
}
#[test]
fn prop_hook_persistence_roundtrip(
id in "[a-z_]+",
name in "[a-zA-Z ]+",
ext in arb_extension(),
enabled in any::<bool>(),
priority in 0u8..255,
) {
let hook = AgentHook::new(id.clone())
.with_name(name.clone())
.with_trigger(HookTrigger::FileChange {
patterns: vec![format!("**/*.{}", ext)],
})
.with_enabled(enabled)
.with_priority(priority);
let json = serde_json::to_string(&hook).expect("Should serialize");
let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
prop_assert_eq!(loaded.id, id);
prop_assert_eq!(loaded.name, name);
prop_assert_eq!(loaded.enabled, enabled);
prop_assert_eq!(loaded.priority, priority);
match &loaded.trigger {
HookTrigger::FileChange { patterns } => {
prop_assert_eq!(patterns.len(), 1);
prop_assert!(patterns[0].ends_with(&ext));
}
_ => prop_assert!(false, "Wrong trigger type"),
}
}
#[test]
fn prop_hook_state_consistency(
id in "[a-z_]+",
initial_enabled in any::<bool>(),
operations in prop::collection::vec(any::<bool>(), 1..10),
) {
let mut engine = HookEngine::new();
let hook = AgentHook::new(id.clone())
.with_enabled(initial_enabled);
engine.register_hook(hook).unwrap();
let mut expected_state = initial_enabled;
for enable in &operations {
if *enable {
engine.enable_hook(&id).unwrap();
expected_state = true;
} else {
engine.disable_hook(&id).unwrap();
expected_state = false;
}
let hook = engine.get_hook(&id).unwrap();
prop_assert_eq!(hook.enabled, expected_state,
"Hook state mismatch after operation: expected {}, got {}",
expected_state, hook.enabled);
}
let final_hook = engine.get_hook(&id).unwrap();
prop_assert_eq!(final_hook.enabled, expected_state);
}
#[test]
fn prop_hook_state_persistence(
id in "[a-z_]+",
name in "[a-zA-Z ]+",
enabled_sequence in prop::collection::vec(any::<bool>(), 1..5),
) {
let mut hook = AgentHook::new(id.clone())
.with_name(name.clone())
.with_enabled(true);
for enabled in enabled_sequence {
hook.enabled = enabled;
let json = serde_json::to_string(&hook).expect("Should serialize");
let loaded: AgentHook = serde_json::from_str(&json).expect("Should deserialize");
prop_assert_eq!(loaded.enabled, enabled,
"Hook enabled state did not persist: expected {}, got {}",
enabled, loaded.enabled);
prop_assert_eq!(loaded.id, id.clone());
prop_assert_eq!(loaded.name, name.clone());
}
}
}
}