tazuna 0.1.0

TUI tool for managing multiple Claude Code sessions in parallel
Documentation
//! Notification manager orchestrating all notification channels.
//!
//! Receives hook events and dispatches to enabled channels.

use std::collections::HashMap;

use tokio::sync::mpsc;
use tracing::{debug, warn};

use crate::config::NotificationConfig;
use crate::hooks::HookEvent;
use crate::notification::{TerminalBell, WebhookNotifier};
use crate::session::SessionId;

/// Notification status for TUI display
#[derive(Debug, Clone, Default)]
pub struct NotificationStatus {
    /// Number of pending notifications requiring attention
    pub pending_count: usize,
    /// Last event that required attention
    pub last_attention_event: Option<HookEvent>,
}

/// Central notification manager
pub struct NotificationManager {
    config: NotificationConfig,
    webhook: Option<WebhookNotifier>,
    session_names: HashMap<SessionId, String>,
    status: NotificationStatus,
    status_tx: Option<mpsc::Sender<NotificationStatus>>,
}

impl NotificationManager {
    /// Create new notification manager
    #[must_use]
    pub fn new(config: NotificationConfig) -> Self {
        let webhook = if config.webhook.enabled {
            Some(WebhookNotifier::new(&config.webhook))
        } else {
            None
        };

        Self {
            config,
            webhook,
            session_names: HashMap::new(),
            status: NotificationStatus::default(),
            status_tx: None,
        }
    }

    /// Register session name for display
    pub fn register_session(&mut self, id: SessionId, name: String) {
        self.session_names.insert(id, name);
    }

    /// Unregister session
    pub fn unregister_session(&mut self, id: &SessionId) {
        self.session_names.remove(id);
    }

    /// Handle incoming hook event
    pub async fn handle(&mut self, event: HookEvent) {
        let session_name = self
            .session_names
            .get(&event.session_id)
            .cloned()
            .unwrap_or_else(|| event.session_id.to_string());

        debug!(
            "Handling {:?} event for session {}",
            event.event_type, session_name
        );

        // Terminal bell for attention events
        if event.requires_attention()
            && self.config.terminal_bell
            && let Err(e) = TerminalBell::ring()
        {
            warn!("Failed to ring terminal bell: {e}");
        }

        // Webhook notification for attention events
        if event.requires_attention()
            && let Some(ref webhook) = self.webhook
            && let Err(e) = webhook.send(&event, &session_name).await
        {
            warn!("Webhook notification failed: {e}");
        }

        // Update status for TUI
        if event.requires_attention() {
            self.status.pending_count += 1;
            self.status.last_attention_event = Some(event);
            self.send_status_update().await;
        }
    }

    /// Get current status
    #[must_use]
    pub fn status(&self) -> &NotificationStatus {
        &self.status
    }

    /// Send status update to TUI
    async fn send_status_update(&self) {
        if let Some(ref tx) = self.status_tx
            && tx.send(self.status.clone()).await.is_err()
        {
            debug!("Status channel closed");
        }
    }
}

#[cfg(test)]
#[allow(clippy::expect_used)]
mod tests {
    use super::*;
    use crate::config::WebhookConfig;
    use rstest::rstest;
    use serde_json::json;
    use uuid::Uuid;

    fn test_config() -> NotificationConfig {
        NotificationConfig {
            terminal_bell: false,
            webhook: WebhookConfig::default(),
        }
    }

    fn test_session_id() -> SessionId {
        SessionId::from(Uuid::new_v4())
    }

    fn make_event(session_id: SessionId, hook_name: &str) -> HookEvent {
        let payload = match hook_name {
            "Notification" => json!({"hook_event_name": "Notification", "message": "Test"}),
            _ => json!({"hook_event_name": hook_name, "tool_name": "Bash"}),
        };
        HookEvent::from_payload(session_id, payload).expect("create event")
    }

    #[rstest]
    #[case("Notification", 1, true)]
    #[case("PreToolUse", 0, false)]
    #[tokio::test]
    async fn handle_event_pending_count(
        #[case] hook_name: &str,
        #[case] expected_count: usize,
        #[case] has_attention: bool,
    ) {
        let mut manager = NotificationManager::new(test_config());
        let session_id = test_session_id();
        manager.register_session(session_id, "test".to_string());

        manager.handle(make_event(session_id, hook_name)).await;

        assert_eq!(manager.status().pending_count, expected_count);
        assert_eq!(
            manager.status().last_attention_event.is_some(),
            has_attention
        );
    }

    #[test]
    fn register_unregister_session() {
        let mut manager = NotificationManager::new(test_config());
        let session_id = test_session_id();

        manager.register_session(session_id, "my-session".to_string());
        assert!(manager.session_names.contains_key(&session_id));

        manager.unregister_session(&session_id);
        assert!(!manager.session_names.contains_key(&session_id));
    }

    #[rstest]
    #[case(true, "http://example.com", true)]
    #[case(false, "", false)]
    fn new_webhook_config(#[case] enabled: bool, #[case] url: &str, #[case] has_webhook: bool) {
        let config = NotificationConfig {
            terminal_bell: false,
            webhook: WebhookConfig {
                enabled,
                url: url.to_string(),
            },
        };
        assert_eq!(
            NotificationManager::new(config).webhook.is_some(),
            has_webhook
        );
    }
}