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///
40/// Each sub-section corresponds to a lifecycle event. All sections default to
41/// empty (no hooks). Events fire in the order hooks are listed.
42#[derive(Debug, Clone, Default, Deserialize, Serialize)]
43#[serde(default)]
44pub struct HooksConfig {
45    /// Hooks fired when the agent's working directory changes via `set_working_directory`.
46    pub cwd_changed: Vec<HookDef>,
47    /// File-change watcher configuration with associated hooks.
48    pub file_changed: Option<FileChangedConfig>,
49    /// Hooks fired when a tool execution is blocked by a `RuntimeLayer::before_tool` check.
50    ///
51    /// Environment variables set for `Command` hooks:
52    /// - `ZEPH_DENIED_TOOL` — the name of the tool that was blocked.
53    /// - `ZEPH_DENY_REASON` — human-readable reason string from the layer.
54    pub permission_denied: Vec<HookDef>,
55    /// Hooks fired after each agent turn completes (#3327).
56    ///
57    /// Runs regardless of the `[notifications]` config. When a `[notifications]` notifier is
58    /// also configured, these hooks share its `should_fire` gate (respecting `min_turn_duration_ms`,
59    /// `only_on_error`, and `enabled`). When no notifier is configured, hooks fire on every
60    /// completed turn.
61    ///
62    /// Use `min_duration_ms` in a wrapper script or the `[notifications].min_turn_duration_ms`
63    /// gate to avoid firing on trivial responses.
64    ///
65    /// Environment variables set for `Command` hooks:
66    /// - `ZEPH_TURN_DURATION_MS`   — wall-clock duration of the turn in milliseconds.
67    /// - `ZEPH_TURN_STATUS`        — `"success"` or `"error"`.
68    /// - `ZEPH_TURN_PREVIEW`       — redacted first ≤ 160 chars of the assistant response.
69    /// - `ZEPH_TURN_LLM_REQUESTS`  — number of completed LLM round-trips this turn.
70    #[serde(default)]
71    pub turn_complete: Vec<HookDef>,
72}
73
74impl HooksConfig {
75    /// Returns `true` when no hooks are configured (all sections are empty or absent).
76    ///
77    /// # Examples
78    ///
79    /// ```
80    /// use zeph_config::hooks::HooksConfig;
81    ///
82    /// assert!(HooksConfig::default().is_empty());
83    /// ```
84    #[must_use]
85    pub fn is_empty(&self) -> bool {
86        self.cwd_changed.is_empty()
87            && self.file_changed.is_none()
88            && self.permission_denied.is_empty()
89            && self.turn_complete.is_empty()
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96    use crate::subagent::HookAction;
97
98    fn cmd_hook(command: &str) -> HookDef {
99        HookDef {
100            action: HookAction::Command {
101                command: command.into(),
102            },
103            timeout_secs: 10,
104            fail_closed: false,
105        }
106    }
107
108    #[test]
109    fn hooks_config_default_is_empty() {
110        let cfg = HooksConfig::default();
111        assert!(cfg.is_empty());
112    }
113
114    #[test]
115    fn file_changed_config_default_debounce() {
116        let cfg = FileChangedConfig::default();
117        assert_eq!(cfg.debounce_ms, 500);
118        assert!(cfg.watch_paths.is_empty());
119        assert!(cfg.hooks.is_empty());
120    }
121
122    #[test]
123    fn hooks_config_parses_from_toml() {
124        let toml = r#"
125[[cwd_changed]]
126type = "command"
127command = "echo changed"
128timeout_secs = 10
129fail_closed = false
130
131[file_changed]
132watch_paths = ["src/", "Cargo.toml"]
133debounce_ms = 300
134[[file_changed.hooks]]
135type = "command"
136command = "cargo check"
137timeout_secs = 30
138fail_closed = false
139
140[[permission_denied]]
141type = "command"
142command = "echo denied"
143timeout_secs = 5
144fail_closed = false
145"#;
146        let cfg: HooksConfig = toml::from_str(toml).unwrap();
147        assert_eq!(cfg.cwd_changed.len(), 1);
148        assert!(
149            matches!(&cfg.cwd_changed[0].action, HookAction::Command { command } if command == "echo changed")
150        );
151        let fc = cfg.file_changed.as_ref().unwrap();
152        assert_eq!(fc.watch_paths.len(), 2);
153        assert_eq!(fc.debounce_ms, 300);
154        assert_eq!(fc.hooks.len(), 1);
155        assert_eq!(cfg.permission_denied.len(), 1);
156        assert!(
157            matches!(&cfg.permission_denied[0].action, HookAction::Command { command } if command == "echo denied")
158        );
159    }
160
161    #[test]
162    fn hooks_config_parses_mcp_tool_hook() {
163        let toml = r#"
164[[permission_denied]]
165type = "mcp_tool"
166server = "policy"
167tool = "audit"
168[permission_denied.args]
169severity = "high"
170"#;
171        let cfg: HooksConfig = toml::from_str(toml).unwrap();
172        assert_eq!(cfg.permission_denied.len(), 1);
173        assert!(matches!(
174            &cfg.permission_denied[0].action,
175            HookAction::McpTool { server, tool, .. } if server == "policy" && tool == "audit"
176        ));
177    }
178
179    #[test]
180    fn hooks_config_not_empty_with_cwd_hooks() {
181        let cfg = HooksConfig {
182            cwd_changed: vec![cmd_hook("echo hi")],
183            file_changed: None,
184            permission_denied: Vec::new(),
185            turn_complete: Vec::new(),
186        };
187        assert!(!cfg.is_empty());
188    }
189
190    #[test]
191    fn hooks_config_not_empty_with_permission_denied_hooks() {
192        let cfg = HooksConfig {
193            cwd_changed: Vec::new(),
194            file_changed: None,
195            permission_denied: vec![cmd_hook("echo denied")],
196            turn_complete: Vec::new(),
197        };
198        assert!(!cfg.is_empty());
199    }
200
201    #[test]
202    fn hooks_config_not_empty_with_turn_complete_hooks() {
203        let cfg = HooksConfig {
204            cwd_changed: Vec::new(),
205            file_changed: None,
206            permission_denied: Vec::new(),
207            turn_complete: vec![cmd_hook("notify-send Zeph done")],
208        };
209        assert!(!cfg.is_empty());
210    }
211
212    #[test]
213    fn hooks_config_is_empty_when_all_empty_including_turn_complete() {
214        let cfg = HooksConfig {
215            cwd_changed: Vec::new(),
216            file_changed: None,
217            permission_denied: Vec::new(),
218            turn_complete: Vec::new(),
219        };
220        assert!(cfg.is_empty());
221    }
222
223    #[test]
224    fn hooks_config_parses_turn_complete_from_toml() {
225        let toml = r#"
226[[turn_complete]]
227type = "command"
228command = "osascript -e 'display notification \"$ZEPH_TURN_PREVIEW\" with title \"Zeph\"'"
229timeout_secs = 3
230fail_closed = false
231"#;
232        let cfg: HooksConfig = toml::from_str(toml).unwrap();
233        assert_eq!(cfg.turn_complete.len(), 1);
234        assert!(cfg.cwd_changed.is_empty());
235        assert!(cfg.permission_denied.is_empty());
236    }
237}