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