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