1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
7#[derive(Debug, Clone, Deserialize, Serialize, Default)]
8pub struct HooksConfig {
9 #[serde(default)]
11 pub lifecycle: LifecycleHooksConfig,
12}
13
14#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
17#[derive(Debug, Clone, Deserialize, Serialize, Default)]
18pub struct LifecycleHooksConfig {
19 #[serde(default)]
21 pub quiet_success_output: bool,
22
23 #[serde(default)]
25 pub session_start: Vec<HookGroupConfig>,
26
27 #[serde(default)]
29 pub session_end: Vec<HookGroupConfig>,
30
31 #[serde(default)]
33 pub subagent_start: Vec<HookGroupConfig>,
34
35 #[serde(default)]
37 pub subagent_stop: Vec<HookGroupConfig>,
38
39 #[serde(default)]
41 pub user_prompt_submit: Vec<HookGroupConfig>,
42
43 #[serde(default)]
45 pub pre_tool_use: Vec<HookGroupConfig>,
46
47 #[serde(default)]
49 pub post_tool_use: Vec<HookGroupConfig>,
50
51 #[serde(default)]
53 pub permission_request: Vec<HookGroupConfig>,
54
55 #[serde(default)]
57 pub pre_compact: Vec<HookGroupConfig>,
58
59 #[serde(default)]
61 pub stop: Vec<HookGroupConfig>,
62
63 #[serde(default)]
65 pub task_completion: Vec<HookGroupConfig>,
66
67 #[serde(default)]
69 pub task_completed: Vec<HookGroupConfig>,
70
71 #[serde(default)]
73 pub notification: Vec<HookGroupConfig>,
74}
75
76impl LifecycleHooksConfig {
77 pub fn is_empty(&self) -> bool {
78 self.session_start.is_empty()
79 && self.session_end.is_empty()
80 && self.subagent_start.is_empty()
81 && self.subagent_stop.is_empty()
82 && self.user_prompt_submit.is_empty()
83 && self.pre_tool_use.is_empty()
84 && self.post_tool_use.is_empty()
85 && self.permission_request.is_empty()
86 && self.pre_compact.is_empty()
87 && self.stop.is_empty()
88 && self.task_completion.is_empty()
89 && self.task_completed.is_empty()
90 && self.notification.is_empty()
91 }
92
93 pub fn normalized(&self) -> Self {
94 let mut normalized = self.clone();
95 normalized.stop.extend(self.task_completion.clone());
96 normalized.stop.extend(self.task_completed.clone());
97 normalized.task_completion.clear();
98 normalized.task_completed.clear();
99 normalized
100 }
101}
102
103#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
105#[derive(Debug, Clone, Deserialize, Serialize, Default)]
106pub struct HookGroupConfig {
107 #[serde(default)]
110 pub matcher: Option<String>,
111
112 #[serde(default)]
114 pub hooks: Vec<HookCommandConfig>,
115}
116
117#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
118#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
119#[serde(rename_all = "snake_case")]
120#[derive(Default)]
121pub enum HookCommandKind {
122 #[default]
123 Command,
124}
125
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
128#[derive(Debug, Clone, Deserialize, Serialize, Default)]
129pub struct HookCommandConfig {
130 #[serde(default)]
132 #[serde(rename = "type")]
133 pub kind: HookCommandKind,
134
135 #[serde(default)]
137 pub command: String,
138
139 #[serde(default)]
141 pub timeout_seconds: Option<u64>,
142}
143
144impl HooksConfig {
145 pub fn validate(&self) -> Result<()> {
146 self.lifecycle
147 .validate()
148 .context("Invalid lifecycle hooks configuration")
149 }
150}
151
152impl LifecycleHooksConfig {
153 pub fn validate(&self) -> Result<()> {
154 validate_groups(&self.session_start, "session_start")?;
155 validate_groups(&self.session_end, "session_end")?;
156 validate_groups(&self.subagent_start, "subagent_start")?;
157 validate_groups(&self.subagent_stop, "subagent_stop")?;
158 validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
159 validate_groups(&self.pre_tool_use, "pre_tool_use")?;
160 validate_groups(&self.post_tool_use, "post_tool_use")?;
161 validate_groups(&self.permission_request, "permission_request")?;
162 validate_groups(&self.pre_compact, "pre_compact")?;
163 validate_groups(&self.stop, "stop")?;
164 validate_groups(&self.task_completion, "task_completion")?;
165 validate_groups(&self.task_completed, "task_completed")?;
166 validate_groups(&self.notification, "notification")?;
167 Ok(())
168 }
169}
170
171fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
172 for (index, group) in groups.iter().enumerate() {
173 if let Some(pattern) = group.matcher.as_ref() {
174 validate_matcher(pattern).with_context(|| {
175 format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
176 })?;
177 }
178
179 ensure!(
180 !group.hooks.is_empty(),
181 "hooks.{context_name}[{index}] must define at least one hook command"
182 );
183
184 for (hook_index, hook) in group.hooks.iter().enumerate() {
185 ensure!(
186 matches!(hook.kind, HookCommandKind::Command),
187 "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
188 );
189
190 ensure!(
191 !hook.command.trim().is_empty(),
192 "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
193 );
194
195 if let Some(timeout) = hook.timeout_seconds {
196 ensure!(
197 timeout > 0,
198 "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
199 );
200 }
201 }
202 }
203
204 Ok(())
205}
206
207fn validate_matcher(pattern: &str) -> Result<()> {
208 let trimmed = pattern.trim();
209 if trimmed.is_empty() || trimmed == "*" {
210 return Ok(());
211 }
212
213 let regex_pattern = format!("^(?:{})$", trimmed);
214 Regex::new(®ex_pattern)
215 .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
216 Ok(())
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222
223 fn sample_group() -> HookGroupConfig {
224 HookGroupConfig {
225 matcher: None,
226 hooks: vec![HookCommandConfig {
227 kind: HookCommandKind::Command,
228 command: "echo ok".to_string(),
229 timeout_seconds: None,
230 }],
231 }
232 }
233
234 #[test]
235 fn permission_request_and_stop_validate() {
236 let config = LifecycleHooksConfig {
237 permission_request: vec![sample_group()],
238 stop: vec![sample_group()],
239 ..Default::default()
240 };
241
242 config.validate().expect("hooks validate");
243 }
244
245 #[test]
246 fn task_aliases_normalize_into_stop() {
247 let config = LifecycleHooksConfig {
248 task_completion: vec![sample_group()],
249 task_completed: vec![sample_group()],
250 ..Default::default()
251 };
252
253 let normalized = config.normalized();
254 assert_eq!(normalized.stop.len(), 2);
255 assert!(normalized.task_completion.is_empty());
256 assert!(normalized.task_completed.is_empty());
257 }
258}