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