use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HookEvent {
SessionStart,
UserPromptSubmit,
PreToolUse,
PermissionRequest,
PostToolUse,
PostToolUseFailure,
Notification,
SubagentStart,
SubagentStop,
Stop,
PreCompact,
SessionEnd,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookHandler {
#[serde(rename = "type")]
pub r#type: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename = "async")]
pub r#async: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none", rename = "statusMessage")]
pub status_message: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct MatcherGroup {
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
pub hooks: Vec<HookHandler>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RegistryEntry {
pub event: HookEvent,
#[serde(skip_serializing_if = "Option::is_none")]
pub matcher: Option<String>,
#[serde(rename = "type")]
pub r#type: String,
pub command: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub timeout: Option<u32>,
#[serde(skip_serializing_if = "Option::is_none", rename = "async")]
pub r#async: Option<bool>,
pub scope: String,
pub enabled: bool,
pub added_at: String,
pub installed_by: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub optional: Option<bool>,
}
impl RegistryEntry {
pub fn matches(&self, event: HookEvent, command: &str) -> bool {
self.event == event && self.command == command
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ListEntry {
pub event: HookEvent,
pub handler: HookHandler,
pub managed: bool,
pub metadata: Option<RegistryMetadata>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RegistryMetadata {
pub added_at: String,
pub installed_by: String,
pub description: Option<String>,
pub reason: Option<String>,
pub optional: Option<bool>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hook_event_serialization() {
let event = HookEvent::Stop;
let json = serde_json::to_string(&event).expect("serialization failed");
assert_eq!(json, r#""Stop""#);
}
#[test]
fn test_hook_event_deserialization() {
let json = r#""SessionStart""#;
let event: HookEvent = serde_json::from_str(json).expect("deserialization failed");
assert_eq!(event, HookEvent::SessionStart);
}
#[test]
fn test_all_hook_events_serialize() {
let events = vec![
(HookEvent::SessionStart, r#""SessionStart""#),
(HookEvent::UserPromptSubmit, r#""UserPromptSubmit""#),
(HookEvent::PreToolUse, r#""PreToolUse""#),
(HookEvent::PermissionRequest, r#""PermissionRequest""#),
(HookEvent::PostToolUse, r#""PostToolUse""#),
(HookEvent::PostToolUseFailure, r#""PostToolUseFailure""#),
(HookEvent::Notification, r#""Notification""#),
(HookEvent::SubagentStart, r#""SubagentStart""#),
(HookEvent::SubagentStop, r#""SubagentStop""#),
(HookEvent::Stop, r#""Stop""#),
(HookEvent::PreCompact, r#""PreCompact""#),
(HookEvent::SessionEnd, r#""SessionEnd""#),
];
for (event, expected) in events {
let json = serde_json::to_string(&event).expect("serialization failed");
assert_eq!(json, expected, "Event {:?} serialized incorrectly", event);
}
}
#[test]
fn test_hook_handler_roundtrip() {
let handler = HookHandler {
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: Some(600),
r#async: None,
status_message: None,
};
let json = serde_json::to_string(&handler).expect("serialization failed");
let deserialized: HookHandler =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(handler, deserialized);
}
#[test]
fn test_hook_handler_optional_fields() {
let handler_full = HookHandler {
r#type: "command".to_string(),
command: "/path/to/script.sh".to_string(),
timeout: Some(300),
r#async: Some(true),
status_message: Some("Running validation...".to_string()),
};
let json = serde_json::to_string(&handler_full).expect("serialization failed");
let deserialized: HookHandler =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(handler_full, deserialized);
let handler_minimal = HookHandler {
r#type: "command".to_string(),
command: "/path/to/script.sh".to_string(),
timeout: None,
r#async: None,
status_message: None,
};
let json = serde_json::to_string(&handler_minimal).expect("serialization failed");
let deserialized: HookHandler =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(handler_minimal, deserialized);
}
#[test]
fn test_matcher_group_roundtrip() {
let group = MatcherGroup {
matcher: Some("Bash".to_string()),
hooks: vec![HookHandler {
r#type: "command".to_string(),
command: "/path/to/script.sh".to_string(),
timeout: Some(10),
r#async: None,
status_message: None,
}],
};
let json = serde_json::to_string(&group).expect("serialization failed");
let deserialized: MatcherGroup =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(group, deserialized);
}
#[test]
fn test_matcher_group_without_matcher() {
let group = MatcherGroup {
matcher: None,
hooks: vec![HookHandler {
r#type: "command".to_string(),
command: "/path/to/script.sh".to_string(),
timeout: None,
r#async: None,
status_message: None,
}],
};
let json = serde_json::to_string(&group).expect("serialization failed");
assert!(!json.contains("matcher"), "matcher should be omitted when None");
let deserialized: MatcherGroup =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(group, deserialized);
}
#[test]
fn test_registry_entry_roundtrip() {
let entry = RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: Some(600),
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "acd".to_string(),
description: Some("Test hook".to_string()),
reason: Some("Testing".to_string()),
optional: Some(false),
};
let json = serde_json::to_string(&entry).expect("serialization failed");
let deserialized: RegistryEntry =
serde_json::from_str(&json).expect("deserialization failed");
assert_eq!(entry, deserialized);
}
#[test]
fn test_registry_entry_matches() {
let entry = RegistryEntry {
event: HookEvent::Stop,
matcher: None,
r#type: "command".to_string(),
command: "/path/to/stop.sh".to_string(),
timeout: None,
r#async: None,
scope: "user".to_string(),
enabled: true,
added_at: "20260203-143022".to_string(),
installed_by: "acd".to_string(),
description: None,
reason: None,
optional: None,
};
assert!(entry.matches(HookEvent::Stop, "/path/to/stop.sh"));
assert!(!entry.matches(HookEvent::Stop, "/different/path"));
assert!(!entry.matches(HookEvent::SessionStart, "/path/to/stop.sh"));
assert!(!entry.matches(HookEvent::SessionStart, "/different/path"));
}
}