use std::path::PathBuf;
use serde::{Deserialize, Serialize};
use crate::subagent::HookDef;
fn default_debounce_ms() -> u64 {
500
}
#[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, Default, 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>,
}
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()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::subagent::HookAction;
fn cmd_hook(command: &str) -> HookDef {
HookDef {
action: HookAction::Command {
command: command.into(),
},
timeout_secs: 10,
fail_closed: false,
}
}
#[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(),
};
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(),
};
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")],
};
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(),
};
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());
}
}