use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::subagent::{HookDef, HookMatcher};
fn default_debounce_ms() -> u64 {
500
}
fn default_hook_block_cap() -> usize {
8
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct FileChangedConfig {
pub watch_paths: Vec<PathBuf>,
#[serde(default = "default_debounce_ms")]
pub debounce_ms: u64,
#[serde(default)]
pub hooks: Vec<HookDef>,
}
impl Default for FileChangedConfig {
fn default() -> Self {
Self {
watch_paths: Vec::new(),
debounce_ms: default_debounce_ms(),
hooks: Vec::new(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct HooksConfig {
pub cwd_changed: Vec<HookDef>,
pub file_changed: Option<FileChangedConfig>,
pub permission_denied: Vec<HookDef>,
#[serde(default)]
pub turn_complete: Vec<HookDef>,
#[serde(default = "default_hook_block_cap")]
pub hook_block_cap: usize,
#[serde(default)]
pub pre_tool_use: Vec<HookMatcher>,
#[serde(default)]
pub post_tool_use: Vec<HookMatcher>,
}
impl Default for HooksConfig {
fn default() -> Self {
Self {
cwd_changed: Vec::new(),
file_changed: None,
permission_denied: Vec::new(),
turn_complete: Vec::new(),
hook_block_cap: default_hook_block_cap(),
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
}
}
}
impl HooksConfig {
#[must_use]
pub fn is_empty(&self) -> bool {
self.cwd_changed.is_empty()
&& self.file_changed.is_none()
&& self.permission_denied.is_empty()
&& self.turn_complete.is_empty()
&& self.pre_tool_use.is_empty()
&& self.post_tool_use.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subagent::HookAction;
use std::assert_matches;
fn cmd_hook(command: &str) -> HookDef {
HookDef {
action: HookAction::Command {
command: command.into(),
},
timeout_secs: 10,
fail_closed: false,
r#if: None,
}
}
#[test]
fn hooks_config_default_is_empty() {
let cfg = HooksConfig::default();
assert!(cfg.is_empty());
}
#[test]
fn file_changed_config_default_debounce() {
let cfg = FileChangedConfig::default();
assert_eq!(cfg.debounce_ms, 500);
assert!(cfg.watch_paths.is_empty());
assert!(cfg.hooks.is_empty());
}
#[test]
fn hooks_config_parses_from_toml() {
let toml = r#"
[[cwd_changed]]
type = "command"
command = "echo changed"
timeout_secs = 10
fail_closed = false
[file_changed]
watch_paths = ["src/", "Cargo.toml"]
debounce_ms = 300
[[file_changed.hooks]]
type = "command"
command = "cargo check"
timeout_secs = 30
fail_closed = false
[[permission_denied]]
type = "command"
command = "echo denied"
timeout_secs = 5
fail_closed = false
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.cwd_changed.len(), 1);
assert!(
matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
);
let fc = cfg.file_changed.as_ref().unwrap();
assert_eq!(fc.watch_paths.len(), 2);
assert_eq!(fc.debounce_ms, 300);
assert_eq!(fc.hooks.len(), 1);
assert_eq!(cfg.permission_denied.len(), 1);
assert!(
matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
);
}
#[test]
fn hooks_config_parses_mcp_tool_hook() {
let toml = r#"
[[permission_denied]]
type = "mcp_tool"
server = "policy"
tool = "audit"
[permission_denied.args]
severity = "high"
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.permission_denied.len(), 1);
assert_matches!(
&cfg.permission_denied[0].action,
HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
);
}
#[test]
fn hooks_config_not_empty_with_cwd_hooks() {
let cfg = HooksConfig {
cwd_changed: vec![cmd_hook("echo hi")],
file_changed: None,
permission_denied: Vec::new(),
turn_complete: Vec::new(),
hook_block_cap: 8,
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
};
assert!(!cfg.is_empty());
}
#[test]
fn hooks_config_not_empty_with_permission_denied_hooks() {
let cfg = HooksConfig {
cwd_changed: Vec::new(),
file_changed: None,
permission_denied: vec![cmd_hook("echo denied")],
turn_complete: Vec::new(),
hook_block_cap: 8,
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
};
assert!(!cfg.is_empty());
}
#[test]
fn hooks_config_not_empty_with_turn_complete_hooks() {
let cfg = HooksConfig {
cwd_changed: Vec::new(),
file_changed: None,
permission_denied: Vec::new(),
turn_complete: vec![cmd_hook("notify-send Zeph done")],
hook_block_cap: 8,
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
};
assert!(!cfg.is_empty());
}
#[test]
fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
let cfg = HooksConfig {
cwd_changed: Vec::new(),
file_changed: None,
permission_denied: Vec::new(),
turn_complete: Vec::new(),
hook_block_cap: 8,
pre_tool_use: Vec::new(),
post_tool_use: Vec::new(),
};
assert!(cfg.is_empty());
}
#[test]
fn hooks_config_parses_turn_complete_from_toml() {
let toml = r#"
[[turn_complete]]
type = "command"
command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
timeout_secs = 3
fail_closed = false
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.turn_complete.len(), 1);
assert!(cfg.cwd_changed.is_empty());
assert!(cfg.permission_denied.is_empty());
}
#[test]
fn hooks_config_not_empty_with_pre_tool_use() {
use crate::subagent::HookMatcher;
let cfg = HooksConfig {
cwd_changed: Vec::new(),
file_changed: None,
permission_denied: Vec::new(),
turn_complete: Vec::new(),
hook_block_cap: 8,
pre_tool_use: vec![HookMatcher {
matcher: "Edit|Write".to_owned(),
hooks: vec![cmd_hook("echo pre")],
}],
post_tool_use: Vec::new(),
};
assert!(!cfg.is_empty());
}
#[test]
fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
let toml = r#"
[[pre_tool_use]]
matcher = "Edit|Write"
[[pre_tool_use.hooks]]
type = "command"
command = "echo pre $ZEPH_TOOL_NAME"
timeout_secs = 5
fail_closed = false
[[post_tool_use]]
matcher = "Shell"
[[post_tool_use.hooks]]
type = "command"
command = "echo post $ZEPH_TOOL_DURATION_MS"
timeout_secs = 5
fail_closed = false
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.pre_tool_use.len(), 1);
assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
assert_eq!(cfg.post_tool_use.len(), 1);
assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
assert!(!cfg.is_empty());
}
#[test]
fn hooks_config_parses_all_sections_in_sequence() {
let toml = r#"
[[cwd_changed]]
type = "command"
command = "echo 'CWD_CHANGED_HOOK_FIRED'"
timeout_secs = 10
fail_closed = false
[file_changed]
watch_paths = ["src/", "Cargo.toml"]
debounce_ms = 500
[[file_changed.hooks]]
type = "command"
command = "cargo check"
timeout_secs = 30
fail_closed = false
[[permission_denied]]
type = "command"
command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
timeout_secs = 5
fail_closed = false
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
assert!(
matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
);
let fc = cfg
.file_changed
.as_ref()
.expect("file_changed must be Some");
assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
assert_eq!(fc.debounce_ms, 500);
assert_eq!(
cfg.permission_denied.len(),
1,
"expected 1 permission_denied hook"
);
assert!(!cfg.is_empty(), "hooks config must not be empty");
}
#[test]
fn hook_block_cap_default_is_8() {
let cfg = HooksConfig::default();
assert_eq!(cfg.hook_block_cap, 8);
}
#[test]
fn hook_block_cap_parses_from_toml() {
let toml = "hook_block_cap = 4\n";
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hook_block_cap, 4);
}
#[test]
fn hook_block_cap_zero_from_toml() {
let toml = "hook_block_cap = 0\n";
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.hook_block_cap, 0);
}
#[test]
fn hooks_config_parses_mcp_tool_in_pre_tool_use() {
let toml = r#"
[[pre_tool_use]]
matcher = "Shell"
[[pre_tool_use.hooks]]
type = "mcp_tool"
server = "policy"
tool = "audit"
[pre_tool_use.hooks.args]
severity = "high"
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
assert_eq!(cfg.pre_tool_use.len(), 1);
assert_eq!(cfg.pre_tool_use[0].matcher, "Shell");
assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
assert_matches!(
&cfg.pre_tool_use[0].hooks[0].action,
HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
);
assert!(!cfg.is_empty());
}
#[test]
fn hook_def_if_none_omits_field_in_toml() {
let hook = cmd_hook("echo hi");
let serialized = toml::to_string(&hook).unwrap();
assert!(
!serialized.contains("if"),
"unexpected `if` key: {serialized}"
);
}
#[test]
fn hook_def_if_some_roundtrips_via_toml() {
use crate::subagent::HookAction;
use crate::subagent::HookDef;
let hook = HookDef {
action: HookAction::Command {
command: "echo hi".into(),
},
timeout_secs: 10,
fail_closed: false,
r#if: Some("tool:shell".to_owned()),
};
let serialized = toml::to_string(&hook).unwrap();
assert!(
serialized.contains("if = \"tool:shell\""),
"missing `if` key: {serialized}"
);
let deserialized: HookDef = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.r#if.as_deref(), Some("tool:shell"));
}
#[test]
fn hook_def_if_condition_parses_from_toml() {
let toml = r#"
[[post_tool_use]]
matcher = "Shell"
[[post_tool_use.hooks]]
type = "command"
command = "echo shell"
timeout_secs = 5
fail_closed = false
if = "tool:shell"
"#;
let cfg: HooksConfig = toml::from_str(toml).unwrap();
let hook = &cfg.post_tool_use[0].hooks[0];
assert_eq!(hook.r#if.as_deref(), Some("tool:shell"));
}
}