hojicha-core 0.2.2

Core Elm Architecture abstractions for terminal UIs in Rust
Documentation
//! WebSocket connection helper commands

use crate::commands;
use crate::core::{Cmd, Message};
use std::sync::Arc;
use tokio::sync::Mutex;

/// WebSocket events
#[derive(Debug, Clone)]
pub enum WebSocketEvent {
    /// Connected to server
    Connected,
    /// Received a text message
    Message(String),
    /// Received binary data
    Binary(Vec<u8>),
    /// Connection closed
    Closed(Option<String>),
    /// Error occurred
    Error(WebSocketError),
}

/// WebSocket errors
#[derive(Debug, Clone)]
pub enum WebSocketError {
    /// Connection failed
    ConnectionFailed(String),
    /// Send failed
    SendFailed(String),
    /// Protocol error
    ProtocolError(String),
    /// Timeout
    Timeout,
}

impl std::fmt::Display for WebSocketError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::ConnectionFailed(e) => write!(f, "Connection failed: {e}"),
            Self::SendFailed(e) => write!(f, "Send failed: {e}"),
            Self::ProtocolError(e) => write!(f, "Protocol error: {e}"),
            Self::Timeout => write!(f, "WebSocket timeout"),
        }
    }
}

impl std::error::Error for WebSocketError {}

/// WebSocket connection handle
#[derive(Debug, Clone)]
pub struct WebSocketHandle {
    /// Unique connection ID
    pub id: String,
    /// URL of the WebSocket server
    pub url: String,
    /// Whether the connection is active
    pub connected: Arc<Mutex<bool>>,
}

impl WebSocketHandle {
    /// Send a text message through the WebSocket
    ///
    /// # Errors
    /// Returns `WebSocketError::SendFailed` if the connection is not active or sending fails.
    pub async fn send_text(&self, _message: String) -> Result<(), WebSocketError> {
        // In a real implementation, this would send through the actual WebSocket
        if *self.connected.lock().await {
            Ok(())
        } else {
            Err(WebSocketError::SendFailed("Not connected".to_string()))
        }
    }

    /// Send binary data through the WebSocket
    ///
    /// # Errors
    /// Returns `WebSocketError::SendFailed` if the connection is not active or sending fails.
    pub async fn send_binary(&self, _data: Vec<u8>) -> Result<(), WebSocketError> {
        if *self.connected.lock().await {
            Ok(())
        } else {
            Err(WebSocketError::SendFailed("Not connected".to_string()))
        }
    }

    /// Close the WebSocket connection
    pub async fn close(&self) {
        *self.connected.lock().await = false;
    }
}

/// Create a WebSocket connection command
///
/// This establishes a WebSocket connection and returns events through the handler.
/// The connection will automatically reconnect on failure.
///
/// # Example
/// ```no_run
/// # use hojicha_core::async_helpers::{websocket, WebSocketEvent};
/// # #[derive(Clone)]
/// # enum Msg {
/// #     WsConnected,
/// #     WsMessage(String),
/// #     WsDisconnected,
/// # }
///
/// websocket("wss://echo.websocket.org", |event| {
///     match event {
///         WebSocketEvent::Connected => Some(Msg::WsConnected),
///         WebSocketEvent::Message(text) => Some(Msg::WsMessage(text)),
///         WebSocketEvent::Closed(_) => Some(Msg::WsDisconnected),
///         _ => None,
///     }
/// })
/// # ;
/// ```
pub fn websocket<M, F>(url: impl Into<String>, mut handler: F) -> Cmd<M>
where
    M: Message,
    F: FnMut(WebSocketEvent) -> Option<M> + Send + 'static,
{
    let url = url.into();
    let handle = WebSocketHandle {
        id: uuid::Uuid::as_string(),
        url,
        connected: Arc::new(Mutex::new(false)),
    };

    commands::spawn(async move {
        // Simulate connection
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;

        // Mark as connected
        *handle.connected.lock().await = true;

        // Send connected event
        handler(WebSocketEvent::Connected)
    })
}

/// Create a WebSocket connection with automatic ping/pong
pub fn websocket_with_heartbeat<M, F>(
    url: impl Into<String>,
    _ping_interval: std::time::Duration,
    mut handler: F,
) -> Cmd<M>
where
    M: Message,
    F: FnMut(WebSocketEvent) -> Option<M> + Send + 'static,
{
    let _url = url.into();

    commands::spawn(async move {
        // In a real implementation, this would:
        // 1. Establish WebSocket connection
        // 2. Set up ping/pong heartbeat
        // 3. Handle reconnection on failure

        // For now, simulate connection
        tokio::time::sleep(std::time::Duration::from_millis(100)).await;
        handler(WebSocketEvent::Connected)
    })
}

/// Helper to create a WebSocket message sender command
#[must_use]
pub fn ws_send<M>(handle: WebSocketHandle, message: String) -> Cmd<M>
where
    M: Message,
{
    commands::spawn(async move {
        let _ = handle.send_text(message).await;
        None
    })
}

/// Helper to close a WebSocket connection
#[must_use]
pub fn ws_close<M>(handle: WebSocketHandle) -> Cmd<M>
where
    M: Message,
{
    commands::spawn(async move {
        handle.close().await;
        None
    })
}

// Note: In a real implementation, we would need to add uuid to dependencies
// For now, we'll create a mock UUID module
mod uuid {
    pub struct Uuid;
    impl Uuid {
        #[allow(dead_code)]
        pub const fn new_v4() -> Self {
            Self
        }
        pub fn as_string() -> String {
            format!("ws-{}", std::process::id())
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use proptest::prelude::*;

    /// Behavioral test: WebSocket error types display correctly
    #[test]
    fn test_websocket_error_display() {
        let errors = vec![
            WebSocketError::ConnectionFailed("Network unreachable".to_string()),
            WebSocketError::SendFailed("Socket closed".to_string()),
            WebSocketError::ProtocolError("Invalid frame".to_string()),
            WebSocketError::Timeout,
        ];

        for error in errors {
            let display = error.to_string();
            assert!(!display.is_empty());

            match error {
                WebSocketError::ConnectionFailed(ref msg) => assert!(display.contains(msg)),
                WebSocketError::SendFailed(ref msg) => assert!(display.contains(msg)),
                WebSocketError::ProtocolError(ref msg) => assert!(display.contains(msg)),
                WebSocketError::Timeout => assert!(display.contains("timeout")),
            }
        }
    }

    proptest! {
        #[test]
        fn prop_websocket_error_consistency(error_msg in ".*") {
            let error = WebSocketError::ConnectionFailed(error_msg.clone());
            let error_string = error.to_string();
            prop_assert!(error_string.contains(&error_msg));
        }
    }
}