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