Skip to main content

atm_core/
hook.rs

1//! Hook event types and tool classification from Claude Code.
2
3use serde::{Deserialize, Serialize};
4use std::fmt;
5
6/// Returns true if the given tool name represents an interactive tool
7/// that requires user input.
8///
9/// Interactive tools pause execution and wait for user response:
10/// - `AskUserQuestion`: Prompts user with a question/options
11/// - `EnterPlanMode`: Enters planning mode, waits for user approval
12/// - `ExitPlanMode`: Presents plan for user approval
13///
14/// When these tools fire `PreToolUse`, the session should show the
15/// "needs input" indicator (blinking yellow !) rather than "running".
16///
17/// # Arguments
18/// * `tool_name` - The name of the tool from a PreToolUse hook event
19///
20/// # Returns
21/// `true` if the tool is interactive and needs user input, `false` otherwise.
22/// Returns `false` for empty or whitespace-only tool names.
23#[must_use]
24pub fn is_interactive_tool(tool_name: &str) -> bool {
25    let trimmed = tool_name.trim();
26    !trimmed.is_empty()
27        && matches!(
28            trimmed,
29            "AskUserQuestion" | "EnterPlanMode" | "ExitPlanMode"
30        )
31}
32
33/// All HookEventType variants paired with their string names.
34/// Single source of truth for string conversion.
35const HOOK_EVENT_VARIANTS: &[(HookEventType, &str)] = &[
36    (HookEventType::PreToolUse, "PreToolUse"),
37    (HookEventType::PostToolUse, "PostToolUse"),
38    (HookEventType::PostToolUseFailure, "PostToolUseFailure"),
39    (HookEventType::UserPromptSubmit, "UserPromptSubmit"),
40    (HookEventType::Stop, "Stop"),
41    (HookEventType::SubagentStart, "SubagentStart"),
42    (HookEventType::SubagentStop, "SubagentStop"),
43    (HookEventType::SessionStart, "SessionStart"),
44    (HookEventType::SessionEnd, "SessionEnd"),
45    (HookEventType::PreCompact, "PreCompact"),
46    (HookEventType::Setup, "Setup"),
47    (HookEventType::Notification, "Notification"),
48];
49
50/// Types of hook events from Claude Code.
51///
52/// All 12 Claude Code hook events, based on official documentation.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
54#[serde(rename_all = "PascalCase")]
55pub enum HookEventType {
56    // === Tool Execution ===
57    /// Before a tool is executed
58    PreToolUse,
59    /// After a tool completes successfully
60    PostToolUse,
61    /// After a tool fails
62    PostToolUseFailure,
63
64    // === User Interaction ===
65    /// User submitted a prompt
66    UserPromptSubmit,
67    /// Claude stopped responding (finished turn)
68    Stop,
69
70    // === Subagent Lifecycle ===
71    /// A subagent was spawned
72    SubagentStart,
73    /// A subagent completed
74    SubagentStop,
75
76    // === Session Lifecycle ===
77    /// Session started (new, resumed, or cleared)
78    SessionStart,
79    /// Session ended
80    SessionEnd,
81
82    // === Context Management ===
83    /// Context compaction is about to occur
84    PreCompact,
85    /// One-time setup is running
86    Setup,
87
88    // === Notifications ===
89    /// Informational notification
90    Notification,
91}
92
93impl HookEventType {
94    /// Returns the canonical string name for this event type.
95    ///
96    /// This is the single source of truth for event name strings,
97    /// used by both `from_event_name()` and `Display`.
98    #[must_use]
99    pub fn as_str(&self) -> &'static str {
100        // Use the constant array as single source of truth
101        for (variant, name) in HOOK_EVENT_VARIANTS {
102            if variant == self {
103                return name;
104            }
105        }
106        // This is unreachable if HOOK_EVENT_VARIANTS is complete
107        "Unknown"
108    }
109
110    /// Returns true if this is a pre-execution event.
111    #[must_use]
112    pub fn is_pre_event(&self) -> bool {
113        matches!(
114            self,
115            Self::PreToolUse
116                | Self::SessionStart
117                | Self::PreCompact
118                | Self::SubagentStart
119                | Self::Setup
120        )
121    }
122
123    /// Returns true if this is a post-execution event.
124    #[must_use]
125    pub fn is_post_event(&self) -> bool {
126        matches!(
127            self,
128            Self::PostToolUse
129                | Self::PostToolUseFailure
130                | Self::SessionEnd
131                | Self::Stop
132                | Self::SubagentStop
133        )
134    }
135
136    /// Parses from a hook event name string.
137    ///
138    /// Uses the `HOOK_EVENT_VARIANTS` constant as single source of truth.
139    #[must_use]
140    pub fn from_event_name(name: &str) -> Option<Self> {
141        HOOK_EVENT_VARIANTS
142            .iter()
143            .find(|(_, s)| *s == name)
144            .map(|(v, _)| *v)
145    }
146}
147
148impl fmt::Display for HookEventType {
149    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
150        f.write_str(self.as_str())
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn test_hook_event_parsing() {
160        assert_eq!(
161            HookEventType::from_event_name("PreToolUse"),
162            Some(HookEventType::PreToolUse)
163        );
164        assert_eq!(HookEventType::from_event_name("Unknown"), None);
165    }
166
167    #[test]
168    fn test_hook_event_classification() {
169        assert!(HookEventType::PreToolUse.is_pre_event());
170        assert!(HookEventType::PostToolUse.is_post_event());
171        assert!(!HookEventType::PreToolUse.is_post_event());
172    }
173
174    #[test]
175    fn test_is_interactive_tool() {
176        // Interactive tools should return true
177        assert!(is_interactive_tool("AskUserQuestion"));
178        assert!(is_interactive_tool("EnterPlanMode"));
179        assert!(is_interactive_tool("ExitPlanMode"));
180
181        // Standard tools should return false
182        assert!(!is_interactive_tool("Bash"));
183        assert!(!is_interactive_tool("Read"));
184        assert!(!is_interactive_tool("Write"));
185        assert!(!is_interactive_tool("Edit"));
186        assert!(!is_interactive_tool("WebSearch"));
187        assert!(!is_interactive_tool("Grep"));
188        assert!(!is_interactive_tool("Glob"));
189        assert!(!is_interactive_tool("Task"));
190    }
191
192    #[test]
193    fn test_is_interactive_tool_edge_cases() {
194        // Empty string should return false
195        assert!(!is_interactive_tool(""));
196
197        // Whitespace only should return false
198        assert!(!is_interactive_tool("   "));
199        assert!(!is_interactive_tool("\t"));
200        assert!(!is_interactive_tool("\n"));
201
202        // Case sensitivity - wrong case should return false
203        assert!(!is_interactive_tool("askuserquestion"));
204        assert!(!is_interactive_tool("ASKUSERQUESTION"));
205        assert!(!is_interactive_tool("AskUserquestion"));
206
207        // Partial matches should return false
208        assert!(!is_interactive_tool("AskUser"));
209        assert!(!is_interactive_tool("Question"));
210        assert!(!is_interactive_tool("EnterPlan"));
211
212        // Extra whitespace should be trimmed
213        assert!(is_interactive_tool("  AskUserQuestion  "));
214        assert!(is_interactive_tool("\tEnterPlanMode\n"));
215    }
216
217    #[test]
218    fn test_hook_event_all_variants_parse() {
219        // Tool events
220        assert_eq!(
221            HookEventType::from_event_name("PreToolUse"),
222            Some(HookEventType::PreToolUse)
223        );
224        assert_eq!(
225            HookEventType::from_event_name("PostToolUse"),
226            Some(HookEventType::PostToolUse)
227        );
228        assert_eq!(
229            HookEventType::from_event_name("PostToolUseFailure"),
230            Some(HookEventType::PostToolUseFailure)
231        );
232
233        // User events
234        assert_eq!(
235            HookEventType::from_event_name("UserPromptSubmit"),
236            Some(HookEventType::UserPromptSubmit)
237        );
238        assert_eq!(
239            HookEventType::from_event_name("Stop"),
240            Some(HookEventType::Stop)
241        );
242
243        // Subagent events
244        assert_eq!(
245            HookEventType::from_event_name("SubagentStart"),
246            Some(HookEventType::SubagentStart)
247        );
248        assert_eq!(
249            HookEventType::from_event_name("SubagentStop"),
250            Some(HookEventType::SubagentStop)
251        );
252
253        // Session events
254        assert_eq!(
255            HookEventType::from_event_name("SessionStart"),
256            Some(HookEventType::SessionStart)
257        );
258        assert_eq!(
259            HookEventType::from_event_name("SessionEnd"),
260            Some(HookEventType::SessionEnd)
261        );
262
263        // Context events
264        assert_eq!(
265            HookEventType::from_event_name("PreCompact"),
266            Some(HookEventType::PreCompact)
267        );
268        assert_eq!(
269            HookEventType::from_event_name("Setup"),
270            Some(HookEventType::Setup)
271        );
272
273        // Notification
274        assert_eq!(
275            HookEventType::from_event_name("Notification"),
276            Some(HookEventType::Notification)
277        );
278    }
279
280    #[test]
281    fn test_hook_event_classification_extended() {
282        // Pre-events
283        assert!(HookEventType::PreToolUse.is_pre_event());
284        assert!(HookEventType::SessionStart.is_pre_event());
285        assert!(HookEventType::PreCompact.is_pre_event());
286        assert!(HookEventType::SubagentStart.is_pre_event());
287        assert!(HookEventType::Setup.is_pre_event());
288
289        // Post-events
290        assert!(HookEventType::PostToolUse.is_post_event());
291        assert!(HookEventType::PostToolUseFailure.is_post_event());
292        assert!(HookEventType::SessionEnd.is_post_event());
293        assert!(HookEventType::Stop.is_post_event());
294        assert!(HookEventType::SubagentStop.is_post_event());
295    }
296}