use serde::{Deserialize, Serialize};
pub const SPEC_VERSION: &str = "0.2.0";
const FIRST_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.event.as_str() == raw_event)
.or_else(|| {
self.events
.iter()
.find(|entry| entry.aliases.contains(&raw_event))
})
}
pub fn supported_native_events(&self) -> Vec<&'static str> {
let mut names: Vec<&'static str> = Vec::new();
for name in self
.events
.iter()
.filter_map(CompatibilityEntry::native_event_name)
{
if !names.contains(&name) {
names.push(name);
}
}
names
}
pub fn accepted_events_summary(&self) -> String {
let mut parts = Vec::new();
for entry in self
.events
.iter()
.filter(|entry| entry.coverage.is_supported())
{
let canonical = entry.event.as_str();
let mut seen: Vec<&str> = vec![canonical];
let mut notes: Vec<String> = Vec::new();
for alias in entry.aliases {
if !seen.contains(alias) {
seen.push(alias);
notes.push((*alias).to_string());
}
}
if let Some(native) = entry.native_event
&& !seen.contains(&native)
{
notes.push(format!("renders as {native}"));
}
parts.push(if notes.is_empty() {
canonical.to_string()
} else {
format!("{canonical} ({})", notes.join(", "))
});
}
parts.join(", ")
}
}
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: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::SessionEnd,
canonical_name: "session_end",
blocking: BlockingScope::NotBlocking,
since_spec_version: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::PreToolUse,
canonical_name: "pre_tool_use",
blocking: BlockingScope::BlocksAction,
since_spec_version: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::PostToolUse,
canonical_name: "post_tool_use",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::BeforeFinish,
canonical_name: "before_finish",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::AfterSave,
canonical_name: "after_save",
blocking: BlockingScope::NotBlocking,
since_spec_version: FIRST_SPEC_VERSION,
payload_schema: PayloadSchema {
fields: COMMON_PAYLOAD_FIELDS,
},
},
HookEventSpec {
event: HookEvent::Stop,
canonical_name: "stop",
blocking: BlockingScope::BlocksContinuation,
since_spec_version: FIRST_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 supported_native_events_lists_each_name_once() {
const EVENTS: &[CompatibilityEntry] = &[
CompatibilityEntry {
event: HookEvent::BeforeFinish,
native_event: Some("stop"),
aliases: &[],
coverage: CoverageLevel::Partial,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
CompatibilityEntry {
event: HookEvent::Stop,
native_event: Some("stop"),
aliases: &[],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
];
let matrix = CompatibilityMatrix {
spec_version: SPEC_VERSION,
adapter: "test",
events: EVENTS,
};
assert_eq!(matrix.supported_native_events(), vec!["stop"]);
}
#[test]
fn accepted_events_summary_leads_with_the_names_a_manifest_can_write() {
const EVENTS: &[CompatibilityEntry] = &[
CompatibilityEntry {
event: HookEvent::PreToolUse,
native_event: Some("preToolUse"),
aliases: &[],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
CompatibilityEntry {
event: HookEvent::PostToolUse,
native_event: Some("PostToolUse"),
aliases: &["PostToolUse"],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
CompatibilityEntry {
event: HookEvent::Stop,
native_event: Some("stop"),
aliases: &[],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
CompatibilityEntry {
event: HookEvent::AfterSave,
native_event: None,
aliases: &["FileChanged"],
coverage: CoverageLevel::Unsupported,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
];
let matrix = CompatibilityMatrix {
spec_version: SPEC_VERSION,
adapter: "test",
events: EVENTS,
};
assert_eq!(
matrix.accepted_events_summary(),
"pre_tool_use (renders as preToolUse), post_tool_use (PostToolUse), stop"
);
}
#[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);
}
#[test]
fn canonical_name_takes_precedence_over_an_earlier_alias() {
const EVENTS: &[CompatibilityEntry] = &[
CompatibilityEntry {
event: HookEvent::BeforeFinish,
native_event: Some("stop"),
aliases: &["stop"],
coverage: CoverageLevel::Partial,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
CompatibilityEntry {
event: HookEvent::Stop,
native_event: Some("stop"),
aliases: &[],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
},
];
let matrix = CompatibilityMatrix {
spec_version: SPEC_VERSION,
adapter: "test",
events: EVENTS,
};
let matched = matrix.find_event("stop").expect("stop event");
assert_eq!(matched.event, HookEvent::Stop);
assert_eq!(matched.coverage, CoverageLevel::Full);
}
#[test]
fn aliases_remain_available_when_no_canonical_name_matches() {
const EVENTS: &[CompatibilityEntry] = &[CompatibilityEntry {
event: HookEvent::PreToolUse,
native_event: Some("PreToolUse"),
aliases: &["BeforeTool"],
coverage: CoverageLevel::Full,
scope: &[],
caveat: None,
source: None,
since_harness_version: None,
until_harness_version: None,
}];
let matrix = CompatibilityMatrix {
spec_version: SPEC_VERSION,
adapter: "test",
events: EVENTS,
};
assert_eq!(
matrix.find_event("BeforeTool").map(|entry| entry.event),
Some(HookEvent::PreToolUse)
);
}
}