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 use std::assert_matches;
176
177 fn cmd_hook(command: &str) -> HookDef {
178 HookDef {
179 action: HookAction::Command {
180 command: command.into(),
181 },
182 timeout_secs: 10,
183 fail_closed: false,
184 r#if: None,
185 }
186 }
187
188 #[test]
189 fn hooks_config_default_is_empty() {
190 let cfg = HooksConfig::default();
191 assert!(cfg.is_empty());
192 }
193
194 #[test]
195 fn file_changed_config_default_debounce() {
196 let cfg = FileChangedConfig::default();
197 assert_eq!(cfg.debounce_ms, 500);
198 assert!(cfg.watch_paths.is_empty());
199 assert!(cfg.hooks.is_empty());
200 }
201
202 #[test]
203 fn hooks_config_parses_from_toml() {
204 let toml = r#"
205[[cwd_changed]]
206type = "command"
207command = "echo changed"
208timeout_secs = 10
209fail_closed = false
210
211[file_changed]
212watch_paths = ["src/", "Cargo.toml"]
213debounce_ms = 300
214[[file_changed.hooks]]
215type = "command"
216command = "cargo check"
217timeout_secs = 30
218fail_closed = false
219
220[[permission_denied]]
221type = "command"
222command = "echo denied"
223timeout_secs = 5
224fail_closed = false
225"#;
226 let cfg: HooksConfig = toml::from_str(toml).unwrap();
227 assert_eq!(cfg.cwd_changed.len(), 1);
228 assert!(
229 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
230 );
231 let fc = cfg.file_changed.as_ref().unwrap();
232 assert_eq!(fc.watch_paths.len(), 2);
233 assert_eq!(fc.debounce_ms, 300);
234 assert_eq!(fc.hooks.len(), 1);
235 assert_eq!(cfg.permission_denied.len(), 1);
236 assert!(
237 matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
238 );
239 }
240
241 #[test]
242 fn hooks_config_parses_mcp_tool_hook() {
243 let toml = r#"
244[[permission_denied]]
245type = "mcp_tool"
246server = "policy"
247tool = "audit"
248[permission_denied.args]
249severity = "high"
250"#;
251 let cfg: HooksConfig = toml::from_str(toml).unwrap();
252 assert_eq!(cfg.permission_denied.len(), 1);
253 assert_matches!(
254 &cfg.permission_denied[0].action,
255 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
256 );
257 }
258
259 #[test]
260 fn hooks_config_not_empty_with_cwd_hooks() {
261 let cfg = HooksConfig {
262 cwd_changed: vec![cmd_hook("echo hi")],
263 file_changed: None,
264 permission_denied: Vec::new(),
265 turn_complete: Vec::new(),
266 hook_block_cap: 8,
267 pre_tool_use: Vec::new(),
268 post_tool_use: Vec::new(),
269 };
270 assert!(!cfg.is_empty());
271 }
272
273 #[test]
274 fn hooks_config_not_empty_with_permission_denied_hooks() {
275 let cfg = HooksConfig {
276 cwd_changed: Vec::new(),
277 file_changed: None,
278 permission_denied: vec![cmd_hook("echo denied")],
279 turn_complete: Vec::new(),
280 hook_block_cap: 8,
281 pre_tool_use: Vec::new(),
282 post_tool_use: Vec::new(),
283 };
284 assert!(!cfg.is_empty());
285 }
286
287 #[test]
288 fn hooks_config_not_empty_with_turn_complete_hooks() {
289 let cfg = HooksConfig {
290 cwd_changed: Vec::new(),
291 file_changed: None,
292 permission_denied: Vec::new(),
293 turn_complete: vec![cmd_hook("notify-send Zeph done")],
294 hook_block_cap: 8,
295 pre_tool_use: Vec::new(),
296 post_tool_use: Vec::new(),
297 };
298 assert!(!cfg.is_empty());
299 }
300
301 #[test]
302 fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
303 let cfg = HooksConfig {
304 cwd_changed: Vec::new(),
305 file_changed: None,
306 permission_denied: Vec::new(),
307 turn_complete: Vec::new(),
308 hook_block_cap: 8,
309 pre_tool_use: Vec::new(),
310 post_tool_use: Vec::new(),
311 };
312 assert!(cfg.is_empty());
313 }
314
315 #[test]
316 fn hooks_config_parses_turn_complete_from_toml() {
317 let toml = r#"
318[[turn_complete]]
319type = "command"
320command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
321timeout_secs = 3
322fail_closed = false
323"#;
324 let cfg: HooksConfig = toml::from_str(toml).unwrap();
325 assert_eq!(cfg.turn_complete.len(), 1);
326 assert!(cfg.cwd_changed.is_empty());
327 assert!(cfg.permission_denied.is_empty());
328 }
329
330 #[test]
331 fn hooks_config_not_empty_with_pre_tool_use() {
332 use crate::subagent::HookMatcher;
333 let cfg = HooksConfig {
334 cwd_changed: Vec::new(),
335 file_changed: None,
336 permission_denied: Vec::new(),
337 turn_complete: Vec::new(),
338 hook_block_cap: 8,
339 pre_tool_use: vec![HookMatcher {
340 matcher: "Edit|Write".to_owned(),
341 hooks: vec![cmd_hook("echo pre")],
342 }],
343 post_tool_use: Vec::new(),
344 };
345 assert!(!cfg.is_empty());
346 }
347
348 #[test]
349 fn hooks_config_parses_pre_and_post_tool_use_from_toml() {
350 let toml = r#"
351[[pre_tool_use]]
352matcher = "Edit|Write"
353[[pre_tool_use.hooks]]
354type = "command"
355command = "echo pre $ZEPH_TOOL_NAME"
356timeout_secs = 5
357fail_closed = false
358
359[[post_tool_use]]
360matcher = "Shell"
361[[post_tool_use.hooks]]
362type = "command"
363command = "echo post $ZEPH_TOOL_DURATION_MS"
364timeout_secs = 5
365fail_closed = false
366"#;
367 let cfg: HooksConfig = toml::from_str(toml).unwrap();
368 assert_eq!(cfg.pre_tool_use.len(), 1);
369 assert_eq!(cfg.pre_tool_use[0].matcher, "Edit|Write");
370 assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
371 assert_eq!(cfg.post_tool_use.len(), 1);
372 assert_eq!(cfg.post_tool_use[0].matcher, "Shell");
373 assert!(!cfg.is_empty());
374 }
375
376 #[test]
380 fn hooks_config_parses_all_sections_in_sequence() {
381 let toml = r#"
382[[cwd_changed]]
383type = "command"
384command = "echo 'CWD_CHANGED_HOOK_FIRED'"
385timeout_secs = 10
386fail_closed = false
387
388[file_changed]
389watch_paths = ["src/", "Cargo.toml"]
390debounce_ms = 500
391[[file_changed.hooks]]
392type = "command"
393command = "cargo check"
394timeout_secs = 30
395fail_closed = false
396
397[[permission_denied]]
398type = "command"
399command = "echo 'PERMISSION_DENIED_HOOK_FIRED'"
400timeout_secs = 5
401fail_closed = false
402"#;
403 let cfg: HooksConfig = toml::from_str(toml).unwrap();
404 assert_eq!(cfg.cwd_changed.len(), 1, "expected 1 cwd_changed hook");
405 assert!(
406 matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo 'CWD_CHANGED_HOOK_FIRED'")
407 );
408 let fc = cfg
409 .file_changed
410 .as_ref()
411 .expect("file_changed must be Some");
412 assert_eq!(fc.hooks.len(), 1, "expected 1 file_changed hook");
413 assert_eq!(fc.debounce_ms, 500);
414 assert_eq!(
415 cfg.permission_denied.len(),
416 1,
417 "expected 1 permission_denied hook"
418 );
419 assert!(!cfg.is_empty(), "hooks config must not be empty");
420 }
421
422 #[test]
423 fn hook_block_cap_default_is_8() {
424 let cfg = HooksConfig::default();
425 assert_eq!(cfg.hook_block_cap, 8);
426 }
427
428 #[test]
429 fn hook_block_cap_parses_from_toml() {
430 let toml = "hook_block_cap = 4\n";
431 let cfg: HooksConfig = toml::from_str(toml).unwrap();
432 assert_eq!(cfg.hook_block_cap, 4);
433 }
434
435 #[test]
436 fn hook_block_cap_zero_from_toml() {
437 let toml = "hook_block_cap = 0\n";
438 let cfg: HooksConfig = toml::from_str(toml).unwrap();
439 assert_eq!(cfg.hook_block_cap, 0);
440 }
441
442 #[test]
443 fn hooks_config_parses_mcp_tool_in_pre_tool_use() {
444 let toml = r#"
445[[pre_tool_use]]
446matcher = "Shell"
447[[pre_tool_use.hooks]]
448type = "mcp_tool"
449server = "policy"
450tool = "audit"
451[pre_tool_use.hooks.args]
452severity = "high"
453"#;
454 let cfg: HooksConfig = toml::from_str(toml).unwrap();
455 assert_eq!(cfg.pre_tool_use.len(), 1);
456 assert_eq!(cfg.pre_tool_use[0].matcher, "Shell");
457 assert_eq!(cfg.pre_tool_use[0].hooks.len(), 1);
458 assert_matches!(
459 &cfg.pre_tool_use[0].hooks[0].action,
460 HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
461 );
462 assert!(!cfg.is_empty());
463 }
464
465 #[test]
468 fn hook_def_if_none_omits_field_in_toml() {
469 let hook = cmd_hook("echo hi");
470 let serialized = toml::to_string(&hook).unwrap();
472 assert!(
473 !serialized.contains("if"),
474 "unexpected `if` key: {serialized}"
475 );
476 }
477
478 #[test]
479 fn hook_def_if_some_roundtrips_via_toml() {
480 use crate::subagent::HookAction;
481 use crate::subagent::HookDef;
482 let hook = HookDef {
483 action: HookAction::Command {
484 command: "echo hi".into(),
485 },
486 timeout_secs: 10,
487 fail_closed: false,
488 r#if: Some("tool:shell".to_owned()),
489 };
490 let serialized = toml::to_string(&hook).unwrap();
491 assert!(
492 serialized.contains("if = \"tool:shell\""),
493 "missing `if` key: {serialized}"
494 );
495 let deserialized: HookDef = toml::from_str(&serialized).unwrap();
496 assert_eq!(deserialized.r#if.as_deref(), Some("tool:shell"));
497 }
498
499 #[test]
500 fn hook_def_if_condition_parses_from_toml() {
501 let toml = r#"
502[[post_tool_use]]
503matcher = "Shell"
504[[post_tool_use.hooks]]
505type = "command"
506command = "echo shell"
507timeout_secs = 5
508fail_closed = false
509if = "tool:shell"
510"#;
511 let cfg: HooksConfig = toml::from_str(toml).unwrap();
512 let hook = &cfg.post_tool_use[0].hooks[0];
513 assert_eq!(hook.r#if.as_deref(), Some("tool:shell"));
514 }
515}