1use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::subagent::{HookDef, HookMatcher};
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 #[serde(default)]
89 pub pre_tool_use: Vec<HookMatcher>,
90 #[serde(default)]
101 pub post_tool_use: Vec<HookMatcher>,
102}
103
104impl HooksConfig {
105 #[must_use]
115 pub fn is_empty(&self) -> bool {
116 self.cwd_changed.is_empty()
117 && self.file_changed.is_none()
118 && self.permission_denied.is_empty()
119 && self.turn_complete.is_empty()
120 && self.pre_tool_use.is_empty()
121 && self.post_tool_use.is_empty()
122 }
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128 use crate::subagent::HookAction;
129
130 fn cmd_hook(command: &str) -> HookDef {
131 HookDef {
132 action: HookAction::Command {
133 command: command.into(),
134 },
135 timeout_secs: 10,
136 fail_closed: false,
137 }
138 }
139
140 #[test]
141 fn hooks_config_default_is_empty() {
142 let cfg = HooksConfig::default();
143 assert!(cfg.is_empty());
144 }
145
146 #[test]
147 fn file_changed_config_default_debounce() {
148 let cfg = FileChangedConfig::default();
149 assert_eq!(cfg.debounce_ms, 500);
150 assert!(cfg.watch_paths.is_empty());
151 assert!(cfg.hooks.is_empty());
152 }
153
154 #[test]
155 fn hooks_config_parses_from_toml() {
156 let toml = r#"
157[[cwd_changed]]
158type = "command"
159command = "echo changed"
160timeout_secs = 10
161fail_closed = false
162
163[file_changed]
164watch_paths = ["src/", "Cargo.toml"]
165debounce_ms = 300
166[[file_changed.hooks]]
167type = "command"
168command = "cargo check"
169timeout_secs = 30
170fail_closed = false
171
172[[permission_denied]]
173type = "command"
174command = "echo denied"
175timeout_secs = 5
176fail_closed = false
177"#;
178 let cfg: HooksConfig = toml::from_str(toml).unwrap();
179 assert_eq!(cfg.cwd_changed.len(), 1);
180 assert!(
181 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
182 );
183 let fc = cfg.file_changed.as_ref().unwrap();
184 assert_eq!(fc.watch_paths.len(), 2);
185 assert_eq!(fc.debounce_ms, 300);
186 assert_eq!(fc.hooks.len(), 1);
187 assert_eq!(cfg.permission_denied.len(), 1);
188 assert!(
189 matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
190 );
191 }
192
193 #[test]
194 fn hooks_config_parses_mcp_tool_hook() {
195 let toml = r#"
196[[permission_denied]]
197type = "mcp_tool"
198server = "policy"
199tool = "audit"
200[permission_denied.args]
201severity = "high"
202"#;
203 let cfg: HooksConfig = toml::from_str(toml).unwrap();
204 assert_eq!(cfg.permission_denied.len(), 1);
205 assert!(matches!(
206 &cfg.permission_denied[0].action,
207 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
208 ));
209 }
210
211 #[test]
212 fn hooks_config_not_empty_with_cwd_hooks() {
213 let cfg = HooksConfig {
214 cwd_changed: vec![cmd_hook("echo hi")],
215 file_changed: None,
216 permission_denied: Vec::new(),
217 turn_complete: Vec::new(),
218 pre_tool_use: Vec::new(),
219 post_tool_use: Vec::new(),
220 };
221 assert!(!cfg.is_empty());
222 }
223
224 #[test]
225 fn hooks_config_not_empty_with_permission_denied_hooks() {
226 let cfg = HooksConfig {
227 cwd_changed: Vec::new(),
228 file_changed: None,
229 permission_denied: vec![cmd_hook("echo denied")],
230 turn_complete: Vec::new(),
231 pre_tool_use: Vec::new(),
232 post_tool_use: Vec::new(),
233 };
234 assert!(!cfg.is_empty());
235 }
236
237 #[test]
238 fn hooks_config_not_empty_with_turn_complete_hooks() {
239 let cfg = HooksConfig {
240 cwd_changed: Vec::new(),
241 file_changed: None,
242 permission_denied: Vec::new(),
243 turn_complete: vec![cmd_hook("notify-send Zeph done")],
244 pre_tool_use: Vec::new(),
245 post_tool_use: Vec::new(),
246 };
247 assert!(!cfg.is_empty());
248 }
249
250 #[test]
251 fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
252 let cfg = HooksConfig {
253 cwd_changed: Vec::new(),
254 file_changed: None,
255 permission_denied: Vec::new(),
256 turn_complete: Vec::new(),
257 pre_tool_use: Vec::new(),
258 post_tool_use: Vec::new(),
259 };
260 assert!(cfg.is_empty());
261 }
262
263 #[test]
264 fn hooks_config_parses_turn_complete_from_toml() {
265 let toml = r#"
266[[turn_complete]]
267type = "command"
268command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
269timeout_secs = 3
270fail_closed = false
271"#;
272 let cfg: HooksConfig = toml::from_str(toml).unwrap();
273 assert_eq!(cfg.turn_complete.len(), 1);
274 assert!(cfg.cwd_changed.is_empty());
275 assert!(cfg.permission_denied.is_empty());
276 }
277
278 #[test]
279 fn hooks_config_not_empty_with_pre_tool_use() {
280 use crate::subagent::HookMatcher;
281 let cfg = HooksConfig {
282 cwd_changed: Vec::new(),
283 file_changed: None,
284 permission_denied: Vec::new(),
285 turn_complete: Vec::new(),
286 pre_tool_use: vec![HookMatcher {
287 matcher: "Edit|Write".to_owned(),
288 hooks: vec![cmd_hook("echo pre")],
289 }],
290 post_tool_use: Vec::new(),
291 };
292 assert!(!cfg.is_empty());
293 }
294
295 #[test]
296 fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
297 let toml = r#"
298[[pre_tool_use]]
299matcher = "Edit|Write"
300[[pre_tool_use.hooks]]
301type = "command"
302command = "echo pre $ZEPH_TOOL_NAME"
303timeout_secs = 5
304fail_closed = false
305
306[[post_tool_use]]
307matcher = "Shell"
308[[post_tool_use.hooks]]
309type = "command"
310command = "echo post $ZEPH_TOOL_DURATION_MS"
311timeout_secs = 5
312fail_closed = false
313"#;
314 let cfg: HooksConfig = toml::from_str(toml).unwrap();
315 assert_eq!(cfg.pre_tool_use.len(), 1);
316 assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
317 assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
318 assert_eq!(cfg.post_tool_use.len(), 1);
319 assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
320 assert!(!cfg.is_empty());
321 }
322
323 #[test]
327 fn hooks_config_parses_all_sections_in_sequence() {
328 let toml = r#"
329[[cwd_changed]]
330type = "command"
331command = "echo 'CWD_CHANGED_HOOK_FIRED'"
332timeout_secs = 10
333fail_closed = false
334
335[file_changed]
336watch_paths = ["src/", "Cargo.toml"]
337debounce_ms = 500
338[[file_changed.hooks]]
339type = "command"
340command = "cargo check"
341timeout_secs = 30
342fail_closed = false
343
344[[permission_denied]]
345type = "command"
346command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
347timeout_secs = 5
348fail_closed = false
349"#;
350 let cfg: HooksConfig = toml::from_str(toml).unwrap();
351 assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
352 assert!(
353 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
354 );
355 let fc = cfg
356 .file_changed
357 .as_ref()
358 .expect("file_changed must be Some");
359 assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
360 assert_eq!(fc.debounce_ms, 500);
361 assert_eq!(
362 cfg.permission_denied.len(),
363 1,
364 "expected 1 permission_denied hook"
365 );
366 assert!(!cfg.is_empty(), "hooks config must not be empty");
367 }
368}