1use crate::permissions::{PermissionDecision, PermissionUpdate};
4use serde::{Deserialize, Serialize};
5use std::collections::HashMap;
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
19#[serde(untagged)]
20pub enum HookOutput {
21 Async(AsyncHookOutput),
22 Sync(SyncHookOutput),
23}
24
25impl Default for HookOutput {
26 fn default() -> Self {
27 HookOutput::Sync(SyncHookOutput::default())
28 }
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
33pub struct AsyncHookOutput {
34 #[serde(rename = "async")]
36 pub is_async: bool,
37 #[serde(skip_serializing_if = "Option::is_none")]
39 pub async_timeout: Option<u64>,
40}
41
42#[derive(Debug, Clone, Default, Serialize, Deserialize)]
44pub struct SyncHookOutput {
45 #[serde(rename = "continue", skip_serializing_if = "Option::is_none")]
47 pub should_continue: Option<bool>,
48
49 #[serde(skip_serializing_if = "Option::is_none")]
51 pub suppress_output: Option<bool>,
52
53 #[serde(skip_serializing_if = "Option::is_none")]
55 pub stop_reason: Option<String>,
56
57 #[serde(skip_serializing_if = "Option::is_none")]
59 pub decision: Option<HookDecision>,
60
61 #[serde(skip_serializing_if = "Option::is_none")]
63 pub system_message: Option<String>,
64
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub reason: Option<String>,
68
69 #[serde(skip_serializing_if = "Option::is_none")]
71 pub hook_specific_output: Option<HookSpecificOutput>,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "lowercase")]
76pub enum HookDecision {
77 Approve,
78 Block,
79}
80
81#[derive(Debug, Clone, Serialize, Deserialize)]
83#[serde(tag = "hookEventName")]
84pub enum HookSpecificOutput {
85 PreToolUse {
86 #[serde(skip_serializing_if = "Option::is_none")]
87 permission_decision: Option<PermissionDecision>,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 permission_decision_reason: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
91 updated_input: Option<HashMap<String, serde_json::Value>>,
92 #[serde(skip_serializing_if = "Option::is_none")]
93 additional_context: Option<String>,
94 },
95
96 PostToolUse {
97 #[serde(skip_serializing_if = "Option::is_none")]
98 additional_context: Option<String>,
99 #[serde(skip_serializing_if = "Option::is_none")]
100 updated_mcp_tool_output: Option<serde_json::Value>,
101 },
102
103 PostToolUseFailure {
104 #[serde(skip_serializing_if = "Option::is_none")]
105 additional_context: Option<String>,
106 },
107
108 UserPromptSubmit {
109 #[serde(skip_serializing_if = "Option::is_none")]
110 additional_context: Option<String>,
111 },
112
113 SessionStart {
114 #[serde(skip_serializing_if = "Option::is_none")]
115 additional_context: Option<String>,
116 },
117
118 Setup {
119 #[serde(skip_serializing_if = "Option::is_none")]
120 additional_context: Option<String>,
121 },
122
123 SubagentStart {
124 #[serde(skip_serializing_if = "Option::is_none")]
125 additional_context: Option<String>,
126 },
127
128 Notification {
129 #[serde(skip_serializing_if = "Option::is_none")]
130 additional_context: Option<String>,
131 },
132
133 PermissionRequest {
134 decision: PermissionRequestDecision,
135 },
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
140#[serde(tag = "behavior")]
141pub enum PermissionRequestDecision {
142 #[serde(rename = "allow")]
143 Allow {
144 #[serde(skip_serializing_if = "Option::is_none")]
145 updated_input: Option<HashMap<String, serde_json::Value>>,
146 #[serde(skip_serializing_if = "Option::is_none")]
147 updated_permissions: Option<Vec<PermissionUpdate>>,
148 },
149 #[serde(rename = "deny")]
150 Deny {
151 #[serde(skip_serializing_if = "Option::is_none")]
152 message: Option<String>,
153 #[serde(skip_serializing_if = "Option::is_none")]
154 interrupt: Option<bool>,
155 },
156}
157
158#[cfg(test)]
159mod tests {
160 use super::*;
161
162 #[test]
163 fn default_output_is_sync() {
164 let output = HookOutput::default();
165 assert!(matches!(output, HookOutput::Sync(_)));
166 }
167
168 #[test]
169 fn sync_output_default_has_no_fields_set() {
170 let sync = SyncHookOutput::default();
171 assert!(sync.should_continue.is_none());
172 assert!(sync.suppress_output.is_none());
173 assert!(sync.stop_reason.is_none());
174 assert!(sync.decision.is_none());
175 assert!(sync.system_message.is_none());
176 assert!(sync.reason.is_none());
177 assert!(sync.hook_specific_output.is_none());
178 }
179
180 #[test]
181 fn hook_decision_serde() {
182 let approve = HookDecision::Approve;
183 let json = serde_json::to_string(&approve).unwrap();
184 assert_eq!(json, "\"approve\"");
185
186 let block = HookDecision::Block;
187 let json = serde_json::to_string(&block).unwrap();
188 assert_eq!(json, "\"block\"");
189 }
190
191 #[test]
192 fn async_output_roundtrip() {
193 let output = HookOutput::Async(AsyncHookOutput {
194 is_async: true,
195 async_timeout: Some(5000),
196 });
197 let json = serde_json::to_string(&output).unwrap();
198 assert!(json.contains("\"async\":true"));
199 assert!(json.contains("5000"));
200 }
201
202 #[test]
203 fn sync_output_with_decision_roundtrip() {
204 let output = HookOutput::Sync(SyncHookOutput {
205 should_continue: Some(false),
206 decision: Some(HookDecision::Block),
207 reason: Some("blocked by policy".into()),
208 ..Default::default()
209 });
210 let json = serde_json::to_string(&output).unwrap();
211 let back: HookOutput = serde_json::from_str(&json).unwrap();
212 match back {
213 HookOutput::Sync(sync) => {
214 assert_eq!(sync.should_continue, Some(false));
215 assert_eq!(sync.decision, Some(HookDecision::Block));
216 assert_eq!(sync.reason.as_deref(), Some("blocked by policy"));
217 }
218 _ => panic!("expected Sync output"),
219 }
220 }
221
222 #[test]
223 fn pre_tool_use_specific_output() {
224 let specific = HookSpecificOutput::PreToolUse {
225 permission_decision: Some(PermissionDecision::Deny),
226 permission_decision_reason: Some("not allowed".into()),
227 updated_input: None,
228 additional_context: Some("context".into()),
229 };
230 let json = serde_json::to_string(&specific).unwrap();
231 assert!(json.contains("\"hookEventName\":\"PreToolUse\""));
232 assert!(json.contains("\"permission_decision\":\"deny\""));
233 }
234
235 #[test]
236 fn permission_request_decision_allow() {
237 let decision = PermissionRequestDecision::Allow {
238 updated_input: None,
239 updated_permissions: None,
240 };
241 let json = serde_json::to_string(&decision).unwrap();
242 assert!(json.contains("\"behavior\":\"allow\""));
243 }
244
245 #[test]
246 fn permission_request_decision_deny_with_message() {
247 let decision = PermissionRequestDecision::Deny {
248 message: Some("no access".into()),
249 interrupt: Some(true),
250 };
251 let json = serde_json::to_string(&decision).unwrap();
252 assert!(json.contains("\"behavior\":\"deny\""));
253 assert!(json.contains("no access"));
254 }
255}