#![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)]
#[path = "../../tests/unit/hooks/mod_test.rs"]
mod tests;