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.matches_name(raw_event))
200 }
201
202 pub fn supported_native_events(&self) -> Vec<&'static str> {
204 self.events
205 .iter()
206 .filter_map(CompatibilityEntry::native_event_name)
207 .collect()
208 }
209}
210
211pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
213 PayloadField {
214 name: "session_id",
215 value_type: PayloadValueType::String,
216 required: false,
217 description: "Harness session identifier, when available.",
218 },
219 PayloadField {
220 name: "cwd",
221 value_type: PayloadValueType::String,
222 required: false,
223 description: "Working directory for the hook invocation.",
224 },
225];
226
227pub const EVENT_SPECS: &[HookEventSpec] = &[
229 HookEventSpec {
230 event: HookEvent::SessionStart,
231 canonical_name: "session_start",
232 blocking: BlockingScope::NotBlocking,
233 since_spec_version: SPEC_VERSION,
234 payload_schema: PayloadSchema {
235 fields: COMMON_PAYLOAD_FIELDS,
236 },
237 },
238 HookEventSpec {
239 event: HookEvent::SessionEnd,
240 canonical_name: "session_end",
241 blocking: BlockingScope::NotBlocking,
242 since_spec_version: SPEC_VERSION,
243 payload_schema: PayloadSchema {
244 fields: COMMON_PAYLOAD_FIELDS,
245 },
246 },
247 HookEventSpec {
248 event: HookEvent::PreToolUse,
249 canonical_name: "pre_tool_use",
250 blocking: BlockingScope::BlocksAction,
251 since_spec_version: SPEC_VERSION,
252 payload_schema: PayloadSchema {
253 fields: COMMON_PAYLOAD_FIELDS,
254 },
255 },
256 HookEventSpec {
257 event: HookEvent::PostToolUse,
258 canonical_name: "post_tool_use",
259 blocking: BlockingScope::BlocksContinuation,
260 since_spec_version: SPEC_VERSION,
261 payload_schema: PayloadSchema {
262 fields: COMMON_PAYLOAD_FIELDS,
263 },
264 },
265 HookEventSpec {
266 event: HookEvent::BeforeFinish,
267 canonical_name: "before_finish",
268 blocking: BlockingScope::BlocksContinuation,
269 since_spec_version: SPEC_VERSION,
270 payload_schema: PayloadSchema {
271 fields: COMMON_PAYLOAD_FIELDS,
272 },
273 },
274 HookEventSpec {
275 event: HookEvent::AfterSave,
276 canonical_name: "after_save",
277 blocking: BlockingScope::NotBlocking,
278 since_spec_version: SPEC_VERSION,
279 payload_schema: PayloadSchema {
280 fields: COMMON_PAYLOAD_FIELDS,
281 },
282 },
283 HookEventSpec {
284 event: HookEvent::Stop,
285 canonical_name: "stop",
286 blocking: BlockingScope::BlocksContinuation,
287 since_spec_version: SPEC_VERSION,
288 payload_schema: PayloadSchema {
289 fields: COMMON_PAYLOAD_FIELDS,
290 },
291 },
292];
293
294#[cfg(test)]
295mod tests {
296 use super::*;
297
298 const ENTRY: CompatibilityEntry = CompatibilityEntry {
299 event: HookEvent::PreToolUse,
300 native_event: Some("PreToolUse"),
301 aliases: &["pre_tool_execution"],
302 coverage: CoverageLevel::Full,
303 scope: &["Bash"],
304 caveat: None,
305 source: None,
306 since_harness_version: None,
307 until_harness_version: None,
308 };
309
310 #[test]
311 fn hook_event_display_uses_canonical_name() {
312 assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
313 }
314
315 #[test]
316 fn compatibility_entry_matches_canonical_name_and_alias() {
317 assert!(ENTRY.matches_name("pre_tool_use"));
318 assert!(ENTRY.matches_name("pre_tool_execution"));
319 assert!(!ENTRY.matches_name("before_finish"));
320 }
321
322 #[test]
323 fn unsupported_entries_have_no_native_event_name() {
324 let entry = CompatibilityEntry {
325 coverage: CoverageLevel::Unsupported,
326 ..ENTRY
327 };
328
329 assert_eq!(entry.native_event_name(), None);
330 }
331}