rconsole 1.1.0

A WebSocket-based logging library for Rust - send structured logs to NConsole desktop app
Documentation
//! Core WebConsole - manages WebSocket connection and log message dispatching.
//!
//! Uses `once_cell::sync::Lazy<Mutex<...>>` for thread-safe singleton access,
//! replacing the previous `static mut` pattern.

use std::fmt::Debug;
use std::sync::Mutex;

use chrono::Utc;
use once_cell::sync::Lazy;
use serde::Serialize;
use serde_json::json;

use crate::console::client_info::ClientInfo;
use crate::console::uri::normalize_uri;
use crate::domain::LogType;
use crate::infrastructure::WsConnection;

/// Thread-safe global singleton for WebConsole.
static INSTANCE: Lazy<Mutex<WebConsole>> = Lazy::new(|| Mutex::new(WebConsole::new()));

/// Internal console that manages the WebSocket connection,
/// client info, group stack, and log dispatch.
pub(crate) struct WebConsole {
    uri: String,
    is_enable: bool,
    ws: Option<WsConnection>,
    current_group: Vec<String>,
    client_info: ClientInfo,
    log_listener: Option<Box<dyn Fn(&str, LogType) + Send>>,
}

impl WebConsole {
    /// Creates a new WebConsole with default settings.
    fn new() -> Self {
        WebConsole {
            uri: normalize_uri(None),
            is_enable: true,
            ws: None,
            current_group: Vec::new(),
            client_info: ClientInfo::new(),
            log_listener: None,
        }
    }

    /// Get a lock on the global singleton.
    ///
    /// # Panics
    /// Panics if the mutex is poisoned (only happens if a previous
    /// holder panicked, which should not occur in normal operation).
    pub(crate) fn instance() -> std::sync::MutexGuard<'static, WebConsole> {
        INSTANCE.lock().expect("WebConsole mutex poisoned")
    }

    /// Set the WebSocket server URI. Resets the existing connection.
    pub(crate) fn set_uri(&mut self, uri: &str) {
        self.uri = normalize_uri(Some(uri));
        // Close existing connection so next send reconnects
        if let Some(ref mut ws) = self.ws {
            ws.close();
        }
        self.ws = None;
    }

    /// Enable or disable logging.
    pub(crate) fn set_enable(&mut self, enable: bool) {
        self.is_enable = enable;
    }

    /// Returns whether logging is currently enabled.
    pub(crate) fn is_enable(&self) -> bool {
        self.is_enable
    }

    /// Returns the current URI.
    pub(crate) fn uri(&self) -> &str {
        &self.uri
    }

    /// Set custom client info.
    pub(crate) fn set_client_info(&mut self, info: ClientInfo) {
        self.client_info = info;
    }

    /// Set a listener that receives every log message (for save-log or debugging).
    pub(crate) fn set_log_listener<F>(&mut self, listener: F)
    where
        F: Fn(&str, LogType) + Send + 'static,
    {
        self.log_listener = Some(Box::new(listener));
    }

    /// Remove the log listener.
    pub(crate) fn clear_log_listener(&mut self) {
        self.log_listener = None;
    }

    /// Push a group label onto the group stack.
    pub(crate) fn push_group(&mut self, label: &str) {
        self.current_group.push(label.to_string());
    }

    /// Pop a group label from the group stack. Returns `true` if a group was popped.
    pub(crate) fn pop_group(&mut self) -> bool {
        self.current_group.pop().is_some()
    }

    /// Send a log message with the given type and arguments.
    ///
    /// Arguments can be any type implementing `Debug + Serialize`.
    /// The message is serialized to JSON and sent via WebSocket.
    pub(crate) fn send_log<T: Debug + Serialize>(&mut self, log_type: LogType, args: &[T]) {
        let values: Vec<serde_json::Value> = args
            .iter()
            .map(|a| serde_json::to_value(a).unwrap_or(serde_json::Value::Null))
            .collect();
        self.send_log_values(log_type, values);
    }

    /// Send a log message with pre-built JSON values.
    ///
    /// This is used by macros to support heterogeneous argument types
    /// (e.g., `nlog!("hello", 42, true, json!({"key": "val"}))`).
    pub(crate) fn send_log_values(&mut self, log_type: LogType, args: Vec<serde_json::Value>) {
        if !self.is_enable {
            return;
        }

        // Build the inner payload data
        let payload_data = json!({
            "clientInfo": self.client_info,
            "data": args,
        });
        let payload_data_str = payload_data.to_string();

        // Notify listener if present
        if let Some(ref listener) = self.log_listener {
            listener(&payload_data_str, log_type);
        }

        // Build the full request envelope
        let request = json!({
            "timestamp": Utc::now().timestamp_millis(),
            "logType": log_type.as_str(),
            "language": "rust",
            "secure": false,
            "payload": {
                "data": payload_data_str,
            },
        });
        let message = request.to_string();

        // Connect if needed
        if self.ws.is_none() {
            self.connect();
        }

        // Send
        if let Some(ref mut ws) = self.ws {
            if let Err(e) = ws.send_text(&message) {
                eprintln!("[nconsole] Failed to send log: {}", e);
                // Connection might be broken, reset for next attempt
                self.ws = None;
            }
        }
    }

    /// Attempt to establish a WebSocket connection.
    fn connect(&mut self) {
        match WsConnection::connect(&self.uri) {
            Ok(ws) => self.ws = Some(ws),
            Err(e) => {
                eprintln!("[nconsole] WebSocket connection failed: {}", e);
                self.ws = None;
            }
        }
    }
}

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

    #[test]
    fn test_web_console_default_state() {
        let console = WebConsole::new();
        assert!(console.is_enable);
        assert_eq!(console.uri, "ws://localhost:9090");
        assert!(console.ws.is_none());
        assert!(console.current_group.is_empty());
    }

    #[test]
    fn test_set_uri() {
        let mut console = WebConsole::new();
        console.set_uri("10.10.30.40");
        assert_eq!(console.uri, "ws://10.10.30.40:9090");
    }

    #[test]
    fn test_set_enable() {
        let mut console = WebConsole::new();
        assert!(console.is_enable());

        console.set_enable(false);
        assert!(!console.is_enable());

        console.set_enable(true);
        assert!(console.is_enable());
    }

    #[test]
    fn test_group_stack() {
        let mut console = WebConsole::new();

        console.push_group("Group A");
        console.push_group("Group B");
        assert_eq!(console.current_group.len(), 2);

        assert!(console.pop_group());
        assert_eq!(console.current_group.len(), 1);
        assert_eq!(console.current_group[0], "Group A");

        assert!(console.pop_group());
        assert!(console.current_group.is_empty());

        // Pop from empty stack
        assert!(!console.pop_group());
    }

    #[test]
    fn test_send_log_when_disabled() {
        let mut console = WebConsole::new();
        console.set_enable(false);
        // Should not panic or attempt connection
        console.send_log(LogType::Log, &["test message"]);
        assert!(console.ws.is_none());
    }

    #[test]
    fn test_set_client_info() {
        let mut console = WebConsole::new();
        let custom_info = ClientInfo {
            id: "custom-id".to_string(),
            name: "Custom Client".to_string(),
            platform: "custom".to_string(),
            version: "0.0.1".to_string(),
            os: "test-os".to_string(),
            os_version: "1.0".to_string(),
            language: "vi-VN".to_string(),
            time_zone: "+07:00".to_string(),
            user_agent: "test-agent".to_string(),
            debug: true,
        };

        console.set_client_info(custom_info.clone());
        assert_eq!(console.client_info.id, "custom-id");
        assert_eq!(console.client_info.name, "Custom Client");
    }

    #[test]
    fn test_log_listener() {
        use std::sync::{Arc, Mutex as StdMutex};

        let received = Arc::new(StdMutex::new(Vec::new()));
        let received_clone = received.clone();

        let mut console = WebConsole::new();
        console.set_enable(true);
        // Don't connect, just test listener
        console.set_log_listener(move |data, log_type| {
            received_clone
                .lock()
                .unwrap()
                .push((data.to_string(), log_type));
        });

        // Won't actually send (no WS server), but listener should fire
        console.send_log(LogType::Info, &["test data"]);

        let messages = received.lock().unwrap();
        assert_eq!(messages.len(), 1);
        assert_eq!(messages[0].1, LogType::Info);
        assert!(messages[0].0.contains("test data"));
    }
}