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