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