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 pre_compact: Vec<HookGroupConfig>,
54
55 #[serde(default)]
57 pub task_completion: Vec<HookGroupConfig>,
58
59 #[serde(default)]
61 pub task_completed: Vec<HookGroupConfig>,
62
63 #[serde(default)]
65 pub notification: Vec<HookGroupConfig>,
66}
67
68impl LifecycleHooksConfig {
69 pub fn is_empty(&self) -> bool {
70 self.session_start.is_empty()
71 && self.session_end.is_empty()
72 && self.subagent_start.is_empty()
73 && self.subagent_stop.is_empty()
74 && self.user_prompt_submit.is_empty()
75 && self.pre_tool_use.is_empty()
76 && self.post_tool_use.is_empty()
77 && self.pre_compact.is_empty()
78 && self.task_completion.is_empty()
79 && self.task_completed.is_empty()
80 && self.notification.is_empty()
81 }
82}
83
84#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
86#[derive(Debug, Clone, Deserialize, Serialize, Default)]
87pub struct HookGroupConfig {
88 #[serde(default)]
91 pub matcher: Option<String>,
92
93 #[serde(default)]
95 pub hooks: Vec<HookCommandConfig>,
96}
97
98#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
99#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
100#[serde(rename_all = "snake_case")]
101#[derive(Default)]
102pub enum HookCommandKind {
103 #[default]
104 Command,
105}
106
107#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
109#[derive(Debug, Clone, Deserialize, Serialize, Default)]
110pub struct HookCommandConfig {
111 #[serde(default)]
113 #[serde(rename = "type")]
114 pub kind: HookCommandKind,
115
116 #[serde(default)]
118 pub command: String,
119
120 #[serde(default)]
122 pub timeout_seconds: Option<u64>,
123}
124
125impl HooksConfig {
126 pub fn validate(&self) -> Result<()> {
127 self.lifecycle
128 .validate()
129 .context("Invalid lifecycle hooks configuration")
130 }
131}
132
133impl LifecycleHooksConfig {
134 pub fn validate(&self) -> Result<()> {
135 validate_groups(&self.session_start, "session_start")?;
136 validate_groups(&self.session_end, "session_end")?;
137 validate_groups(&self.subagent_start, "subagent_start")?;
138 validate_groups(&self.subagent_stop, "subagent_stop")?;
139 validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
140 validate_groups(&self.pre_tool_use, "pre_tool_use")?;
141 validate_groups(&self.post_tool_use, "post_tool_use")?;
142 validate_groups(&self.pre_compact, "pre_compact")?;
143 validate_groups(&self.task_completion, "task_completion")?;
144 validate_groups(&self.task_completed, "task_completed")?;
145 validate_groups(&self.notification, "notification")?;
146 Ok(())
147 }
148}
149
150fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
151 for (index, group) in groups.iter().enumerate() {
152 if let Some(pattern) = group.matcher.as_ref() {
153 validate_matcher(pattern).with_context(|| {
154 format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
155 })?;
156 }
157
158 ensure!(
159 !group.hooks.is_empty(),
160 "hooks.{context_name}[{index}] must define at least one hook command"
161 );
162
163 for (hook_index, hook) in group.hooks.iter().enumerate() {
164 ensure!(
165 matches!(hook.kind, HookCommandKind::Command),
166 "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
167 );
168
169 ensure!(
170 !hook.command.trim().is_empty(),
171 "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
172 );
173
174 if let Some(timeout) = hook.timeout_seconds {
175 ensure!(
176 timeout > 0,
177 "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
178 );
179 }
180 }
181 }
182
183 Ok(())
184}
185
186fn validate_matcher(pattern: &str) -> Result<()> {
187 let trimmed = pattern.trim();
188 if trimmed.is_empty() || trimmed == "*" {
189 return Ok(());
190 }
191
192 let regex_pattern = format!("^(?:{})$", trimmed);
193 Regex::new(®ex_pattern)
194 .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
195 Ok(())
196}