use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::fmt;
use uuid::Uuid;
pub use crate::hook_event::HookEvent;
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum HookHandler {
Command {
command: String,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
working_dir: Option<String>,
},
Http {
url: String,
#[serde(default = "default_http_method")]
method: String,
#[serde(default)]
headers: HashMap<String, String>,
#[serde(default)]
body_template: Option<String>,
},
Wasm {
module_path: String,
#[serde(default = "default_wasm_function")]
function: String,
},
Agent {
prompt_template: String,
#[serde(default)]
model: Option<String>,
#[serde(default)]
max_tokens: Option<u32>,
},
}
fn default_http_method() -> String {
"POST".to_string()
}
fn default_wasm_function() -> String {
"handle".to_string()
}
#[allow(dead_code)]
impl HookHandler {
#[must_use]
pub(crate) fn command(command: impl Into<String>) -> Self {
Self::Command {
command: command.into(),
args: Vec::new(),
env: HashMap::new(),
working_dir: None,
}
}
#[must_use]
pub(crate) fn http(url: impl Into<String>) -> Self {
Self::Http {
url: url.into(),
method: "POST".to_string(),
headers: HashMap::new(),
body_template: None,
}
}
#[must_use]
pub(crate) fn wasm(module_path: impl Into<String>) -> Self {
Self::Wasm {
module_path: module_path.into(),
function: "handle".to_string(),
}
}
#[must_use]
pub(crate) fn agent(prompt_template: impl Into<String>) -> Self {
Self::Agent {
prompt_template: prompt_template.into(),
model: None,
max_tokens: None,
}
}
#[must_use]
pub(crate) fn is_stubbed(&self) -> bool {
matches!(self, Self::Agent { .. })
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FailAction {
#[default]
Warn,
Block,
Ignore,
}
impl fmt::Display for FailAction {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Warn => write!(f, "warn"),
Self::Block => write!(f, "block"),
Self::Ignore => write!(f, "ignore"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Hook {
pub id: Uuid,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub description: Option<String>,
pub event: HookEvent,
#[serde(default)]
pub matcher: Option<HookMatcher>,
pub handler: HookHandler,
#[serde(default = "default_timeout")]
pub timeout_secs: u64,
#[serde(default)]
pub fail_action: FailAction,
#[serde(default)]
pub async_mode: bool,
#[serde(default = "default_enabled")]
pub enabled: bool,
#[serde(default = "default_priority")]
pub priority: i32,
}
fn default_timeout() -> u64 {
30
}
fn default_enabled() -> bool {
true
}
fn default_priority() -> i32 {
100
}
#[allow(dead_code)]
impl Hook {
#[must_use]
pub(crate) fn new(event: HookEvent) -> Self {
Self {
id: Uuid::new_v4(),
name: None,
description: None,
event,
matcher: None,
handler: HookHandler::command("echo"),
timeout_secs: 30,
fail_action: FailAction::Warn,
async_mode: false,
enabled: true,
priority: 100,
}
}
#[must_use]
pub(crate) fn with_name(mut self, name: impl Into<String>) -> Self {
self.name = Some(name.into());
self
}
#[must_use]
pub(crate) fn with_description(mut self, description: impl Into<String>) -> Self {
self.description = Some(description.into());
self
}
#[must_use]
pub(crate) fn with_handler(mut self, handler: HookHandler) -> Self {
self.handler = handler;
self
}
#[must_use]
pub(crate) fn with_matcher(mut self, matcher: HookMatcher) -> Self {
self.matcher = Some(matcher);
self
}
#[must_use]
pub(crate) fn with_timeout(mut self, secs: u64) -> Self {
self.timeout_secs = secs;
self
}
#[must_use]
pub(crate) fn with_fail_action(mut self, action: FailAction) -> Self {
self.fail_action = action;
self
}
#[must_use]
pub(crate) fn async_mode(mut self) -> Self {
self.async_mode = true;
self
}
#[must_use]
pub(crate) fn disabled(mut self) -> Self {
self.enabled = false;
self
}
#[must_use]
pub(crate) fn with_priority(mut self, priority: i32) -> Self {
self.priority = priority;
self
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "type")]
pub enum HookMatcher {
Glob {
pattern: String,
},
Regex {
pattern: String,
},
ToolNames {
names: Vec<String>,
},
ServerNames {
names: Vec<String>,
},
}
#[allow(dead_code)]
impl HookMatcher {
#[must_use]
pub(crate) fn glob(pattern: impl Into<String>) -> Self {
Self::Glob {
pattern: pattern.into(),
}
}
#[must_use]
pub(crate) fn regex(pattern: impl Into<String>) -> Self {
Self::Regex {
pattern: pattern.into(),
}
}
#[must_use]
pub(crate) fn tools(names: Vec<String>) -> Self {
Self::ToolNames { names }
}
#[must_use]
pub(crate) fn servers(names: Vec<String>) -> Self {
Self::ServerNames { names }
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_event_display() {
assert_eq!(HookEvent::SessionStart.to_string(), "session_start");
assert_eq!(HookEvent::PreToolCall.to_string(), "pre_tool_call");
}
#[test]
fn test_hook_creation() {
let hook = Hook::new(HookEvent::PreToolCall)
.with_name("log-tool-calls")
.with_handler(HookHandler::command("echo"))
.with_timeout(60);
assert_eq!(hook.event, HookEvent::PreToolCall);
assert_eq!(hook.name, Some("log-tool-calls".to_string()));
assert_eq!(hook.timeout_secs, 60);
assert!(hook.enabled);
}
#[test]
fn test_hook_handler_creation() {
let cmd = HookHandler::command("echo");
assert!(!cmd.is_stubbed());
let wasm = HookHandler::wasm("/path/to/module.wasm");
assert!(!wasm.is_stubbed());
let agent = HookHandler::agent("Analyze this event: {{event}}");
assert!(agent.is_stubbed());
}
#[test]
fn test_hook_matcher() {
let glob = HookMatcher::glob("fs_*");
let regex = HookMatcher::regex(r"^fs_\w+$");
let tools = HookMatcher::tools(vec!["read_file".to_string(), "write_file".to_string()]);
assert!(matches!(glob, HookMatcher::Glob { .. }));
assert!(matches!(regex, HookMatcher::Regex { .. }));
assert!(matches!(tools, HookMatcher::ToolNames { .. }));
}
#[test]
fn test_fail_action_default() {
assert_eq!(FailAction::default(), FailAction::Warn);
}
}