#![allow(dead_code, unused_imports, unused_variables)]
pub mod builtin;
pub mod shell_handler;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use tracing::{debug, info, warn};
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "PascalCase")]
pub enum HookEvent {
PreToolUse,
PostToolUse,
Stop,
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
HookEvent::PreToolUse => write!(f, "PreToolUse"),
HookEvent::PostToolUse => write!(f, "PostToolUse"),
HookEvent::Stop => write!(f, "Stop"),
}
}
}
#[derive(Debug, Clone)]
pub enum HookAction {
Continue,
Skip { reason: String },
Error { message: String },
}
#[derive(Debug, Clone)]
pub struct HookContext {
pub event: HookEvent,
pub tool_name: Option<String>,
pub tool_args: Option<String>,
pub tool_success: Option<bool>,
pub tool_result: Option<String>,
pub affected_path: Option<String>,
}
impl HookContext {
pub fn pre_tool(tool_name: &str, tool_args: &str) -> Self {
let affected_path = extract_path_from_args(tool_args);
Self {
event: HookEvent::PreToolUse,
tool_name: Some(tool_name.to_string()),
tool_args: Some(tool_args.to_string()),
tool_success: None,
tool_result: None,
affected_path,
}
}
pub fn post_tool(tool_name: &str, tool_args: &str, success: bool, result: &str) -> Self {
let affected_path = extract_path_from_args(tool_args);
Self {
event: HookEvent::PostToolUse,
tool_name: Some(tool_name.to_string()),
tool_args: Some(tool_args.to_string()),
tool_success: Some(success),
tool_result: Some(result.to_string()),
affected_path,
}
}
pub fn stop() -> Self {
Self {
event: HookEvent::Stop,
tool_name: None,
tool_args: None,
tool_success: None,
tool_result: None,
affected_path: None,
}
}
}
fn extract_path_from_args(args: &str) -> Option<String> {
serde_json::from_str::<serde_json::Value>(args)
.ok()?
.get("path")?
.as_str()
.map(String::from)
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookConfig {
pub event: HookEvent,
pub command: String,
#[serde(default)]
pub match_tools: Vec<String>,
#[serde(default = "default_hook_timeout")]
pub timeout_secs: u64,
}
fn default_hook_timeout() -> u64 {
30
}
#[derive(Debug, Clone)]
pub struct HookRegistry {
hooks: Vec<HookConfig>,
}
impl HookRegistry {
pub fn new() -> Self {
Self { hooks: Vec::new() }
}
pub fn from_config(hooks: &[HookConfig]) -> Self {
info!("Loaded {} hook(s) from configuration", hooks.len());
Self {
hooks: hooks.to_vec(),
}
}
pub fn register(&mut self, hook: HookConfig) {
info!(
"Registered hook: {} on {:?} (match_tools: {:?})",
hook.command, hook.event, hook.match_tools
);
self.hooks.push(hook);
}
pub async fn fire(&self, ctx: &HookContext) -> HookAction {
let matching: Vec<&HookConfig> = self
.hooks
.iter()
.filter(|h| h.event == ctx.event)
.filter(|h| {
if h.match_tools.is_empty() {
return true;
}
ctx.tool_name
.as_ref()
.map(|tn| h.match_tools.iter().any(|m| m == tn))
.unwrap_or(false)
})
.collect();
if matching.is_empty() {
return HookAction::Continue;
}
debug!(
"Firing {} hook(s) for event {:?}",
matching.len(),
ctx.event
);
for hook in matching {
let result = shell_handler::execute_hook(hook, ctx).await;
match result {
HookAction::Skip { ref reason } => {
info!("Hook requested skip: {}", reason);
return result;
}
HookAction::Error { ref message } => {
warn!("Hook error (non-fatal): {}", message);
}
HookAction::Continue => {
debug!("Hook completed successfully: {}", hook.command);
}
}
}
HookAction::Continue
}
pub fn has_hooks_for(&self, event: &HookEvent) -> bool {
self.hooks.iter().any(|h| &h.event == event)
}
pub fn len(&self) -> usize {
self.hooks.len()
}
pub fn is_empty(&self) -> bool {
self.hooks.is_empty()
}
}
impl Default for HookRegistry {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_event_display() {
assert_eq!(HookEvent::PreToolUse.to_string(), "PreToolUse");
assert_eq!(HookEvent::PostToolUse.to_string(), "PostToolUse");
assert_eq!(HookEvent::Stop.to_string(), "Stop");
}
#[test]
fn test_extract_path_from_args() {
let args = r#"{"path": "./src/main.rs", "content": "test"}"#;
assert_eq!(
extract_path_from_args(args),
Some("./src/main.rs".to_string())
);
let args = r#"{"command": "cargo test"}"#;
assert_eq!(extract_path_from_args(args), None);
}
#[test]
fn test_hook_context_constructors() {
let ctx = HookContext::pre_tool("file_write", r#"{"path": "test.rs"}"#);
assert_eq!(ctx.event, HookEvent::PreToolUse);
assert_eq!(ctx.tool_name.as_deref(), Some("file_write"));
assert_eq!(ctx.affected_path.as_deref(), Some("test.rs"));
let ctx = HookContext::post_tool("file_edit", r#"{"path": "x.rs"}"#, true, "ok");
assert_eq!(ctx.event, HookEvent::PostToolUse);
assert_eq!(ctx.tool_success, Some(true));
let ctx = HookContext::stop();
assert_eq!(ctx.event, HookEvent::Stop);
assert!(ctx.tool_name.is_none());
}
#[test]
fn test_hook_registry_empty() {
let registry = HookRegistry::new();
assert!(registry.is_empty());
assert_eq!(registry.len(), 0);
assert!(!registry.has_hooks_for(&HookEvent::Stop));
}
#[test]
fn test_hook_registry_from_config() {
let hooks = vec![HookConfig {
event: HookEvent::PostToolUse,
command: "cargo fmt".to_string(),
match_tools: vec!["file_write".to_string()],
timeout_secs: 30,
}];
let registry = HookRegistry::from_config(&hooks);
assert_eq!(registry.len(), 1);
assert!(registry.has_hooks_for(&HookEvent::PostToolUse));
assert!(!registry.has_hooks_for(&HookEvent::PreToolUse));
}
#[tokio::test]
async fn test_fire_no_matching_hooks() {
let registry = HookRegistry::new();
let ctx = HookContext::stop();
let action = registry.fire(&ctx).await;
assert!(matches!(action, HookAction::Continue));
}
}