use serde::{Deserialize, Serialize};
pub const SPEC_VERSION: &str = "0.1.0";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HookEvent {
SessionStart,
SessionEnd,
PreToolUse,
PostToolUse,
BeforeFinish,
AfterSave,
Stop,
}
impl HookEvent {
pub const fn as_str(self) -> &'static str {
match self {
Self::SessionStart => "session_start",
Self::SessionEnd => "session_end",
Self::PreToolUse => "pre_tool_use",
Self::PostToolUse => "post_tool_use",
Self::BeforeFinish => "before_finish",
Self::AfterSave => "after_save",
Self::Stop => "stop",
}
}
}
impl std::fmt::Display for HookEvent {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PayloadValueType {
String,
Boolean,
Number,
Object,
Array,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PayloadField {
pub name: &'static str,
pub value_type: PayloadValueType,
pub required: bool,
pub description: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct PayloadSchema {
pub fields: &'static [PayloadField],
}
impl PayloadSchema {
pub fn is_empty(&self) -> bool {
self.fields.is_empty()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum BlockingScope {
NotBlocking,
BlocksAction,
BlocksContinuation,
Custom(&'static str),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct HookEventSpec {
pub event: HookEvent,
pub canonical_name: &'static str,
pub blocking: BlockingScope,
pub since_spec_version: &'static str,
pub payload_schema: PayloadSchema,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum CoverageLevel {
Full,
Partial,
Unsupported,
}
impl CoverageLevel {
pub const fn is_supported(self) -> bool {
!matches!(self, Self::Unsupported)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CompatibilityEntry {
pub event: HookEvent,
pub native_event: Option<&'static str>,
pub aliases: &'static [&'static str],
pub coverage: CoverageLevel,
pub scope: &'static [&'static str],
pub caveat: Option<&'static str>,
pub source: Option<&'static str>,
pub since_harness_version: Option<&'static str>,
pub until_harness_version: Option<&'static str>,
}
impl CompatibilityEntry {
pub fn matches_name(&self, raw_event: &str) -> bool {
self.event.as_str() == raw_event || self.aliases.contains(&raw_event)
}
pub fn native_event_name(&self) -> Option<&'static str> {
self.coverage
.is_supported()
.then_some(self.native_event)
.flatten()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct CompatibilityMatrix {
pub spec_version: &'static str,
pub adapter: &'static str,
pub events: &'static [CompatibilityEntry],
}
impl CompatibilityMatrix {
pub fn find_event(&self, raw_event: &str) -> Option<&CompatibilityEntry> {
self.events
.iter()
.find(|entry| entry.matches_name(raw_event))
}
pub fn supported_native_events(&self) -> Vec<&'static str> {
self.events
.iter()
.filter_map(CompatibilityEntry::native_event_name)
.collect()
}
}
pub const COMMON_PAYLOAD_FIELDS: &[PayloadField] = &[
PayloadField {
name: "session_id",
value_type: PayloadValueType::String,
required: false,
description: "Harness session identifier, when available.",
},
PayloadField {
name: "cwd",
value_type: PayloadValueType::String,
required: false,
description: "Working directory for the hook invocation.",
},
];
pub const EVENT_SPECS: &[HookEventSpec] = &[
HookEventSpec {
event: HookEvent::SessionStart,
canonical_name: "session_start",
blocking: BlockingScope::NotBlocking,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::SessionEnd,
canonical_name: "session_end",
blocking: BlockingScope::NotBlocking,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::PreToolUse,
canonical_name: "pre_tool_use",
blocking: BlockingScope::BlocksAction,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::PostToolUse,
canonical_name: "post_tool_use",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::BeforeFinish,
canonical_name: "before_finish",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::AfterSave,
canonical_name: "after_save",
blocking: BlockingScope::NotBlocking,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::Stop,
canonical_name: "stop",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
];
#[cfg(test)]
mod tests {
use super::*;
const ENTRY: CompatibilityEntry = CompatibilityEntry {
event: HookEvent::PreToolUse,
native_event: Some("PreToolUse"),
aliases: &["pre_tool_execution"],
coverage: CoverageLevel::Full,
scope: &["Bash"],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
};
#[test]
fn hook_event_display_uses_canonical_name() {
assert_eq!(HookEvent::PreToolUse.to_string(), "pre_tool_use");
}
#[test]
fn compatibility_entry_matches_canonical_name_and_alias() {
assert!(ENTRY.matches_name("pre_tool_use"));
assert!(ENTRY.matches_name("pre_tool_execution"));
assert!(!ENTRY.matches_name("before_finish"));
}
#[test]
fn unsupported_entries_have_no_native_event_name() {
let entry = CompatibilityEntry {
coverage: CoverageLevel::Unsupported,
..ENTRY
};
assert_eq!(entry.native_event_name(), None);
}
}