Skip to main content

claude_hooks/
types.rs

1//! Core domain types for claude-hooks
2//!
3//! This module defines the types that model Claude Code hooks, including
4//! HookEvent, HookHandler, RegistryEntry, and ListEntry.
5
6use serde::{Deserialize, Serialize};
7
8/// Claude Code hook events
9///
10/// Matches Claude's event names exactly when serialized.
11/// See: https://docs.anthropic.com/en/docs/claude-code/hooks
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
13pub enum HookEvent {
14    /// Session begins or resumes
15    SessionStart,
16    /// User submits prompt
17    UserPromptSubmit,
18    /// Before tool execution
19    PreToolUse,
20    /// Permission dialog shown
21    PermissionRequest,
22    /// After tool succeeds
23    PostToolUse,
24    /// After tool fails
25    PostToolUseFailure,
26    /// Notification sent
27    Notification,
28    /// Subagent spawned
29    SubagentStart,
30    /// Subagent finishes
31    SubagentStop,
32    /// Claude finishes response
33    Stop,
34    /// Before compaction
35    PreCompact,
36    /// Session terminates
37    SessionEnd,
38}
39
40/// Hook handler configuration (matches Claude's settings.json structure)
41///
42/// This is the innermost handler object inside a matcher group's `hooks` array.
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44pub struct HookHandler {
45    /// Handler type: "command", "prompt", or "agent"
46    #[serde(rename = "type")]
47    pub r#type: String,
48    /// Full command string with arguments (for type="command")
49    pub command: String,
50    /// Optional timeout in seconds (default 600)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub timeout: Option<u32>,
53    /// Optional async flag (only for PostToolUse/PostToolUseFailure)
54    #[serde(skip_serializing_if = "Option::is_none", rename = "async")]
55    pub r#async: Option<bool>,
56    /// Optional custom spinner message
57    #[serde(skip_serializing_if = "Option::is_none", rename = "statusMessage")]
58    pub status_message: Option<String>,
59}
60
61/// Matcher group in Claude Code hooks structure
62///
63/// Each event has an array of matcher groups. Each group has an optional
64/// `matcher` regex and a `hooks` array of handlers.
65#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
66pub struct MatcherGroup {
67    /// Optional regex matcher to filter when hooks run
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub matcher: Option<String>,
70    /// Array of hook handlers
71    pub hooks: Vec<HookHandler>,
72}
73
74/// Registry entry (internal representation with metadata)
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
76pub struct RegistryEntry {
77    // Identity fields (composite key - D22)
78    /// Hook event
79    pub event: HookEvent,
80    /// Optional matcher regex (None for hooks without matcher)
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub matcher: Option<String>,
83    /// Handler type
84    #[serde(rename = "type")]
85    pub r#type: String,
86    /// Command string
87    pub command: String,
88
89    // Configuration fields (not part of identity)
90    /// Optional timeout in seconds
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub timeout: Option<u32>,
93    /// Optional async flag
94    #[serde(skip_serializing_if = "Option::is_none", rename = "async")]
95    pub r#async: Option<bool>,
96
97    // Metadata fields
98    /// Scope (e.g., "user" in v0.1)
99    pub scope: String,
100    /// Whether hook is enabled
101    pub enabled: bool,
102    /// Timestamp when hook was added (yyyyMMdd-hhmmss)
103    pub added_at: String,
104    /// Free-form string identifying installer (D24)
105    pub installed_by: String,
106    /// Optional description of what the hook does
107    #[serde(skip_serializing_if = "Option::is_none")]
108    pub description: Option<String>,
109    /// Optional reason why the hook was added
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub reason: Option<String>,
112    /// Optional flag for whether hook is optional
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub optional: Option<bool>,
115}
116
117impl RegistryEntry {
118    /// Check if this entry matches the given event and command (composite key)
119    ///
120    /// This implements the composite key matching logic from D22.
121    pub fn matches(&self, event: HookEvent, command: &str) -> bool {
122        self.event == event && self.command == command
123    }
124}
125
126/// Entry returned by list() function
127#[derive(Debug, Clone, PartialEq, Eq)]
128pub struct ListEntry {
129    /// Hook event
130    pub event: HookEvent,
131    /// Hook handler configuration
132    pub handler: HookHandler,
133    /// True if we installed this hook
134    pub managed: bool,
135    /// Present if managed, contains registry metadata
136    pub metadata: Option<RegistryMetadata>,
137}
138
139/// Subset of registry metadata for list output
140#[derive(Debug, Clone, PartialEq, Eq)]
141pub struct RegistryMetadata {
142    /// Timestamp when hook was added
143    pub added_at: String,
144    /// Free-form string identifying installer
145    pub installed_by: String,
146    /// Optional description
147    pub description: Option<String>,
148    /// Optional reason
149    pub reason: Option<String>,
150    /// Optional flag
151    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        // Test with all optional fields present
213        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        // Test with all optional fields absent
226        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        // Should match same event and command
318        assert!(entry.matches(HookEvent::Stop, "/path/to/stop.sh"));
319
320        // Should not match different command
321        assert!(!entry.matches(HookEvent::Stop, "/different/path"));
322
323        // Should not match different event
324        assert!(!entry.matches(HookEvent::SessionStart, "/path/to/stop.sh"));
325
326        // Should not match both different
327        assert!(!entry.matches(HookEvent::SessionStart, "/different/path"));
328    }
329}