use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
pub struct HooksConfig {
#[serde(flatten)]
pub events: HashMap<String, Vec<HookMatcher>>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct HookMatcher {
pub matcher: Option<String>,
pub hooks: Vec<HookCommand>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
pub struct HookCommand {
pub r#type: HookCommandType,
pub command: Option<String>,
pub url: Option<String>,
pub prompt: Option<String>,
#[serde(default = "default_timeout")]
pub timeout: u64,
pub status_message: Option<String>,
#[serde(default)]
pub once: bool,
#[serde(default)]
pub r#async: bool,
#[serde(default)]
pub async_rewake: bool,
}
#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq, Default)]
#[serde(rename_all = "lowercase")]
pub enum HookCommandType {
#[default]
Command,
Http,
Prompt,
Agent,
}
fn default_timeout() -> u64 {
5
}
pub fn matches_hook(matcher: &Option<String>, tool_name: &str, args: &serde_json::Value) -> bool {
let Some(pattern) = matcher else {
return true;
};
if let Some(paren_pos) = pattern.find('(') {
let tool_pat = &pattern[..paren_pos];
if tool_name != tool_pat {
return false;
}
let arg_pat = pattern[paren_pos + 1..].trim_end_matches(')');
if let Some(first) = first_string_arg(args) {
return glob_match(arg_pat, &first);
}
return false;
}
tool_name == pattern.as_str()
}
fn first_string_arg(args: &serde_json::Value) -> Option<String> {
let obj = args.as_object()?;
for key in &["command", "path", "goal", "input"] {
if let Some(v) = obj.get(*key).and_then(|v| v.as_str()) {
return Some(v.to_string());
}
}
obj.values().find_map(|v| v.as_str().map(|s| s.to_string()))
}
fn glob_match(pattern: &str, value: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
value.starts_with(prefix)
} else {
value == pattern
}
}
pub fn load_hooks_config(dirs: &[std::path::PathBuf]) -> HooksConfig {
for dir in dirs {
let path = dir.join("hooks.json");
if path.exists() {
if let Ok(text) = std::fs::read_to_string(&path) {
if let Ok(cfg) = serde_json::from_str::<HooksConfig>(&text) {
return cfg;
}
}
}
}
HooksConfig::default()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn hooks_config_deserializes_from_json() {
let json = r#"
{
"PreToolCall": [
{
"matcher": "run_shell",
"hooks": [
{ "type": "command", "command": "/usr/local/bin/check.sh", "timeout": 10 }
]
}
]
}"#;
let cfg: HooksConfig = serde_json::from_str(json).unwrap();
assert!(cfg.events.contains_key("PreToolCall"));
let matchers = &cfg.events["PreToolCall"];
assert_eq!(matchers.len(), 1);
assert_eq!(matchers[0].matcher.as_deref(), Some("run_shell"));
assert_eq!(matchers[0].hooks.len(), 1);
assert_eq!(matchers[0].hooks[0].timeout, 10);
assert_eq!(matchers[0].hooks[0].r#type, HookCommandType::Command);
}
#[test]
fn hooks_config_empty_is_default() {
let cfg: HooksConfig = serde_json::from_str("{}").unwrap();
assert!(cfg.events.is_empty());
let cfg = load_hooks_config(&[]);
assert!(cfg.events.is_empty());
}
#[test]
fn matcher_none_matches_all_tools() {
let args = serde_json::json!({"command": "ls"});
assert!(matches_hook(&None, "run_shell", &args));
assert!(matches_hook(&None, "write_file", &args));
assert!(matches_hook(&None, "anything", &serde_json::json!({})));
}
#[test]
fn matcher_tool_name_exact() {
let args = serde_json::json!({});
let m = Some("run_shell".to_string());
assert!(matches_hook(&m, "run_shell", &args));
assert!(!matches_hook(&m, "write_file", &args));
assert!(!matches_hook(&m, "read_file", &args));
}
#[test]
fn matcher_tool_name_with_arg_prefix() {
let m = Some("run_shell(git *)".to_string());
let git_args = serde_json::json!({"command": "git status"});
let ls_args = serde_json::json!({"command": "ls -la"});
assert!(matches_hook(&m, "run_shell", &git_args));
assert!(!matches_hook(&m, "run_shell", &ls_args));
}
#[test]
fn matcher_tool_name_with_arg_prefix_no_match() {
let m = Some("run_shell(git *)".to_string());
let args = serde_json::json!({"command": "rm -rf /"});
assert!(!matches_hook(&m, "run_shell", &args));
}
#[test]
fn matcher_tool_name_mismatch_with_arg_pattern() {
let m = Some("run_shell(git *)".to_string());
let args = serde_json::json!({"command": "git status"});
assert!(!matches_hook(&m, "write_file", &args));
}
#[test]
fn matcher_path_arg_used_for_write_file() {
let m = Some("write_file(src/*)".to_string());
let src_args = serde_json::json!({"path": "src/main.rs"});
let other_args = serde_json::json!({"path": "tests/foo.rs"});
assert!(matches_hook(&m, "write_file", &src_args));
assert!(!matches_hook(&m, "write_file", &other_args));
}
#[test]
fn load_hooks_config_reads_from_dir() {
let tmp = tempfile::tempdir().unwrap();
let json = r#"{"PreToolCall": [{"hooks": [{"type": "command", "command": "echo hi"}]}]}"#;
std::fs::write(tmp.path().join("hooks.json"), json).unwrap();
let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
assert!(cfg.events.contains_key("PreToolCall"));
}
#[test]
fn load_hooks_config_skips_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let cfg = load_hooks_config(&[tmp.path().to_path_buf()]);
assert!(cfg.events.is_empty());
}
#[test]
fn hook_command_defaults() {
let json = r#"{"type": "command", "command": "echo hi"}"#;
let cmd: HookCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd.timeout, 5);
assert!(!cmd.once);
assert!(!cmd.r#async);
assert!(!cmd.async_rewake);
}
#[test]
fn hook_command_type_http_deserializes() {
let json = r#"{"type": "http", "url": "https://example.com/hook"}"#;
let cmd: HookCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd.r#type, HookCommandType::Http);
assert_eq!(cmd.url.as_deref(), Some("https://example.com/hook"));
}
#[test]
fn hook_command_type_prompt_deserializes() {
let json = r#"{"type": "prompt", "prompt": "Is this safe? $ARGUMENTS"}"#;
let cmd: HookCommand = serde_json::from_str(json).unwrap();
assert_eq!(cmd.r#type, HookCommandType::Prompt);
assert!(cmd.prompt.as_deref().unwrap().contains("$ARGUMENTS"));
}
}