use anyhow::{Context, Result};
use serde::Deserialize;
use std::path::Path;
use std::sync::Arc;
use crate::hook::HookEngine;
use super::script_runner::{ScriptHook, ScriptHookConfig};
use super::webhook::{WebhookHook, WebhookConfig};
use super::async_batcher::{AsyncWebhookRegistry, AsyncWebhookConfig};
#[derive(Debug, Deserialize)]
pub struct HooksConfig {
#[serde(default)]
pub hooks: Vec<ScriptHookConfig>,
#[serde(default)]
pub webhooks: Vec<WebhookConfig>,
#[serde(default)]
pub async_webhooks: Vec<AsyncWebhookConfig>,
}
impl HooksConfig {
pub fn from_file(path: &Path) -> Result<Self> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read hooks config: {}", path.display()))?;
let config: HooksConfig = toml::from_str(&content)
.with_context(|| format!("Failed to parse hooks config: {}", path.display()))?;
Ok(config)
}
pub fn from_dir(dir: &Path) -> Result<Self> {
let config_path = dir.join("hooks.toml");
if config_path.exists() {
Self::from_file(&config_path)
} else {
Ok(Self {
hooks: Vec::new(),
webhooks: Vec::new(),
async_webhooks: Vec::new(),
})
}
}
pub fn register_hooks_to_engine(&self, engine: &mut HookEngine, base_dir: &Path) {
for config in &self.hooks {
if !config.enabled {
continue;
}
let script_path = if config.script.is_absolute() {
config.script.clone()
} else {
base_dir.join(&config.script)
};
if !script_path.exists() {
tracing::warn!("[Hook] Warning: Script not found: {}", script_path.display());
continue;
}
let config_with_path = ScriptHookConfig {
name: config.name.clone(),
trigger: config.trigger.clone(),
script: script_path,
script_type: config.script_type.clone(),
enabled: config.enabled,
timeout_secs: config.timeout_secs,
description: config.description.clone(),
};
let hook = Arc::new(ScriptHook::new(config_with_path));
match config.trigger.as_str() {
"pre_tool" | "pre_tool_execution" => {
engine.register_pre_tool_hook(hook);
}
"post_tool" | "post_tool_execution" => {
engine.register_post_tool_hook(hook);
}
"post_turn" => {
engine.register_post_turn_hook(hook);
}
"system_prompt" => {
engine.register_system_prompt_hook(hook);
}
_ => {
tracing::warn!("[Hook] Warning: Unknown trigger type: {}", config.trigger);
}
}
}
}
pub fn register_webhooks_to_engine(&self, engine: &mut HookEngine) {
let mut async_registry = AsyncWebhookRegistry::new();
for config in &self.async_webhooks {
if !config.enabled {
continue;
}
async_registry.register(config.clone());
}
for config in &self.webhooks {
if !config.enabled {
continue;
}
let webhook = if let Some(batcher) = async_registry.get(&config.name) {
Arc::new(WebhookHook::new_with_async(config.clone(), batcher.clone()))
} else {
Arc::new(WebhookHook::new(config.clone()))
};
Self::register_webhook_by_trigger(engine, &webhook, &config.trigger);
tracing::info!("[Webhook] Registered: {} -> {}", config.name, config.url);
}
if !async_registry.batchers.is_empty() {
tracing::info!("[AsyncWebhook] Registered {} async batchers", async_registry.batchers.len());
}
}
fn register_webhook_by_trigger(
engine: &mut HookEngine,
webhook: &Arc<WebhookHook>,
trigger: &str,
) {
let t = trigger.to_lowercase();
if t.contains("pre_tool") || t.contains("before_tool") {
engine.register_pre_tool_hook(webhook.clone());
}
if t.contains("post_tool") || t.contains("after_tool") {
engine.register_post_tool_hook(webhook.clone());
}
if t.contains("post_turn") {
engine.register_post_turn_hook(webhook.clone());
}
if t.contains("system_prompt") {
engine.register_system_prompt_hook(webhook.clone());
}
if t.contains("session_start") {
engine.register_on_session_start_hook(webhook.clone());
}
if t.contains("session_end") {
engine.register_on_session_end_hook(webhook.clone());
}
if t.contains("error") {
engine.register_on_error_hook(webhook.clone());
}
if t.contains("turn_start") {
engine.register_on_turn_start_hook(webhook.clone());
}
if t.contains("turn_complete") || t.contains("after_turn") {
engine.register_on_turn_complete_hook(webhook.clone());
}
if t.contains("tool_call_start") {
engine.register_on_tool_call_start_hook(webhook.clone());
}
if t.contains("model_response") {
engine.register_on_model_response_hook(webhook.clone());
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::path::Path;
use std::path::PathBuf;
#[test]
fn test_hooks_config_deserialize_toml() {
let toml_str = r#"
[[hooks]]
name = "pre-check"
trigger = "pre_tool"
script = "check.sh"
enabled = true
timeout_secs = 5
[[hooks]]
name = "post-check"
trigger = "post_tool"
script = "report.sh"
script_type = "python"
enabled = true
[[webhooks]]
name = "notify"
trigger = "post_turn"
url = "https://example.com/hook"
[[async_webhooks]]
name = "batch-logger"
trigger = "pre_tool"
url = "https://example.com/batch"
batch_size = 20
"#;
let config: HooksConfig = toml::from_str(toml_str).expect("Should parse TOML");
assert_eq!(config.hooks.len(), 2);
assert_eq!(config.webhooks.len(), 1);
assert_eq!(config.async_webhooks.len(), 1);
assert_eq!(config.hooks[0].name, "pre-check");
assert_eq!(config.hooks[0].trigger, "pre_tool");
assert_eq!(config.hooks[0].script.to_string_lossy(), "check.sh");
assert!(config.hooks[0].enabled);
assert_eq!(config.hooks[0].timeout_secs, 5);
assert_eq!(config.hooks[0].script_type, "shell");
assert_eq!(config.hooks[1].name, "post-check");
assert_eq!(config.hooks[1].trigger, "post_tool");
assert_eq!(config.hooks[1].script_type, "python");
assert_eq!(config.webhooks[0].name, "notify");
assert_eq!(config.webhooks[0].url, "https://example.com/hook");
assert_eq!(config.async_webhooks[0].name, "batch-logger");
assert_eq!(config.async_webhooks[0].batch_size, 20);
}
#[test]
fn test_hooks_config_empty() {
let config: HooksConfig = toml::from_str("").expect("Should parse empty TOML");
assert!(config.hooks.is_empty());
assert!(config.webhooks.is_empty());
assert!(config.async_webhooks.is_empty());
}
#[test]
fn test_from_file_valid_toml() {
let dir = std::env::temp_dir().join(format!("hook_test_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let config_path = dir.join("hooks.toml");
fs::write(&config_path, r#"
[[hooks]]
name = "test-hook"
trigger = "pre_tool"
script = "test.sh"
enabled = true
timeout_secs = 3
"#).expect("Should write test file");
let config = HooksConfig::from_file(&config_path).expect("Should load from file");
assert_eq!(config.hooks.len(), 1);
assert_eq!(config.hooks[0].name, "test-hook");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_from_file_nonexistent() {
let result = HooksConfig::from_file(Path::new("/tmp/nonexistent_hooks_file_12345.toml"));
assert!(result.is_err());
}
#[test]
fn test_from_dir_with_existing_config() {
let dir = std::env::temp_dir().join(format!("hook_test_dir_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
fs::write(dir.join("hooks.toml"), r#"
[[hooks]]
name = "dir-hook"
trigger = "post_turn"
script = "report.sh"
"#).expect("Should write test file");
let config = HooksConfig::from_dir(&dir).expect("Should load from dir");
assert_eq!(config.hooks.len(), 1);
assert_eq!(config.hooks[0].name, "dir-hook");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_from_dir_without_config() {
let dir = std::env::temp_dir().join(format!("hook_test_empty_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let config = HooksConfig::from_dir(&dir).expect("Should return empty config");
assert!(config.hooks.is_empty());
let _ = fs::remove_dir_all(&dir);
}
fn make_script_config(name: &str, trigger: &str, script: &str) -> ScriptHookConfig {
ScriptHookConfig {
name: name.to_string(),
trigger: trigger.to_string(),
script: PathBuf::from(script),
script_type: "shell".to_string(),
enabled: true,
timeout_secs: 5,
description: String::new(),
}
}
#[allow(dead_code)]
fn assert_engine_stats(engine: &HookEngine, expected_has: bool) {
assert_eq!(engine.has_any(), expected_has, "has_any mismatch");
}
#[test]
fn test_register_hooks_to_engine_pre_tool() {
let config = HooksConfig {
hooks: vec![make_script_config("pre", "pre_tool", "/bin/echo")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(engine.has_any());
}
#[test]
fn test_register_hooks_to_engine_post_tool() {
let config = HooksConfig {
hooks: vec![make_script_config("post", "post_tool", "/bin/echo")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(engine.has_any());
}
#[test]
fn test_register_hooks_to_engine_post_turn() {
let config = HooksConfig {
hooks: vec![make_script_config("turn", "post_turn", "/bin/echo")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(engine.has_any());
}
#[test]
fn test_register_hooks_to_engine_system_prompt() {
let config = HooksConfig {
hooks: vec![make_script_config("sys", "system_prompt", "/bin/echo")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(engine.has_any());
}
#[test]
fn test_register_hooks_to_engine_unknown_trigger() {
let config = HooksConfig {
hooks: vec![make_script_config("bad", "unknown_trigger", "/bin/echo")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(!engine.has_any(), "unknown trigger should register nothing");
}
#[test]
fn test_register_hooks_to_engine_disabled_skipped() {
let mut script_cfg = make_script_config("disabled", "pre_tool", "/bin/echo");
script_cfg.enabled = false;
let config = HooksConfig {
hooks: vec![script_cfg],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(!engine.has_any(), "disabled hook should not register");
}
#[test]
fn test_register_hooks_to_engine_nonexistent_script() {
let config = HooksConfig {
hooks: vec![make_script_config("missing", "pre_tool", "/tmp/nonexistent_script_12345.sh")],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_hooks_to_engine(&mut engine, Path::new("/tmp"));
assert!(!engine.has_any(), "nonexistent script should not register");
}
#[test]
fn test_register_webhooks_to_engine_with_webhook() {
let config = HooksConfig {
hooks: vec![],
webhooks: vec![WebhookConfig {
name: "test-webhook".to_string(),
trigger: "pre_tool".to_string(),
url: "http://localhost:9999/hook".to_string(),
enabled: true,
timeout_secs: 5,
method: "POST".to_string(),
headers: std::collections::HashMap::new(),
description: String::new(),
retries: 0,
}],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_webhooks_to_engine(&mut engine);
assert!(engine.has_any());
}
#[test]
fn test_register_webhooks_to_engine_empty() {
let config = HooksConfig {
hooks: vec![],
webhooks: vec![],
async_webhooks: vec![],
};
let mut engine = HookEngine::new();
config.register_webhooks_to_engine(&mut engine);
assert!(!engine.has_any());
}
#[test]
fn test_hook_engine_load_all_from_dir() {
let dir = std::env::temp_dir().join(format!("hook_engine_test_{}", std::process::id()));
let _ = fs::create_dir_all(dir.join(".atomcode").join("hooks"));
fs::write(dir.join(".atomcode").join("hooks").join("hooks.toml"), r#"
[[hooks]]
name = "test-hook"
trigger = "pre_tool"
script = "/bin/echo"
enabled = true
"#).expect("Should write test file");
let mut engine = HookEngine::new();
engine.load_all(&dir);
assert!(engine.has_any(), "load_all should register hooks from dir");
let _ = fs::remove_dir_all(&dir);
}
#[test]
fn test_hook_engine_load_all_empty_dir() {
let dir = std::env::temp_dir().join(format!("hook_engine_empty_{}", std::process::id()));
let _ = fs::create_dir_all(&dir);
let mut engine = HookEngine::new();
engine.load_all(&dir);
assert!(engine.has_any());
let _ = fs::remove_dir_all(&dir);
}
}