use std::time::SystemTime;
use serde::{Deserialize, Serialize};
use crate::error::HooksError;
use crate::session::SessionId;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HookEventType {
PreToolUse,
PostToolUse,
PermissionRequest,
Notification,
Stop,
SubagentStop,
SessionStart,
SessionEnd,
UserPromptSubmit,
PreCompact,
}
impl HookEventType {
pub const ALL: &'static [Self] = &[
Self::PreToolUse,
Self::PostToolUse,
Self::PermissionRequest,
Self::Notification,
Self::Stop,
Self::SubagentStop,
Self::SessionStart,
Self::SessionEnd,
Self::UserPromptSubmit,
Self::PreCompact,
];
#[must_use]
pub fn as_hook_name(&self) -> &'static str {
match self {
Self::PreToolUse => "PreToolUse",
Self::PostToolUse => "PostToolUse",
Self::PermissionRequest => "PermissionRequest",
Self::Notification => "Notification",
Self::Stop => "Stop",
Self::SubagentStop => "SubagentStop",
Self::SessionStart => "SessionStart",
Self::SessionEnd => "SessionEnd",
Self::UserPromptSubmit => "UserPromptSubmit",
Self::PreCompact => "PreCompact",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookEvent {
pub session_id: SessionId,
pub event_type: HookEventType,
pub raw_payload: serde_json::Value,
pub timestamp: SystemTime,
}
impl HookEvent {
pub fn from_payload(
session_id: SessionId,
payload: serde_json::Value,
) -> Result<Self, HooksError> {
let hook_name = payload
.get("hook_event_name")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| {
HooksError::ParseFailed("missing 'hook_event_name' field".to_string())
})?;
let event_type = match hook_name {
"PreToolUse" => HookEventType::PreToolUse,
"PostToolUse" => HookEventType::PostToolUse,
"PermissionRequest" => HookEventType::PermissionRequest,
"Notification" => HookEventType::Notification,
"Stop" => HookEventType::Stop,
"SubagentStop" => HookEventType::SubagentStop,
"SessionStart" => HookEventType::SessionStart,
"SessionEnd" => HookEventType::SessionEnd,
"UserPromptSubmit" => HookEventType::UserPromptSubmit,
"PreCompact" => HookEventType::PreCompact,
_ => {
return Err(HooksError::ParseFailed(format!(
"unknown hook: {hook_name}"
)));
}
};
Ok(Self {
session_id,
event_type,
raw_payload: payload,
timestamp: SystemTime::now(),
})
}
#[must_use]
pub fn message(&self) -> Option<String> {
match self.event_type {
HookEventType::Notification => self
.raw_payload
.get("message")
.and_then(serde_json::Value::as_str)
.map(String::from),
HookEventType::PreToolUse | HookEventType::PostToolUse => {
let tool = self
.raw_payload
.get("tool_name")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
Some(format!("Tool: {tool}"))
}
HookEventType::PermissionRequest => {
let tool = self
.raw_payload
.get("tool_name")
.and_then(serde_json::Value::as_str)
.unwrap_or("unknown");
Some(format!("Permission requested for: {tool}"))
}
HookEventType::Stop | HookEventType::SubagentStop => {
let reason = self
.raw_payload
.get("reason")
.and_then(serde_json::Value::as_str)
.unwrap_or("completed");
Some(format!("Agent stopped: {reason}"))
}
_ => None,
}
}
#[must_use]
pub fn requires_attention(&self) -> bool {
matches!(self.event_type, HookEventType::Notification)
}
}
#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
use super::*;
use rstest::rstest;
use serde_json::json;
use uuid::Uuid;
fn test_session_id() -> SessionId {
SessionId::from(Uuid::new_v4())
}
#[test]
fn hook_event_type_serde_round_trip() {
for event_type in HookEventType::ALL {
let json = serde_json::to_string(event_type).expect("serialize");
let parsed: HookEventType = serde_json::from_str(&json).expect("deserialize");
assert_eq!(*event_type, parsed);
}
}
#[rstest]
#[case(HookEventType::PreToolUse, "PreToolUse")]
#[case(HookEventType::PostToolUse, "PostToolUse")]
#[case(HookEventType::PermissionRequest, "PermissionRequest")]
#[case(HookEventType::Notification, "Notification")]
#[case(HookEventType::Stop, "Stop")]
#[case(HookEventType::SubagentStop, "SubagentStop")]
#[case(HookEventType::SessionStart, "SessionStart")]
#[case(HookEventType::SessionEnd, "SessionEnd")]
#[case(HookEventType::UserPromptSubmit, "UserPromptSubmit")]
#[case(HookEventType::PreCompact, "PreCompact")]
fn hook_event_type_as_hook_name(#[case] event_type: HookEventType, #[case] expected: &str) {
assert_eq!(event_type.as_hook_name(), expected);
}
#[rstest]
#[case(
json!({"hook_event_name": "PreToolUse", "tool_name": "Bash"}),
HookEventType::PreToolUse,
Some("Tool: Bash"),
false
)]
#[case(
json!({"hook_event_name": "Notification", "message": "Test msg"}),
HookEventType::Notification,
Some("Test msg"),
true
)]
#[case(
json!({"hook_event_name": "PermissionRequest", "tool_name": "Edit"}),
HookEventType::PermissionRequest,
Some("Permission requested for: Edit"),
false
)]
#[case(
json!({"hook_event_name": "Stop", "reason": "completed"}),
HookEventType::Stop,
Some("Agent stopped: completed"),
false
)]
#[case(
json!({"hook_event_name": "SessionStart"}),
HookEventType::SessionStart,
None,
false
)]
fn from_payload_success(
#[case] payload: serde_json::Value,
#[case] expected_type: HookEventType,
#[case] expected_msg: Option<&str>,
#[case] requires_attention: bool,
) {
let event = HookEvent::from_payload(test_session_id(), payload).expect("parse");
assert_eq!(event.event_type, expected_type);
assert_eq!(event.message().as_deref(), expected_msg);
assert_eq!(event.requires_attention(), requires_attention);
}
#[rstest]
#[case(json!({"tool_name": "Bash"}))]
#[case(json!({"hook_event_name": "UnknownHook"}))]
fn from_payload_error(#[case] payload: serde_json::Value) {
assert!(HookEvent::from_payload(test_session_id(), payload).is_err());
}
#[test]
fn all_hook_types_count() {
assert_eq!(HookEventType::ALL.len(), 10);
}
}