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
14fn default_hook_block_cap() -> usize {
15 8
16}
17
18#[derive(Debug, Clone, Deserialize, Serialize)]
20#[serde(default)]
21pub struct FileChangedConfig {
22 pub watch_paths: Vec<PathBuf>,
24 #[serde(default = "default_debounce_ms")]
26 pub debounce_ms: u64,
27 #[serde(default)]
29 pub hooks: Vec<HookDef>,
30}
31
32impl Default for FileChangedConfig {
33 fn default() -> Self {
34 Self {
35 watch_paths: Vec::new(),
36 debounce_ms: default_debounce_ms(),
37 hooks: Vec::new(),
38 }
39 }
40}
41
42#[derive(Debug, Clone, Deserialize, Serialize)]
70#[serde(default)]
71pub struct HooksConfig {
72 pub cwd_changed: Vec<HookDef>,
74 pub file_changed: Option<FileChangedConfig>,
76 pub permission_denied: Vec<HookDef>,
82 #[serde(default)]
98 pub turn_complete: Vec<HookDef>,
99 #[serde(default = "default_hook_block_cap")]
103 pub hook_block_cap: usize,
104 #[serde(default)]
121 pub pre_tool_use: Vec<HookMatcher>,
122 #[serde(default)]
133 pub post_tool_use: Vec<HookMatcher>,
134}
135
136impl Default for HooksConfig {
137 fn default() -> Self {
138 Self {
139 cwd_changed: Vec::new(),
140 file_changed: None,
141 permission_denied: Vec::new(),
142 turn_complete: Vec::new(),
143 hook_block_cap: default_hook_block_cap(),
144 pre_tool_use: Vec::new(),
145 post_tool_use: Vec::new(),
146 }
147 }
148}
149
150impl HooksConfig {
151 #[must_use]
161 pub fn is_empty(&self) -> bool {
162 self.cwd_changed.is_empty()
163 && self.file_changed.is_none()
164 && self.permission_denied.is_empty()
165 && self.turn_complete.is_empty()
166 && self.pre_tool_use.is_empty()
167 && self.post_tool_use.is_empty()
168 }
169}
170
171#[cfg(test)]
172mod tests {
173 use super::*;
174 use crate::subagent::HookAction;
175
176 fn cmd_hook(command: &str) -> HookDef {
177 HookDef {
178 action: HookAction::Command {
179 command: command.into(),
180 },
181 timeout_secs: 10,
182 fail_closed: false,
183 }
184 }
185
186 #[test]
187 fn hooks_config_default_is_empty() {
188 let cfg = HooksConfig::default();
189 assert!(cfg.is_empty());
190 }
191
192 #[test]
193 fn file_changed_config_default_debounce() {
194 let cfg = FileChangedConfig::default();
195 assert_eq!(cfg.debounce_ms, 500);
196 assert!(cfg.watch_paths.is_empty());
197 assert!(cfg.hooks.is_empty());
198 }
199
200 #[test]
201 fn hooks_config_parses_from_toml() {
202 let toml = r#"
203[[cwd_changed]]
204type = "command"
205command = "echo changed"
206timeout_secs = 10
207fail_closed = false
208
209[file_changed]
210watch_paths = ["src/", "Cargo.toml"]
211debounce_ms = 300
212[[file_changed.hooks]]
213type = "command"
214command = "cargo check"
215timeout_secs = 30
216fail_closed = false
217
218[[permission_denied]]
219type = "command"
220command = "echo denied"
221timeout_secs = 5
222fail_closed = false
223"#;
224 let cfg: HooksConfig = toml::from_str(toml).unwrap();
225 assert_eq!(cfg.cwd_changed.len(), 1);
226 assert!(
227 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
228 );
229 let fc = cfg.file_changed.as_ref().unwrap();
230 assert_eq!(fc.watch_paths.len(), 2);
231 assert_eq!(fc.debounce_ms, 300);
232 assert_eq!(fc.hooks.len(), 1);
233 assert_eq!(cfg.permission_denied.len(), 1);
234 assert!(
235 matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
236 );
237 }
238
239 #[test]
240 fn hooks_config_parses_mcp_tool_hook() {
241 let toml = r#"
242[[permission_denied]]
243type = "mcp_tool"
244server = "policy"
245tool = "audit"
246[permission_denied.args]
247severity = "high"
248"#;
249 let cfg: HooksConfig = toml::from_str(toml).unwrap();
250 assert_eq!(cfg.permission_denied.len(), 1);
251 assert!(matches!(
252 &cfg.permission_denied[0].action,
253 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
254 ));
255 }
256
257 #[test]
258 fn hooks_config_not_empty_with_cwd_hooks() {
259 let cfg = HooksConfig {
260 cwd_changed: vec![cmd_hook("echo hi")],
261 file_changed: None,
262 permission_denied: Vec::new(),
263 turn_complete: Vec::new(),
264 hook_block_cap: 8,
265 pre_tool_use: Vec::new(),
266 post_tool_use: Vec::new(),
267 };
268 assert!(!cfg.is_empty());
269 }
270
271 #[test]
272 fn hooks_config_not_empty_with_permission_denied_hooks() {
273 let cfg = HooksConfig {
274 cwd_changed: Vec::new(),
275 file_changed: None,
276 permission_denied: vec![cmd_hook("echo denied")],
277 turn_complete: Vec::new(),
278 hook_block_cap: 8,
279 pre_tool_use: Vec::new(),
280 post_tool_use: Vec::new(),
281 };
282 assert!(!cfg.is_empty());
283 }
284
285 #[test]
286 fn hooks_config_not_empty_with_turn_complete_hooks() {
287 let cfg = HooksConfig {
288 cwd_changed: Vec::new(),
289 file_changed: None,
290 permission_denied: Vec::new(),
291 turn_complete: vec![cmd_hook("notify-send Zeph done")],
292 hook_block_cap: 8,
293 pre_tool_use: Vec::new(),
294 post_tool_use: Vec::new(),
295 };
296 assert!(!cfg.is_empty());
297 }
298
299 #[test]
300 fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
301 let cfg = HooksConfig {
302 cwd_changed: Vec::new(),
303 file_changed: None,
304 permission_denied: Vec::new(),
305 turn_complete: Vec::new(),
306 hook_block_cap: 8,
307 pre_tool_use: Vec::new(),
308 post_tool_use: Vec::new(),
309 };
310 assert!(cfg.is_empty());
311 }
312
313 #[test]
314 fn hooks_config_parses_turn_complete_from_toml() {
315 let toml = r#"
316[[turn_complete]]
317type = "command"
318command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
319timeout_secs = 3
320fail_closed = false
321"#;
322 let cfg: HooksConfig = toml::from_str(toml).unwrap();
323 assert_eq!(cfg.turn_complete.len(), 1);
324 assert!(cfg.cwd_changed.is_empty());
325 assert!(cfg.permission_denied.is_empty());
326 }
327
328 #[test]
329 fn hooks_config_not_empty_with_pre_tool_use() {
330 use crate::subagent::HookMatcher;
331 let cfg = HooksConfig {
332 cwd_changed: Vec::new(),
333 file_changed: None,
334 permission_denied: Vec::new(),
335 turn_complete: Vec::new(),
336 hook_block_cap: 8,
337 pre_tool_use: vec![HookMatcher {
338 matcher: "Edit|Write".to_owned(),
339 hooks: vec![cmd_hook("echo pre")],
340 }],
341 post_tool_use: Vec::new(),
342 };
343 assert!(!cfg.is_empty());
344 }
345
346 #[test]
347 fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
348 let toml = r#"
349[[pre_tool_use]]
350matcher = "Edit|Write"
351[[pre_tool_use.hooks]]
352type = "command"
353command = "echo pre $ZEPH_TOOL_NAME"
354timeout_secs = 5
355fail_closed = false
356
357[[post_tool_use]]
358matcher = "Shell"
359[[post_tool_use.hooks]]
360type = "command"
361command = "echo post $ZEPH_TOOL_DURATION_MS"
362timeout_secs = 5
363fail_closed = false
364"#;
365 let cfg: HooksConfig = toml::from_str(toml).unwrap();
366 assert_eq!(cfg.pre_tool_use.len(), 1);
367 assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
368 assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
369 assert_eq!(cfg.post_tool_use.len(), 1);
370 assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
371 assert!(!cfg.is_empty());
372 }
373
374 #[test]
378 fn hooks_config_parses_all_sections_in_sequence() {
379 let toml = r#"
380[[cwd_changed]]
381type = "command"
382command = "echo 'CWD_CHANGED_HOOK_FIRED'"
383timeout_secs = 10
384fail_closed = false
385
386[file_changed]
387watch_paths = ["src/", "Cargo.toml"]
388debounce_ms = 500
389[[file_changed.hooks]]
390type = "command"
391command = "cargo check"
392timeout_secs = 30
393fail_closed = false
394
395[[permission_denied]]
396type = "command"
397command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
398timeout_secs = 5
399fail_closed = false
400"#;
401 let cfg: HooksConfig = toml::from_str(toml).unwrap();
402 assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
403 assert!(
404 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
405 );
406 let fc = cfg
407 .file_changed
408 .as_ref()
409 .expect("file_changed must be Some");
410 assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
411 assert_eq!(fc.debounce_ms, 500);
412 assert_eq!(
413 cfg.permission_denied.len(),
414 1,
415 "expected 1 permission_denied hook"
416 );
417 assert!(!cfg.is_empty(), "hooks config must not be empty");
418 }
419
420 #[test]
421 fn hook_block_cap_default_is_8() {
422 let cfg = HooksConfig::default();
423 assert_eq!(cfg.hook_block_cap, 8);
424 }
425
426 #[test]
427 fn hook_block_cap_parses_from_toml() {
428 let toml = "hook_block_cap = 4\n";
429 let cfg: HooksConfig = toml::from_str(toml).unwrap();
430 assert_eq!(cfg.hook_block_cap, 4);
431 }
432
433 #[test]
434 fn hook_block_cap_zero_from_toml() {
435 let toml = "hook_block_cap = 0\n";
436 let cfg: HooksConfig = toml::from_str(toml).unwrap();
437 assert_eq!(cfg.hook_block_cap, 0);
438 }
439
440 #[test]
441 fn hooks_config_parses_mcp_tool_in_pre_tool_use() {
442 let toml = r#"
443[[pre_tool_use]]
444matcher = "Shell"
445[[pre_tool_use.hooks]]
446type = "mcp_tool"
447server = "policy"
448tool = "audit"
449[pre_tool_use.hooks.args]
450severity = "high"
451"#;
452 let cfg: HooksConfig = toml::from_str(toml).unwrap();
453 assert_eq!(cfg.pre_tool_use.len(), 1);
454 assert_eq!(cfg.pre_tool_use[0].matcher, "Shell");
455 assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
456 assert!(matches!(
457 &cfg.pre_tool_use[0].hooks[0].action,
458 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
459 ));
460 assert!(!cfg.is_empty());
461 }
462}