Skip to main content

systemprompt_models/services/
hooks.rs

1//! Hook configuration: lifecycle events, matchers, and actions.
2//!
3//! [`HookEvent`] enumerates the agent lifecycle points a hook can bind to;
4//! [`HookEventsConfig`] groups the [`HookMatcher`]/[`HookAction`] bindings per
5//! event and validates them via [`HookEventsConfig::validate`].
6//! [`DiskHookConfig`] is the per-hook on-disk descriptor.
7
8use 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}