systemprompt_models/services/
hooks.rs1use std::fmt;
12use std::str::FromStr;
13
14use serde::{Deserialize, Serialize};
15use systemprompt_identifiers::HookId;
16
17use crate::errors::{ConfigValidationError, ParseEnumError};
18
19pub const HOOK_CONFIG_FILENAME: &str = "config.yaml";
20
21const fn default_true() -> bool {
22 true
23}
24
25fn default_version() -> String {
26 "1.0.0".to_owned()
27}
28
29fn default_matcher() -> String {
30 "*".to_owned()
31}
32
33fn default_hook_id() -> HookId {
34 HookId::new("")
35}
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
38#[serde(rename_all = "PascalCase")]
39pub enum HookEvent {
40 PreToolUse,
41 PostToolUse,
42 PostToolUseFailure,
43 SessionStart,
44 SessionEnd,
45 UserPromptSubmit,
46 Notification,
47 Stop,
48 SubagentStart,
49 SubagentStop,
50}
51
52impl HookEvent {
53 pub const ALL_VARIANTS: &'static [Self] = &[
54 Self::PreToolUse,
55 Self::PostToolUse,
56 Self::PostToolUseFailure,
57 Self::SessionStart,
58 Self::SessionEnd,
59 Self::UserPromptSubmit,
60 Self::Notification,
61 Self::Stop,
62 Self::SubagentStart,
63 Self::SubagentStop,
64 ];
65
66 pub const fn as_str(&self) -> &'static str {
67 match self {
68 Self::PreToolUse => "PreToolUse",
69 Self::PostToolUse => "PostToolUse",
70 Self::PostToolUseFailure => "PostToolUseFailure",
71 Self::SessionStart => "SessionStart",
72 Self::SessionEnd => "SessionEnd",
73 Self::UserPromptSubmit => "UserPromptSubmit",
74 Self::Notification => "Notification",
75 Self::Stop => "Stop",
76 Self::SubagentStart => "SubagentStart",
77 Self::SubagentStop => "SubagentStop",
78 }
79 }
80}
81
82impl fmt::Display for HookEvent {
83 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
84 write!(f, "{}", self.as_str())
85 }
86}
87
88impl FromStr for HookEvent {
89 type Err = ParseEnumError;
90
91 fn from_str(s: &str) -> Result<Self, Self::Err> {
92 match s {
93 "PreToolUse" => Ok(Self::PreToolUse),
94 "PostToolUse" => Ok(Self::PostToolUse),
95 "PostToolUseFailure" => Ok(Self::PostToolUseFailure),
96 "SessionStart" => Ok(Self::SessionStart),
97 "SessionEnd" => Ok(Self::SessionEnd),
98 "UserPromptSubmit" => Ok(Self::UserPromptSubmit),
99 "Notification" => Ok(Self::Notification),
100 "Stop" => Ok(Self::Stop),
101 "SubagentStart" => Ok(Self::SubagentStart),
102 "SubagentStop" => Ok(Self::SubagentStop),
103 _ => Err(ParseEnumError::new("hook_event", s)),
104 }
105 }
106}
107
108#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
109#[serde(rename_all = "lowercase")]
110pub enum HookCategory {
111 System,
112 #[default]
113 Custom,
114}
115
116impl HookCategory {
117 pub const fn as_str(&self) -> &'static str {
118 match self {
119 Self::System => "system",
120 Self::Custom => "custom",
121 }
122 }
123}
124
125impl fmt::Display for HookCategory {
126 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127 write!(f, "{}", self.as_str())
128 }
129}
130
131impl FromStr for HookCategory {
132 type Err = ParseEnumError;
133
134 fn from_str(s: &str) -> Result<Self, Self::Err> {
135 match s {
136 "system" => Ok(Self::System),
137 "custom" => Ok(Self::Custom),
138 _ => Err(ParseEnumError::new("hook_category", s)),
139 }
140 }
141}
142
143#[derive(Debug, Clone, Deserialize)]
144pub struct DiskHookConfig {
145 #[serde(default = "default_hook_id")]
146 pub id: HookId,
147 #[serde(default)]
148 pub name: String,
149 #[serde(default)]
150 pub description: String,
151 #[serde(default = "default_version")]
152 pub version: String,
153 #[serde(default = "default_true")]
154 pub enabled: bool,
155 pub event: HookEvent,
156 #[serde(default = "default_matcher")]
157 pub matcher: String,
158 #[serde(default)]
159 pub command: String,
160 #[serde(default, rename = "async")]
161 pub is_async: bool,
162 #[serde(default)]
163 pub category: HookCategory,
164 #[serde(default)]
165 pub tags: Vec<String>,
166 #[serde(default)]
167 pub visible_to: Vec<String>,
168}
169
170#[derive(Debug, Clone, Default, Serialize, Deserialize)]
171#[serde(rename_all = "PascalCase")]
172pub struct HookEventsConfig {
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
174 pub pre_tool_use: Vec<HookMatcher>,
175 #[serde(default, skip_serializing_if = "Vec::is_empty")]
176 pub post_tool_use: Vec<HookMatcher>,
177 #[serde(default, skip_serializing_if = "Vec::is_empty")]
178 pub post_tool_use_failure: Vec<HookMatcher>,
179 #[serde(default, skip_serializing_if = "Vec::is_empty")]
180 pub session_start: Vec<HookMatcher>,
181 #[serde(default, skip_serializing_if = "Vec::is_empty")]
182 pub session_end: Vec<HookMatcher>,
183 #[serde(default, skip_serializing_if = "Vec::is_empty")]
184 pub user_prompt_submit: Vec<HookMatcher>,
185 #[serde(default, skip_serializing_if = "Vec::is_empty")]
186 pub notification: Vec<HookMatcher>,
187 #[serde(default, skip_serializing_if = "Vec::is_empty")]
188 pub stop: Vec<HookMatcher>,
189 #[serde(default, skip_serializing_if = "Vec::is_empty")]
190 pub subagent_start: Vec<HookMatcher>,
191 #[serde(default, skip_serializing_if = "Vec::is_empty")]
192 pub subagent_stop: Vec<HookMatcher>,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize)]
196pub struct HookMatcher {
197 pub matcher: String,
198 pub hooks: Vec<HookAction>,
199}
200
201#[derive(Debug, Clone, Serialize, Deserialize)]
202pub struct HookAction {
203 #[serde(rename = "type")]
204 pub hook_type: HookType,
205 #[serde(skip_serializing_if = "Option::is_none")]
206 pub command: Option<String>,
207 #[serde(skip_serializing_if = "Option::is_none")]
208 pub prompt: Option<String>,
209 #[serde(default, rename = "async")]
210 pub r#async: bool,
211 #[serde(skip_serializing_if = "Option::is_none")]
212 pub timeout: Option<u32>,
213 #[serde(skip_serializing_if = "Option::is_none", rename = "statusMessage")]
214 pub status_message: Option<String>,
215}
216
217#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
218#[serde(rename_all = "lowercase")]
219pub enum HookType {
220 Command,
221 Prompt,
222 Agent,
223}
224
225impl HookEventsConfig {
226 pub const fn is_empty(&self) -> bool {
227 self.pre_tool_use.is_empty()
228 && self.post_tool_use.is_empty()
229 && self.post_tool_use_failure.is_empty()
230 && self.session_start.is_empty()
231 && self.session_end.is_empty()
232 && self.user_prompt_submit.is_empty()
233 && self.notification.is_empty()
234 && self.stop.is_empty()
235 && self.subagent_start.is_empty()
236 && self.subagent_stop.is_empty()
237 }
238
239 pub fn matchers_for_event(&self, event: HookEvent) -> &[HookMatcher] {
240 match event {
241 HookEvent::PreToolUse => &self.pre_tool_use,
242 HookEvent::PostToolUse => &self.post_tool_use,
243 HookEvent::PostToolUseFailure => &self.post_tool_use_failure,
244 HookEvent::SessionStart => &self.session_start,
245 HookEvent::SessionEnd => &self.session_end,
246 HookEvent::UserPromptSubmit => &self.user_prompt_submit,
247 HookEvent::Notification => &self.notification,
248 HookEvent::Stop => &self.stop,
249 HookEvent::SubagentStart => &self.subagent_start,
250 HookEvent::SubagentStop => &self.subagent_stop,
251 }
252 }
253
254 pub fn validate(&self) -> Result<(), ConfigValidationError> {
255 for event in HookEvent::ALL_VARIANTS {
256 for matcher in self.matchers_for_event(*event) {
257 for action in &matcher.hooks {
258 match action.hook_type {
259 HookType::Command => {
260 if action.command.is_none() {
261 return Err(ConfigValidationError::required(format!(
262 "Hook matcher '{}': command hook requires a 'command' field",
263 matcher.matcher
264 )));
265 }
266 },
267 HookType::Prompt => {
268 if action.prompt.is_none() {
269 return Err(ConfigValidationError::required(format!(
270 "Hook matcher '{}': prompt hook requires a 'prompt' field",
271 matcher.matcher
272 )));
273 }
274 },
275 HookType::Agent => {},
276 }
277 }
278 }
279 }
280
281 Ok(())
282 }
283}