1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum HookEvent {
14 SessionStart,
16 UserPromptSubmit,
18 PreToolUse,
20 PermissionRequest,
22 PostToolUse,
24 PostToolUseFailure,
26 Notification,
28 SubagentStart,
30 SubagentStop,
32 Stop,
34 PreCompact,
36 SessionEnd,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct HookHandler {
45 #[serde(rename = "type")]
47 pub r#type: String,
48 pub command: String,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub timeout: Option<u32>,
53 #[serde(skip_serializing_if = "Option::is_none", rename = "async")]
55 pub r#async: Option<bool>,
56 #[serde(skip_serializing_if = "Option::is_none", rename = "statusMessage")]
58 pub status_message: Option<String>,
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MatcherGroup {
67 #[serde(skip_serializing_if = "Option::is_none")]
69 pub matcher: Option<String>,
70 pub hooks: Vec<HookHandler>,
72}
73
74#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct RegistryEntry {
77 pub event: HookEvent,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub matcher: Option<String>,
83 #[serde(rename = "type")]
85 pub r#type: String,
86 pub command: String,
88
89 #[serde(skip_serializing_if = "Option::is_none")]
92 pub timeout: Option<u32>,
93 #[serde(skip_serializing_if = "Option::is_none", rename = "async")]
95 pub r#async: Option<bool>,
96
97 pub scope: String,
100 pub enabled: bool,
102 pub added_at: String,
104 pub installed_by: String,
106 #[serde(skip_serializing_if = "Option::is_none")]
108 pub description: Option<String>,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub reason: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub optional: Option<bool>,
115}
116
117impl RegistryEntry {
118 pub fn matches(&self, event: HookEvent, command: &str) -> bool {
122 self.event == event && self.command == command
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ListEntry {
129 pub event: HookEvent,
131 pub handler: HookHandler,
133 pub managed: bool,
135 pub metadata: Option<RegistryMetadata>,
137}
138
139#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RegistryMetadata {
142 pub added_at: String,
144 pub installed_by: String,
146 pub description: Option<String>,
148 pub reason: Option<String>,
150 pub optional: Option<bool>,
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 #[test]
159 fn test_hook_event_serialization() {
160 let event = HookEvent::Stop;
161 let json = serde_json::to_string(&event).expect("serialization failed");
162 assert_eq!(json, r#""Stop""#);
163 }
164
165 #[test]
166 fn test_hook_event_deserialization() {
167 let json = r#""SessionStart""#;
168 let event: HookEvent = serde_json::from_str(json).expect("deserialization failed");
169 assert_eq!(event, HookEvent::SessionStart);
170 }
171
172 #[test]
173 fn test_all_hook_events_serialize() {
174 let events = vec![
175 (HookEvent::SessionStart, r#""SessionStart""#),
176 (HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
177 (HookEvent::PreToolUse, r#""PreToolUse""#),
178 (HookEvent::PermissionRequest, r#""PermissionRequest""#),
179 (HookEvent::PostToolUse, r#""PostToolUse""#),
180 (HookEvent::PostToolUseFailure, r#""PostToolUseFailure""#),
181 (HookEvent::Notification, r#""Notification""#),
182 (HookEvent::SubagentStart, r#""SubagentStart""#),
183 (HookEvent::SubagentStop, r#""SubagentStop""#),
184 (HookEvent::Stop, r#""Stop""#),
185 (HookEvent::PreCompact, r#""PreCompact""#),
186 (HookEvent::SessionEnd, r#""SessionEnd""#),
187 ];
188
189 for (event, expected) in events {
190 let json = serde_json::to_string(&event).expect("serialization failed");
191 assert_eq!(json, expected, "Event {:?} serialized incorrectly", event);
192 }
193 }
194
195 #[test]
196 fn test_hook_handler_roundtrip() {
197 let handler = HookHandler {
198 r#type: "command".to_string(),
199 command: "/path/to/stop.sh".to_string(),
200 timeout: Some(600),
201 r#async: None,
202 status_message: None,
203 };
204 let json = serde_json::to_string(&handler).expect("serialization failed");
205 let deserialized: HookHandler =
206 serde_json::from_str(&json).expect("deserialization failed");
207 assert_eq!(handler, deserialized);
208 }
209
210 #[test]
211 fn test_hook_handler_optional_fields() {
212 let handler_full = HookHandler {
214 r#type: "command".to_string(),
215 command: "/path/to/script.sh".to_string(),
216 timeout: Some(300),
217 r#async: Some(true),
218 status_message: Some("Running validation...".to_string()),
219 };
220 let json = serde_json::to_string(&handler_full).expect("serialization failed");
221 let deserialized: HookHandler =
222 serde_json::from_str(&json).expect("deserialization failed");
223 assert_eq!(handler_full, deserialized);
224
225 let handler_minimal = HookHandler {
227 r#type: "command".to_string(),
228 command: "/path/to/script.sh".to_string(),
229 timeout: None,
230 r#async: None,
231 status_message: None,
232 };
233 let json = serde_json::to_string(&handler_minimal).expect("serialization failed");
234 let deserialized: HookHandler =
235 serde_json::from_str(&json).expect("deserialization failed");
236 assert_eq!(handler_minimal, deserialized);
237 }
238
239 #[test]
240 fn test_matcher_group_roundtrip() {
241 let group = MatcherGroup {
242 matcher: Some("Bash".to_string()),
243 hooks: vec![HookHandler {
244 r#type: "command".to_string(),
245 command: "/path/to/script.sh".to_string(),
246 timeout: Some(10),
247 r#async: None,
248 status_message: None,
249 }],
250 };
251 let json = serde_json::to_string(&group).expect("serialization failed");
252 let deserialized: MatcherGroup =
253 serde_json::from_str(&json).expect("deserialization failed");
254 assert_eq!(group, deserialized);
255 }
256
257 #[test]
258 fn test_matcher_group_without_matcher() {
259 let group = MatcherGroup {
260 matcher: None,
261 hooks: vec![HookHandler {
262 r#type: "command".to_string(),
263 command: "/path/to/script.sh".to_string(),
264 timeout: None,
265 r#async: None,
266 status_message: None,
267 }],
268 };
269 let json = serde_json::to_string(&group).expect("serialization failed");
270 assert!(!json.contains("matcher"), "matcher should be omitted when None");
271 let deserialized: MatcherGroup =
272 serde_json::from_str(&json).expect("deserialization failed");
273 assert_eq!(group, deserialized);
274 }
275
276 #[test]
277 fn test_registry_entry_roundtrip() {
278 let entry = RegistryEntry {
279 event: HookEvent::Stop,
280 matcher: None,
281 r#type: "command".to_string(),
282 command: "/path/to/stop.sh".to_string(),
283 timeout: Some(600),
284 r#async: None,
285 scope: "user".to_string(),
286 enabled: true,
287 added_at: "20260203-143022".to_string(),
288 installed_by: "acd".to_string(),
289 description: Some("Test hook".to_string()),
290 reason: Some("Testing".to_string()),
291 optional: Some(false),
292 };
293 let json = serde_json::to_string(&entry).expect("serialization failed");
294 let deserialized: RegistryEntry =
295 serde_json::from_str(&json).expect("deserialization failed");
296 assert_eq!(entry, deserialized);
297 }
298
299 #[test]
300 fn test_registry_entry_matches() {
301 let entry = RegistryEntry {
302 event: HookEvent::Stop,
303 matcher: None,
304 r#type: "command".to_string(),
305 command: "/path/to/stop.sh".to_string(),
306 timeout: None,
307 r#async: None,
308 scope: "user".to_string(),
309 enabled: true,
310 added_at: "20260203-143022".to_string(),
311 installed_by: "acd".to_string(),
312 description: None,
313 reason: None,
314 optional: None,
315 };
316
317 assert!(entry.matches(HookEvent::Stop, "/path/to/stop.sh"));
319
320 assert!(!entry.matches(HookEvent::Stop, "/different/path"));
322
323 assert!(!entry.matches(HookEvent::SessionStart, "/path/to/stop.sh"));
325
326 assert!(!entry.matches(HookEvent::SessionStart, "/different/path"));
328 }
329}