1use serde::{Deserialize, Serialize};
9
10pub const SPEC_VERSION: &str = "0.2.0";
12
13const FIRST_SPEC_VERSION: &str = "0.1.0";
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(rename_all = "snake_case")]
21pub enum HookEvent {
22 SessionStart,
24 SessionEnd,
26 PreToolUse,
28 PostToolUse,
30 BeforeFinish,
32 AfterSave,
34 Stop,
36}
37
38impl HookEvent {
39 pub const fn as_str(self) -> &'static str {
41 match self {
42 Self::SessionStart => "session_start",
43 Self::SessionEnd => "session_end",
44 Self::PreToolUse => "pre_tool_use",
45 Self::PostToolUse => "post_tool_use",
46 Self::BeforeFinish => "before_finish",
47 Self::AfterSave => "after_save",
48 Self::Stop => "stop",
49 }
50 }
51}
52
53impl std::fmt::Display for HookEvent {
54 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
55 f.write_str(self.as_str())
56 }
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "snake_case")]
62pub enum PayloadValueType {
63 String,
65 Boolean,
67 Number,
69 Object,
71 Array,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
77pub struct PayloadField {
78 pub name: &'static str,
80 pub value_type: PayloadValueType,
82 pub required: bool,
84 pub description: &'static str,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
90pub struct PayloadSchema {
91 pub fields: &'static [PayloadField],
93}
94
95impl PayloadSchema {
96 pub fn is_empty(&self) -> bool {
98 self.fields.is_empty()
99 }
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
104#[serde(rename_all = "snake_case")]
105pub enum BlockingScope {
106 NotBlocking,
108 BlocksAction,
110 BlocksContinuation,
112 Custom(&'static str),
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
118pub struct HookEventSpec {
119 pub event: HookEvent,
121 pub canonical_name: &'static str,
123 pub blocking: BlockingScope,
125 pub since_spec_version: &'static str,
127 pub payload_schema: PayloadSchema,
129}
130
131#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
133#[serde(rename_all = "lowercase")]
134pub enum CoverageLevel {
135 Full,
137 Partial,
139 Unsupported,
141}
142
143impl CoverageLevel {
144 pub const fn is_supported(self) -> bool {
146 !matches!(self, Self::Unsupported)
147 }
148}
149
150#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
152pub struct CompatibilityEntry {
153 pub event: HookEvent,
155 pub native_event: Option<&'static str>,
157 pub aliases: &'static [&'static str],
159 pub coverage: CoverageLevel,
161 pub scope: &'static [&'static str],
163 pub caveat: Option<&'static str>,
165 pub source: Option<&'static str>,
167 pub since_harness_version: Option<&'static str>,
169 pub until_harness_version: Option<&'static str>,
171}
172
173impl CompatibilityEntry {
174 pub fn matches_name(&self, raw_event: &str) -> bool {
176 self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
177 }
178
179 pub fn native_event_name(&self) -> Option<&'static str> {
181 self.coverage
182 .is_supported()
183 .then_some(self.native_event)
184 .flatten()
185 }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
190pub struct CompatibilityMatrix {
191 pub spec_version: &'static str,
193 pub adapter: &'static str,
195 pub events: &'static [CompatibilityEntry],
197}
198
199impl CompatibilityMatrix {
200 pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
202 self.events
203 .iter()
204 .find(|entry| entry.event.as_str() == raw_event)
205 .or_else(|| {
206 self.events
207 .iter()
208 .find(|entry| entry.aliases.contains(&raw_event))
209 })
210 }
211
212 pub fn supported_native_events(&self) -> Vec<&'static str> {
218 let mut names: Vec<&'static str> = Vec::new();
219 for name in self
220 .events
221 .iter()
222 .filter_map(CompatibilityEntry::native_event_name)
223 {
224 if !names.contains(&name) {
225 names.push(name);
226 }
227 }
228 names
229 }
230
231 pub fn accepted_events_summary(&self) -> String {
239 let mut parts = Vec::new();
240 for entry in self
241 .events
242 .iter()
243 .filter(|entry| entry.coverage.is_supported())
244 {
245 let canonical = entry.event.as_str();
246 let mut seen: Vec<&str> = vec![canonical];
247 let mut notes: Vec<String> = Vec::new();
248 for alias in entry.aliases {
249 if !seen.contains(alias) {
250 seen.push(alias);
251 notes.push((*alias).to_string());
252 }
253 }
254 if let Some(native) = entry.native_event
255 && !seen.contains(&native)
256 {
257 notes.push(format!("renders as {native}"));
258 }
259 parts.push(if notes.is_empty() {
260 canonical.to_string()
261 } else {
262 format!("{canonical} ({})", notes.join(", "))
263 });
264 }
265 parts.join(", ")
266 }
267}
268
269pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
271 PayloadField {
272 name: "session_id",
273 value_type: PayloadValueType::String,
274 required: false,
275 description: "Harness session identifier, when available.",
276 },
277 PayloadField {
278 name: "cwd",
279 value_type: PayloadValueType::String,
280 required: false,
281 description: "Working directory for the hook invocation.",
282 },
283];
284
285pub const EVENT_SPECS: &[HookEventSpec] = &[
287 HookEventSpec {
288 event: HookEvent::SessionStart,
289 canonical_name: "session_start",
290 blocking: BlockingScope::NotBlocking,
291 since_spec_version: FIRST_SPEC_VERSION,
292 payload_schema: PayloadSchema {
293 fields: COMMON_PAYLOAD_FIELDS,
294 },
295 },
296 HookEventSpec {
297 event: HookEvent::SessionEnd,
298 canonical_name: "session_end",
299 blocking: BlockingScope::NotBlocking,
300 since_spec_version: FIRST_SPEC_VERSION,
301 payload_schema: PayloadSchema {
302 fields: COMMON_PAYLOAD_FIELDS,
303 },
304 },
305 HookEventSpec {
306 event: HookEvent::PreToolUse,
307 canonical_name: "pre_tool_use",
308 blocking: BlockingScope::BlocksAction,
309 since_spec_version: FIRST_SPEC_VERSION,
310 payload_schema: PayloadSchema {
311 fields: COMMON_PAYLOAD_FIELDS,
312 },
313 },
314 HookEventSpec {
315 event: HookEvent::PostToolUse,
316 canonical_name: "post_tool_use",
317 blocking: BlockingScope::BlocksContinuation,
318 since_spec_version: FIRST_SPEC_VERSION,
319 payload_schema: PayloadSchema {
320 fields: COMMON_PAYLOAD_FIELDS,
321 },
322 },
323 HookEventSpec {
324 event: HookEvent::BeforeFinish,
325 canonical_name: "before_finish",
326 blocking: BlockingScope::BlocksContinuation,
327 since_spec_version: FIRST_SPEC_VERSION,
328 payload_schema: PayloadSchema {
329 fields: COMMON_PAYLOAD_FIELDS,
330 },
331 },
332 HookEventSpec {
333 event: HookEvent::AfterSave,
334 canonical_name: "after_save",
335 blocking: BlockingScope::NotBlocking,
336 since_spec_version: FIRST_SPEC_VERSION,
337 payload_schema: PayloadSchema {
338 fields: COMMON_PAYLOAD_FIELDS,
339 },
340 },
341 HookEventSpec {
342 event: HookEvent::Stop,
343 canonical_name: "stop",
344 blocking: BlockingScope::BlocksContinuation,
345 since_spec_version: FIRST_SPEC_VERSION,
346 payload_schema: PayloadSchema {
347 fields: COMMON_PAYLOAD_FIELDS,
348 },
349 },
350];
351
352#[cfg(test)]
353mod tests {
354 use super::*;
355
356 const ENTRY: CompatibilityEntry = CompatibilityEntry {
357 event: HookEvent::PreToolUse,
358 native_event: Some("PreToolUse"),
359 aliases: &["pre_tool_execution"],
360 coverage: CoverageLevel::Full,
361 scope: &["Bash"],
362 caveat: None,
363 source: None,
364 since_harness_version: None,
365 until_harness_version: None,
366 };
367
368 #[test]
369 fn supported_native_events_lists_each_name_once() {
370 const EVENTS: &[CompatibilityEntry] = &[
371 CompatibilityEntry {
372 event: HookEvent::BeforeFinish,
373 native_event: Some("stop"),
374 aliases: &[],
375 coverage: CoverageLevel::Partial,
376 scope: &[],
377 caveat: None,
378 source: None,
379 since_harness_version: None,
380 until_harness_version: None,
381 },
382 CompatibilityEntry {
383 event: HookEvent::Stop,
384 native_event: Some("stop"),
385 aliases: &[],
386 coverage: CoverageLevel::Full,
387 scope: &[],
388 caveat: None,
389 source: None,
390 since_harness_version: None,
391 until_harness_version: None,
392 },
393 ];
394 let matrix = CompatibilityMatrix {
395 spec_version: SPEC_VERSION,
396 adapter: "test",
397 events: EVENTS,
398 };
399 assert_eq!(matrix.supported_native_events(), vec!["stop"]);
400 }
401
402 #[test]
403 fn accepted_events_summary_leads_with_the_names_a_manifest_can_write() {
404 const EVENTS: &[CompatibilityEntry] = &[
405 CompatibilityEntry {
406 event: HookEvent::PreToolUse,
407 native_event: Some("preToolUse"),
408 aliases: &[],
409 coverage: CoverageLevel::Full,
410 scope: &[],
411 caveat: None,
412 source: None,
413 since_harness_version: None,
414 until_harness_version: None,
415 },
416 CompatibilityEntry {
417 event: HookEvent::PostToolUse,
418 native_event: Some("PostToolUse"),
419 aliases: &["PostToolUse"],
420 coverage: CoverageLevel::Full,
421 scope: &[],
422 caveat: None,
423 source: None,
424 since_harness_version: None,
425 until_harness_version: None,
426 },
427 CompatibilityEntry {
428 event: HookEvent::Stop,
429 native_event: Some("stop"),
430 aliases: &[],
431 coverage: CoverageLevel::Full,
432 scope: &[],
433 caveat: None,
434 source: None,
435 since_harness_version: None,
436 until_harness_version: None,
437 },
438 CompatibilityEntry {
439 event: HookEvent::AfterSave,
440 native_event: None,
441 aliases: &["FileChanged"],
442 coverage: CoverageLevel::Unsupported,
443 scope: &[],
444 caveat: None,
445 source: None,
446 since_harness_version: None,
447 until_harness_version: None,
448 },
449 ];
450 let matrix = CompatibilityMatrix {
451 spec_version: SPEC_VERSION,
452 adapter: "test",
453 events: EVENTS,
454 };
455 assert_eq!(
456 matrix.accepted_events_summary(),
457 "pre_tool_use (renders as preToolUse), post_tool_use (PostToolUse), stop"
458 );
459 }
460
461 #[test]
462 fn hook_event_display_uses_canonical_name() {
463 assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
464 }
465
466 #[test]
467 fn compatibility_entry_matches_canonical_name_and_alias() {
468 assert!(ENTRY.matches_name("pre_tool_use"));
469 assert!(ENTRY.matches_name("pre_tool_execution"));
470 assert!(!ENTRY.matches_name("before_finish"));
471 }
472
473 #[test]
474 fn unsupported_entries_have_no_native_event_name() {
475 let entry = CompatibilityEntry {
476 coverage: CoverageLevel::Unsupported,
477 ..ENTRY
478 };
479
480 assert_eq!(entry.native_event_name(), None);
481 }
482
483 #[test]
484 fn canonical_name_takes_precedence_over_an_earlier_alias() {
485 const EVENTS: &[CompatibilityEntry] = &[
486 CompatibilityEntry {
487 event: HookEvent::BeforeFinish,
488 native_event: Some("stop"),
489 aliases: &["stop"],
490 coverage: CoverageLevel::Partial,
491 scope: &[],
492 caveat: None,
493 source: None,
494 since_harness_version: None,
495 until_harness_version: None,
496 },
497 CompatibilityEntry {
498 event: HookEvent::Stop,
499 native_event: Some("stop"),
500 aliases: &[],
501 coverage: CoverageLevel::Full,
502 scope: &[],
503 caveat: None,
504 source: None,
505 since_harness_version: None,
506 until_harness_version: None,
507 },
508 ];
509 let matrix = CompatibilityMatrix {
510 spec_version: SPEC_VERSION,
511 adapter: "test",
512 events: EVENTS,
513 };
514
515 let matched = matrix.find_event("stop").expect("stop event");
516 assert_eq!(matched.event, HookEvent::Stop);
517 assert_eq!(matched.coverage, CoverageLevel::Full);
518 }
519
520 #[test]
521 fn aliases_remain_available_when_no_canonical_name_matches() {
522 const EVENTS: &[CompatibilityEntry] = &[CompatibilityEntry {
523 event: HookEvent::PreToolUse,
524 native_event: Some("PreToolUse"),
525 aliases: &["BeforeTool"],
526 coverage: CoverageLevel::Full,
527 scope: &[],
528 caveat: None,
529 source: None,
530 since_harness_version: None,
531 until_harness_version: None,
532 }];
533 let matrix = CompatibilityMatrix {
534 spec_version: SPEC_VERSION,
535 adapter: "test",
536 events: EVENTS,
537 };
538
539 assert_eq!(
540 matrix.find_event("BeforeTool").map(|entry| entry.event),
541 Some(HookEvent::PreToolUse)
542 );
543 }
544}