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    /// Returns `true` when no hooks are configured (all sections are empty or absent).
50    ///
51    /// # Examples
52    ///
53    /// ```
54    /// use zeph_config::hooks::HooksConfig;
55    ///
56    /// assert!(HooksConfig::default().is_empty());
57    /// ```
58    #[must_use]
59    pub fn is_empty(&self) -> bool {
60        self.cwd_changed.is_empty() && self.file_changed.is_none()
61    }
62}
63
64#[cfg(test)]
65mod tests {
66    use super::*;
67    use crate::subagent::HookType;
68
69    #[test]
70    fn hooks_config_default_is_empty() {
71        let cfg = HooksConfig::default();
72        assert!(cfg.is_empty());
73    }
74
75    #[test]
76    fn file_changed_config_default_debounce() {
77        let cfg = FileChangedConfig::default();
78        assert_eq!(cfg.debounce_ms, 500);
79        assert!(cfg.watch_paths.is_empty());
80        assert!(cfg.hooks.is_empty());
81    }
82
83    #[test]
84    fn hooks_config_parses_from_toml() {
85        let toml = r#"
86[[cwd_changed]]
87type = "command"
88command = "echo changed"
89timeout_secs = 10
90fail_closed = false
91
92[file_changed]
93watch_paths = ["src/", "Cargo.toml"]
94debounce_ms = 300
95[[file_changed.hooks]]
96type = "command"
97command = "cargo check"
98timeout_secs = 30
99fail_closed = false
100"#;
101        let cfg: HooksConfig = toml::from_str(toml).unwrap();
102        assert_eq!(cfg.cwd_changed.len(), 1);
103        assert_eq!(cfg.cwd_changed[0].command, "echo changed");
104        assert_eq!(cfg.cwd_changed[0].hook_type, HookType::Command);
105        let fc = cfg.file_changed.as_ref().unwrap();
106        assert_eq!(fc.watch_paths.len(), 2);
107        assert_eq!(fc.debounce_ms, 300);
108        assert_eq!(fc.hooks.len(), 1);
109        assert_eq!(fc.hooks[0].command, "cargo check");
110    }
111
112    #[test]
113    fn hooks_config_not_empty_with_cwd_hooks() {
114        let cfg = HooksConfig {
115            cwd_changed: vec![HookDef {
116                hook_type: HookType::Command,
117                command: "echo hi".into(),
118                timeout_secs: 10,
119                fail_closed: false,
120            }],
121            file_changed: None,
122        };
123        assert!(!cfg.is_empty());
124    }
125}