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")]
49pub enum HookCommandKind {
50 Command,
51}
52
53impl Default for HookCommandKind {
54 fn default() -> Self {
55 Self::Command
56 }
57}
58
59#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
60#[derive(Debug, Clone, Deserialize, Serialize, Default)]
61pub struct HookCommandConfig {
62 #[serde(default)]
63 #[serde(rename = "type")]
64 pub kind: HookCommandKind,
65 #[serde(default)]
66 pub command: String,
67 #[serde(default)]
68 pub timeout_seconds: Option<u64>,
69}
70
71impl HooksConfig {
72 pub fn validate(&self) -> Result<()> {
73 self.lifecycle
74 .validate()
75 .context("Invalid lifecycle hooks configuration")
76 }
77}
78
79impl LifecycleHooksConfig {
80 pub fn validate(&self) -> Result<()> {
81 validate_groups(&self.session_start, "session_start")?;
82 validate_groups(&self.session_end, "session_end")?;
83 validate_groups(&self.user_prompt_submit, "user_prompt_submit")?;
84 validate_groups(&self.pre_tool_use, "pre_tool_use")?;
85 validate_groups(&self.post_tool_use, "post_tool_use")?;
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}