1use anyhow::{Context, Result, ensure};
2use regex::Regex;
3use serde::{Deserialize, Serialize};
4
5#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
6#[derive(Debug, Clone, Deserialize, Serialize, Default)]
7pub struct HooksConfig {
8 #[serde(default)]
9 pub lifecycle: LifecycleHooksConfig,
10}
11
12#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
13#[derive(Debug, Clone, Deserialize, Serialize, Default)]
14pub struct LifecycleHooksConfig {
15 #[serde(default)]
16 pub session_start: Vec<HookGroupConfig>,
17 #[serde(default)]
18 pub session_end: Vec<HookGroupConfig>,
19 #[serde(default)]
20 pub user_prompt_submit: Vec<HookGroupConfig>,
21 #[serde(default)]
22 pub pre_tool_use: Vec<HookGroupConfig>,
23 #[serde(default)]
24 pub post_tool_use: Vec<HookGroupConfig>,
25 #[serde(default)]
26 pub task_completion: Vec<HookGroupConfig>,
27}
28
29impl LifecycleHooksConfig {
30 pub fn is_empty(&self) -> bool {
31 self.session_start.is_empty()
32 && self.session_end.is_empty()
33 && self.user_prompt_submit.is_empty()
34 && self.pre_tool_use.is_empty()
35 && self.post_tool_use.is_empty()
36 && self.task_completion.is_empty()
37 }
38}
39
40#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
41#[derive(Debug, Clone, Deserialize, Serialize, Default)]
42pub struct HookGroupConfig {
43 #[serde(default)]
44 pub matcher: Option<String>,
45 #[serde(default)]
46 pub hooks: Vec<HookCommandConfig>,
47}
48
49#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
50#[derive(Debug, Clone, Deserialize, Serialize, PartialEq, Eq)]
51#[serde(rename_all = "snake_case")]
52#[derive(Default)]
53pub enum HookCommandKind {
54 #[default]
55 Command,
56}
57
58#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
59#[derive(Debug, Clone, Deserialize, Serialize, Default)]
60pub struct HookCommandConfig {
61 #[serde(default)]
62 #[serde(rename = "type")]
63 pub kind: HookCommandKind,
64 #[serde(default)]
65 pub command: String,
66 #[serde(default)]
67 pub timeout_seconds: Option<u64>,
68}
69
70impl HooksConfig {
71 pub fn validate(&self) -> Result<()> {
72 self.lifecycle
73 .validate()
74 .context("Invalid lifecycle hooks configuration")
75 }
76}
77
78impl LifecycleHooksConfig {
79 pub fn validate(&self) -> Result<()> {
80 validate_groups(&self.session_start, "session_start")?;
81 validate_groups(&self.session_end, "session_end")?;
82 validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
83 validate_groups(&self.pre_tool_use, "pre_tool_use")?;
84 validate_groups(&self.post_tool_use, "post_tool_use")?;
85 validate_groups(&self.task_completion, "task_completion")?;
86 Ok(())
87 }
88}
89
90fn validate_groups(groups: &[HookGroupConfig], context_name: &str) -> Result<()> {
91 for (index, group) in groups.iter().enumerate() {
92 if let Some(pattern) = group.matcher.as_ref() {
93 validate_matcher(pattern).with_context(|| {
94 format!("Invalid matcher in hooks.{context_name}[{index}] -> matcher")
95 })?;
96 }
97
98 ensure!(
99 !group.hooks.is_empty(),
100 "hooks.{context_name}[{index}] must define at least one hook command"
101 );
102
103 for (hook_index, hook) in group.hooks.iter().enumerate() {
104 ensure!(
105 matches!(hook.kind, HookCommandKind::Command),
106 "hooks.{context_name}[{index}].hooks[{hook_index}] has unsupported type"
107 );
108
109 ensure!(
110 !hook.command.trim().is_empty(),
111 "hooks.{context_name}[{index}].hooks[{hook_index}] must specify a command"
112 );
113
114 if let Some(timeout) = hook.timeout_seconds {
115 ensure!(
116 timeout > 0,
117 "hooks.{context_name}[{index}].hooks[{hook_index}].timeout_seconds must be positive"
118 );
119 }
120 }
121 }
122
123 Ok(())
124}
125
126fn validate_matcher(pattern: &str) -> Result<()> {
127 let trimmed = pattern.trim();
128 if trimmed.is_empty() || trimmed == "*" {
129 return Ok(());
130 }
131
132 let regex_pattern = format!("^(?:{})$", trimmed);
133 Regex::new(®ex_pattern)
134 .with_context(|| format!("failed to compile lifecycle hook matcher: {pattern}"))?;
135 Ok(())
136}