1use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::subagent::HookDef;
9
10fn default_debounce_ms() -> u64 {
11 500
12}
13
14#[derive(Debug, Clone, Deserialize, Serialize)]
16#[serde(default)]
17pub struct FileChangedConfig {
18 pub watch_paths: Vec<PathBuf>,
20 #[serde(default = "default_debounce_ms")]
22 pub debounce_ms: u64,
23 #[serde(default)]
25 pub hooks: Vec<HookDef>,
26}
27
28impl Default for FileChangedConfig {
29 fn default() -> Self {
30 Self {
31 watch_paths: Vec::new(),
32 debounce_ms: default_debounce_ms(),
33 hooks: Vec::new(),
34 }
35 }
36}
37
38#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default)]
44pub struct HooksConfig {
45 pub cwd_changed: Vec<HookDef>,
47 pub file_changed: Option<FileChangedConfig>,
49 pub permission_denied: Vec<HookDef>,
55 #[serde(default)]
71 pub turn_complete: Vec<HookDef>,
72}
73
74impl HooksConfig {
75 #[must_use]
85 pub fn is_empty(&self) -> bool {
86 self.cwd_changed.is_empty()
87 && self.file_changed.is_none()
88 && self.permission_denied.is_empty()
89 && self.turn_complete.is_empty()
90 }
91}
92
93#[cfg(test)]
94mod tests {
95 use super::*;
96 use crate::subagent::HookAction;
97
98 fn cmd_hook(command: &str) -> HookDef {
99 HookDef {
100 action: HookAction::Command {
101 command: command.into(),
102 },
103 timeout_secs: 10,
104 fail_closed: false,
105 }
106 }
107
108 #[test]
109 fn hooks_config_default_is_empty() {
110 let cfg = HooksConfig::default();
111 assert!(cfg.is_empty());
112 }
113
114 #[test]
115 fn file_changed_config_default_debounce() {
116 let cfg = FileChangedConfig::default();
117 assert_eq!(cfg.debounce_ms, 500);
118 assert!(cfg.watch_paths.is_empty());
119 assert!(cfg.hooks.is_empty());
120 }
121
122 #[test]
123 fn hooks_config_parses_from_toml() {
124 let toml = r#"
125[[cwd_changed]]
126type = "command"
127command = "echo changed"
128timeout_secs = 10
129fail_closed = false
130
131[file_changed]
132watch_paths = ["src/", "Cargo.toml"]
133debounce_ms = 300
134[[file_changed.hooks]]
135type = "command"
136command = "cargo check"
137timeout_secs = 30
138fail_closed = false
139
140[[permission_denied]]
141type = "command"
142command = "echo denied"
143timeout_secs = 5
144fail_closed = false
145"#;
146 let cfg: HooksConfig = toml::from_str(toml).unwrap();
147 assert_eq!(cfg.cwd_changed.len(), 1);
148 assert!(
149 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
150 );
151 let fc = cfg.file_changed.as_ref().unwrap();
152 assert_eq!(fc.watch_paths.len(), 2);
153 assert_eq!(fc.debounce_ms, 300);
154 assert_eq!(fc.hooks.len(), 1);
155 assert_eq!(cfg.permission_denied.len(), 1);
156 assert!(
157 matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
158 );
159 }
160
161 #[test]
162 fn hooks_config_parses_mcp_tool_hook() {
163 let toml = r#"
164[[permission_denied]]
165type = "mcp_tool"
166server = "policy"
167tool = "audit"
168[permission_denied.args]
169severity = "high"
170"#;
171 let cfg: HooksConfig = toml::from_str(toml).unwrap();
172 assert_eq!(cfg.permission_denied.len(), 1);
173 assert!(matches!(
174 &cfg.permission_denied[0].action,
175 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
176 ));
177 }
178
179 #[test]
180 fn hooks_config_not_empty_with_cwd_hooks() {
181 let cfg = HooksConfig {
182 cwd_changed: vec![cmd_hook("echo hi")],
183 file_changed: None,
184 permission_denied: Vec::new(),
185 turn_complete: Vec::new(),
186 };
187 assert!(!cfg.is_empty());
188 }
189
190 #[test]
191 fn hooks_config_not_empty_with_permission_denied_hooks() {
192 let cfg = HooksConfig {
193 cwd_changed: Vec::new(),
194 file_changed: None,
195 permission_denied: vec![cmd_hook("echo denied")],
196 turn_complete: Vec::new(),
197 };
198 assert!(!cfg.is_empty());
199 }
200
201 #[test]
202 fn hooks_config_not_empty_with_turn_complete_hooks() {
203 let cfg = HooksConfig {
204 cwd_changed: Vec::new(),
205 file_changed: None,
206 permission_denied: Vec::new(),
207 turn_complete: vec![cmd_hook("notify-send Zeph done")],
208 };
209 assert!(!cfg.is_empty());
210 }
211
212 #[test]
213 fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
214 let cfg = HooksConfig {
215 cwd_changed: Vec::new(),
216 file_changed: None,
217 permission_denied: Vec::new(),
218 turn_complete: Vec::new(),
219 };
220 assert!(cfg.is_empty());
221 }
222
223 #[test]
224 fn hooks_config_parses_turn_complete_from_toml() {
225 let toml = r#"
226[[turn_complete]]
227type = "command"
228command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
229timeout_secs = 3
230fail_closed = false
231"#;
232 let cfg: HooksConfig = toml::from_str(toml).unwrap();
233 assert_eq!(cfg.turn_complete.len(), 1);
234 assert!(cfg.cwd_changed.is_empty());
235 assert!(cfg.permission_denied.is_empty());
236 }
237}