slack-log 1.1.3

Slack log for sending plain and block messages using Slack webhook
Documentation
use std::sync::OnceLock;
use serde::Serialize;
use serde_json::{json, Value};
use reqwest::Client;
use tokio; // make sure `tokio` is in Cargo.toml

#[derive(Debug)]
pub enum LogLevel {
    DEFAULT,
    SUCCESS,
    INFO,
    WARN,
    ERROR,
}

impl LogLevel {
    pub fn color(&self) -> &'static str {
        match self {
            LogLevel::DEFAULT => "#B4B4B8",
            LogLevel::SUCCESS => "#65B741",
            LogLevel::INFO => "#40A2D8",
            LogLevel::WARN => "#E3651D",
            LogLevel::ERROR => "#FF0000",
        }
    }
}

#[derive(Debug, Serialize, Clone)]
pub struct BlockField {
    pub title: String,
    pub value: Value,
}

impl BlockField {
    pub fn new<T: Serialize>(title: impl Into<String>, value: T) -> Self {
        BlockField {
            title: title.into(),
            value: serde_json::to_value(value).unwrap_or(Value::Null),
        }
    }
}

#[derive(Debug, Clone)]
pub struct SlackConfig {
    pub webhook_url: String,
    pub debugger: bool,
}

static CONFIG: OnceLock<SlackConfig> = OnceLock::new();

pub fn slack_log_initialize(config: SlackConfig) {
    CONFIG.set(config).expect("SlackLogger already initialized");
}

fn get_config() -> &'static SlackConfig {
    CONFIG
        .get()
        .expect("SlackLogger not initialized. Call `slack_log_initialize()` first.")
}

fn get_webhook_url() -> Option<String> {
    let config = get_config();
    if config.webhook_url.starts_with("https://") {
        Some(config.webhook_url.clone())
    } else {
        None
    }
}

fn is_debug() -> bool {
    get_config().debugger
}

async fn send_async(payload: Value) {
    if let Some(url) = get_webhook_url() {
        let client = Client::new();
        match client.post(url).json(&payload).send().await {
            Ok(_) => {
                if is_debug() {
                    println!("✅ Slack log sent.");
                }
            }
            Err(e) => {
                eprintln!("🚨 Failed to send Slack log: {}", e);
            }
        }
    } else {
        eprintln!("🚨 Invalid or missing Slack Webhook URL");
    }
}

// ---------- Improved Simple Text Logs with Optional Fields ----------

pub fn log<T: Serialize + Send + 'static>(data: T) {
    spawn_dynamic("🔸 LOG", data);
}
pub fn log_info<T: Serialize + Send + 'static>(data: T) {
    spawn_dynamic("ℹ INFO", data);
}
pub fn log_warn<T: Serialize + Send + 'static>(data: T) {
    spawn_dynamic("⚠ WARNING", data);
}
pub fn log_error<T: Serialize + Send + 'static>(data: T) {
    spawn_dynamic("🚨 ERROR", data);
}
pub fn log_success<T: Serialize + Send + 'static>(data: T) {
    spawn_dynamic("✅ SUCCESS", data);
}

fn spawn_dynamic<T: Serialize + Send + 'static>(prefix: &str, data: T) {
    let prefix = prefix.to_string();
    tokio::spawn(async move {
        send_dynamic(&prefix, data).await;
    });
}

async fn send_dynamic<T: Serialize>(prefix: &str, data: T) {
    let value: Value = serde_json::to_value(data).unwrap_or(Value::Null);

    let message = match &value {
        // Value::String(s) => format!("{prefix}: {s}"),
        Value::String(s) => {
            if s.contains('\n') {
                format!("{prefix}:\n```{}```", s)
            } else {
                format!("{prefix}: {s}")
            }
        },
        Value::Number(n) => format!("{prefix}: {n}"),
        Value::Bool(b) => format!("{prefix}: {b}"),
        _ => format!("{prefix}:\n```{}```", serde_json::to_string_pretty(&value).unwrap_or_else(|_| "serialization error".to_string())),
        // _ => format!("{prefix}:\n{}", serde_json::to_string_pretty(&value).unwrap_or_default()),
    };
    let payload = json!({ "text": message });

    send_async(payload).await;
}

// ---------- Block Logs with Thread Safety ----------

pub fn log_block(label: &str, fields: &[BlockField]) {
    spawn_block_log(label, fields, LogLevel::DEFAULT);
}

pub fn log_block_success(label: &str, fields: &[BlockField]) {
    spawn_block_log(label, fields, LogLevel::SUCCESS);
}
pub fn log_block_info(label: &str, fields: &[BlockField]) {
    spawn_block_log(label, fields, LogLevel::INFO);
}
pub fn log_block_warn(label: &str, fields: &[BlockField]) {
    spawn_block_log(label, fields, LogLevel::WARN);
}
pub fn log_block_error(label: &str, fields: &[BlockField]) {
    spawn_block_log(label, fields, LogLevel::ERROR);
}

fn spawn_block_log(label: &str, fields: &[BlockField], level: LogLevel) {
    let label = label.to_string();
    let fields = fields.to_vec();
    tokio::spawn(async move {
        log_block_message(&label, &fields, level).await;
    });
}

pub async fn log_block_message(label: &str, fields: &[BlockField], level: LogLevel) {
    let blocks = vec![
        json!({ "type": "divider" }),
        json!({
            "type": "header",
            "text": { "type": "plain_text", "text": label, "emoji": true }
        }),
        json!({ "type": "divider" }),
    ];

    let attachment_blocks: Vec<_> = fields
        .iter()
        .map(|field| {
            let raw = serde_json::to_string_pretty(&field.value).unwrap_or_else(|_| "serialization error".to_string());

            let formatted_value = if raw.contains('\n') {
                format!("```{}```", raw)
            } else {
                raw
            };

            json!({
                "type": "section",
                "text": {
                    "type": "mrkdwn",
                    "text": format!("*{}:* {}", field.title, formatted_value)
                }
            })
        })
        .collect();

    let payload = json!({
        "text": label,
        "blocks": blocks,
        "attachments": [{
            "color": level.color(),
            "blocks": attachment_blocks
        }]
    });

    send_async(payload).await;
}