1use serde::{Deserialize, Serialize};
9
10pub const SPEC_VERSION: &str = "0.1.0";
12
13#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
15#[serde(rename_all = "snake_case")]
16pub enum HookEvent {
17 SessionStart,
19 SessionEnd,
21 PreToolUse,
23 PostToolUse,
25 BeforeFinish,
27 AfterSave,
29 Stop,
31}
32
33impl HookEvent {
34 pub const fn as_str(self) -> &'static str {
36 match self {
37 Self::SessionStart => "session_start",
38 Self::SessionEnd => "session_end",
39 Self::PreToolUse => "pre_tool_use",
40 Self::PostToolUse => "post_tool_use",
41 Self::BeforeFinish => "before_finish",
42 Self::AfterSave => "after_save",
43 Self::Stop => "stop",
44 }
45 }
46}
47
48impl std::fmt::Display for HookEvent {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 f.write_str(self.as_str())
51 }
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56#[serde(rename_all = "snake_case")]
57pub enum PayloadValueType {
58 String,
60 Boolean,
62 Number,
64 Object,
66 Array,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
72pub struct PayloadField {
73 pub name: &'static str,
75 pub value_type: PayloadValueType,
77 pub required: bool,
79 pub description: &'static str,
81}
82
83#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
85pub struct PayloadSchema {
86 pub fields: &'static [PayloadField],
88}
89
90impl PayloadSchema {
91 pub fn is_empty(&self) -> bool {
93 self.fields.is_empty()
94 }
95}
96
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
99#[serde(rename_all = "snake_case")]
100pub enum BlockingScope {
101 NotBlocking,
103 BlocksAction,
105 BlocksContinuation,
107 Custom(&'static str),
109}
110
111#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113pub struct HookEventSpec {
114 pub event: HookEvent,
116 pub canonical_name: &'static str,
118 pub blocking: BlockingScope,
120 pub since_spec_version: &'static str,
122 pub payload_schema: PayloadSchema,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
128#[serde(rename_all = "lowercase")]
129pub enum CoverageLevel {
130 Full,
132 Partial,
134 Unsupported,
136}
137
138impl CoverageLevel {
139 pub const fn is_supported(self) -> bool {
141 !matches!(self, Self::Unsupported)
142 }
143}
144
145#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
147pub struct CompatibilityEntry {
148 pub event: HookEvent,
150 pub native_event: Option<&'static str>,
152 pub aliases: &'static [&'static str],
154 pub coverage: CoverageLevel,
156 pub scope: &'static [&'static str],
158 pub caveat: Option<&'static str>,
160 pub source: Option<&'static str>,
162 pub since_harness_version: Option<&'static str>,
164 pub until_harness_version: Option<&'static str>,
166}
167
168impl CompatibilityEntry {
169 pub fn matches_name(&self, raw_event: &str) -> bool {
171 self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
172 }
173
174 pub fn native_event_name(&self) -> Option<&'static str> {
176 self.coverage
177 .is_supported()
178 .then_some(self.native_event)
179 .flatten()
180 }
181}
182
183#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
185pub struct CompatibilityMatrix {
186 pub spec_version: &'static str,
188 pub adapter: &'static str,
190 pub events: &'static [CompatibilityEntry],
192}
193
194impl CompatibilityMatrix {
195 pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
197 self.events
198 .iter()
199 .find(|entry| entry.event.as_str() == raw_event)
200 .or_else(|| {
201 self.events
202 .iter()
203 .find(|entry| entry.aliases.contains(&raw_event))
204 })
205 }
206
207 pub fn supported_native_events(&self) -> Vec<&'static str> {
209 self.events
210 .iter()
211 .filter_map(CompatibilityEntry::native_event_name)
212 .collect()
213 }
214}
215
216pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
218 PayloadField {
219 name: "session_id",
220 value_type: PayloadValueType::String,
221 required: false,
222 description: "Harness session identifier, when available.",
223 },
224 PayloadField {
225 name: "cwd",
226 value_type: PayloadValueType::String,
227 required: false,
228 description: "Working directory for the hook invocation.",
229 },
230];
231
232pub const EVENT_SPECS: &[HookEventSpec] = &[
234 HookEventSpec {
235 event: HookEvent::SessionStart,
236 canonical_name: "session_start",
237 blocking: BlockingScope::NotBlocking,
238 since_spec_version: SPEC_VERSION,
239 payload_schema: PayloadSchema {
240 fields: COMMON_PAYLOAD_FIELDS,
241 },
242 },
243 HookEventSpec {
244 event: HookEvent::SessionEnd,
245 canonical_name: "session_end",
246 blocking: BlockingScope::NotBlocking,
247 since_spec_version: SPEC_VERSION,
248 payload_schema: PayloadSchema {
249 fields: COMMON_PAYLOAD_FIELDS,
250 },
251 },
252 HookEventSpec {
253 event: HookEvent::PreToolUse,
254 canonical_name: "pre_tool_use",
255 blocking: BlockingScope::BlocksAction,
256 since_spec_version: SPEC_VERSION,
257 payload_schema: PayloadSchema {
258 fields: COMMON_PAYLOAD_FIELDS,
259 },
260 },
261 HookEventSpec {
262 event: HookEvent::PostToolUse,
263 canonical_name: "post_tool_use",
264 blocking: BlockingScope::BlocksContinuation,
265 since_spec_version: SPEC_VERSION,
266 payload_schema: PayloadSchema {
267 fields: COMMON_PAYLOAD_FIELDS,
268 },
269 },
270 HookEventSpec {
271 event: HookEvent::BeforeFinish,
272 canonical_name: "before_finish",
273 blocking: BlockingScope::BlocksContinuation,
274 since_spec_version: SPEC_VERSION,
275 payload_schema: PayloadSchema {
276 fields: COMMON_PAYLOAD_FIELDS,
277 },
278 },
279 HookEventSpec {
280 event: HookEvent::AfterSave,
281 canonical_name: "after_save",
282 blocking: BlockingScope::NotBlocking,
283 since_spec_version: SPEC_VERSION,
284 payload_schema: PayloadSchema {
285 fields: COMMON_PAYLOAD_FIELDS,
286 },
287 },
288 HookEventSpec {
289 event: HookEvent::Stop,
290 canonical_name: "stop",
291 blocking: BlockingScope::BlocksContinuation,
292 since_spec_version: SPEC_VERSION,
293 payload_schema: PayloadSchema {
294 fields: COMMON_PAYLOAD_FIELDS,
295 },
296 },
297];
298
299#[cfg(test)]
300mod tests {
301 use super::*;
302
303 const ENTRY: CompatibilityEntry = CompatibilityEntry {
304 event: HookEvent::PreToolUse,
305 native_event: Some("PreToolUse"),
306 aliases: &["pre_tool_execution"],
307 coverage: CoverageLevel::Full,
308 scope: &["Bash"],
309 caveat: None,
310 source: None,
311 since_harness_version: None,
312 until_harness_version: None,
313 };
314
315 #[test]
316 fn hook_event_display_uses_canonical_name() {
317 assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
318 }
319
320 #[test]
321 fn compatibility_entry_matches_canonical_name_and_alias() {
322 assert!(ENTRY.matches_name("pre_tool_use"));
323 assert!(ENTRY.matches_name("pre_tool_execution"));
324 assert!(!ENTRY.matches_name("before_finish"));
325 }
326
327 #[test]
328 fn unsupported_entries_have_no_native_event_name() {
329 let entry = CompatibilityEntry {
330 coverage: CoverageLevel::Unsupported,
331 ..ENTRY
332 };
333
334 assert_eq!(entry.native_event_name(), None);
335 }
336
337 #[test]
338 fn canonical_name_takes_precedence_over_an_earlier_alias() {
339 const EVENTS: &[CompatibilityEntry] = &[
340 CompatibilityEntry {
341 event: HookEvent::BeforeFinish,
342 native_event: Some("stop"),
343 aliases: &["stop"],
344 coverage: CoverageLevel::Partial,
345 scope: &[],
346 caveat: None,
347 source: None,
348 since_harness_version: None,
349 until_harness_version: None,
350 },
351 CompatibilityEntry {
352 event: HookEvent::Stop,
353 native_event: Some("stop"),
354 aliases: &[],
355 coverage: CoverageLevel::Full,
356 scope: &[],
357 caveat: None,
358 source: None,
359 since_harness_version: None,
360 until_harness_version: None,
361 },
362 ];
363 let matrix = CompatibilityMatrix {
364 spec_version: SPEC_VERSION,
365 adapter: "test",
366 events: EVENTS,
367 };
368
369 let matched = matrix.find_event("stop").expect("stop event");
370 assert_eq!(matched.event, HookEvent::Stop);
371 assert_eq!(matched.coverage, CoverageLevel::Full);
372 }
373
374 #[test]
375 fn aliases_remain_available_when_no_canonical_name_matches() {
376 const EVENTS: &[CompatibilityEntry] = &[CompatibilityEntry {
377 event: HookEvent::PreToolUse,
378 native_event: Some("PreToolUse"),
379 aliases: &["BeforeTool"],
380 coverage: CoverageLevel::Full,
381 scope: &[],
382 caveat: None,
383 source: None,
384 since_harness_version: None,
385 until_harness_version: None,
386 }];
387 let matrix = CompatibilityMatrix {
388 spec_version: SPEC_VERSION,
389 adapter: "test",
390 events: EVENTS,
391 };
392
393 assert_eq!(
394 matrix.find_event("BeforeTool").map(|entry| entry.event),
395 Some(HookEvent::PreToolUse)
396 );
397 }
398}