tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Hook event types from Claude Code.
//!
//! Parsing and representation of hook events.

use std::time::SystemTime;

use serde::{Deserialize, Serialize};

use crate::error::HooksError;
use crate::session::SessionId;

/// Hook event types from Claude Code
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum HookEventType {
    /// Before tool execution
    PreToolUse,
    /// After tool execution
    PostToolUse,
    /// When permission is requested
    PermissionRequest,
    /// When notification occurs (`permission_prompt`, `idle_prompt`, etc.)
    Notification,
    /// When main agent completes
    Stop,
    /// When subagent completes
    SubagentStop,
    /// When session starts
    SessionStart,
    /// When session ends
    SessionEnd,
    /// When prompt is submitted
    UserPromptSubmit,
    /// Before compaction
    PreCompact,
}

impl HookEventType {
    /// All hook event types
    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,
    ];

    /// Hook name as used in Claude Code settings.json
    #[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",
        }
    }
}

/// Internal hook event representation
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HookEvent {
    /// Session ID from environment
    pub session_id: SessionId,
    /// Event type
    pub event_type: HookEventType,
    /// Original Claude Code payload
    pub raw_payload: serde_json::Value,
    /// Event timestamp
    pub timestamp: SystemTime,
}

impl HookEvent {
    /// Parse from Claude Code JSON payload + session ID
    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(),
        })
    }

    /// Extract notification message for display
    #[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,
        }
    }

    /// Check if event requires user attention
    ///
    /// Only `Notification` requires attention because it covers user-facing cases:
    /// - `permission_prompt`: permission needed (same as `PermissionRequest`)
    /// - `idle_prompt`: Claude idle
    ///
    /// `PermissionRequest` is excluded to avoid duplicate notifications.
    #[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);
    }
}