Skip to main content

zeph_config/
hooks.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7
8use crate::subagent::HookDef;
9
10fn default_debounce_ms() -> u64 {
11    500
12}
13
14/// Configuration for hooks triggered when watched files change.
15#[derive(Debug, Clone, Deserialize, Serialize)]
16#[serde(default)]
17pub struct FileChangedConfig {
18    /// Paths to watch for changes. Resolved relative to the project root (cwd at startup).
19    pub watch_paths: Vec<PathBuf>,
20    /// Debounce interval in milliseconds. Default: 500.
21    #[serde(default = "default_debounce_ms")]
22    pub debounce_ms: u64,
23    /// Hooks fired when a watched file changes.
24    #[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/// Top-level hooks configuration section.
39#[derive(Debug, Clone, Default, Deserialize, Serialize)]
40#[serde(default)]
41pub struct HooksConfig {
42    /// Hooks fired when the agent's working directory changes via `set_working_directory`.
43    pub cwd_changed: Vec<HookDef>,
44    /// File-change watcher configuration with associated hooks.
45    pub file_changed: Option<FileChangedConfig>,
46}
47
48impl HooksConfig {
49    #[must_use]
50    pub fn is_empty(&self) -> bool {
51        self.cwd_changed.is_empty() && self.file_changed.is_none()
52    }
53}
54
55#[cfg(test)]
56mod tests {
57    use super::*;
58    use crate::subagent::HookType;
59
60    #[test]
61    fn hooks_config_default_is_empty() {
62        let cfg = HooksConfig::default();
63        assert!(cfg.is_empty());
64    }
65
66    #[test]
67    fn file_changed_config_default_debounce() {
68        let cfg = FileChangedConfig::default();
69        assert_eq!(cfg.debounce_ms, 500);
70        assert!(cfg.watch_paths.is_empty());
71        assert!(cfg.hooks.is_empty());
72    }
73
74    #[test]
75    fn hooks_config_parses_from_toml() {
76        let toml = r#"
77[[cwd_changed]]
78type = "command"
79command = "echo changed"
80timeout_secs = 10
81fail_closed = false
82
83[file_changed]
84watch_paths = ["src/", "Cargo.toml"]
85debounce_ms = 300
86[[file_changed.hooks]]
87type = "command"
88command = "cargo check"
89timeout_secs = 30
90fail_closed = false
91"#;
92        let cfg: HooksConfig = toml::from_str(toml).unwrap();
93        assert_eq!(cfg.cwd_changed.len(), 1);
94        assert_eq!(cfg.cwd_changed[0].command, "echo changed");
95        assert_eq!(cfg.cwd_changed[0].hook_type, HookType::Command);
96        let fc = cfg.file_changed.as_ref().unwrap();
97        assert_eq!(fc.watch_paths.len(), 2);
98        assert_eq!(fc.debounce_ms, 300);
99        assert_eq!(fc.hooks.len(), 1);
100        assert_eq!(fc.hooks[0].command, "cargo check");
101    }
102
103    #[test]
104    fn hooks_config_not_empty_with_cwd_hooks() {
105        let cfg = HooksConfig {
106            cwd_changed: vec![HookDef {
107                hook_type: HookType::Command,
108                command: "echo hi".into(),
109                timeout_secs: 10,
110                fail_closed: false,
111            }],
112            file_changed: None,
113        };
114        assert!(!cfg.is_empty());
115    }
116}