use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::fmt;
use std::io::{self, Write};
use std::sync::Arc;
use tokio::sync::mpsc;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LogLevel::Trace => write!(f, "TRACE"),
LogLevel::Debug => write!(f, "DEBUG"),
LogLevel::Info => write!(f, "INFO"),
LogLevel::Warn => write!(f, "WARN"),
LogLevel::Error => write!(f, "ERROR"),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LogEntry {
pub timestamp: String,
pub level: LogLevel,
pub target: String,
pub message: String,
#[serde(skip_serializing_if = "BTreeMap::is_empty")]
pub fields: BTreeMap<String, serde_json::Value>,
}
impl LogEntry {
pub fn new(level: LogLevel, target: impl Into<String>, message: impl Into<String>) -> Self {
Self {
timestamp: chrono::Utc::now().to_rfc3339(),
level,
target: target.into(),
message: message.into(),
fields: BTreeMap::new(),
}
}
pub fn with_field(
mut self,
key: impl Into<String>,
value: impl Into<serde_json::Value>,
) -> Self {
self.fields.insert(key.into(), value.into());
self
}
pub fn with_fields(mut self, fields: Vec<(String, serde_json::Value)>) -> Self {
for (key, value) in fields {
self.fields.insert(key, value);
}
self
}
}
#[derive(Debug, Clone)]
pub struct LoggerConfig {
pub min_level: LogLevel,
pub format: LogFormat,
pub colored: bool,
pub max_file_size: u64,
pub max_files: u32,
}
impl Default for LoggerConfig {
fn default() -> Self {
Self {
min_level: LogLevel::Info,
format: LogFormat::Json,
colored: true,
max_file_size: 10 * 1024 * 1024, max_files: 5,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogFormat {
Json,
Text,
}
pub struct StructuredLogger {
config: LoggerConfig,
tx: mpsc::Sender<LogEntry>,
handle: Option<tokio::task::JoinHandle<()>>,
}
impl StructuredLogger {
pub fn new(config: LoggerConfig) -> Self {
let (tx, mut rx) = mpsc::channel::<LogEntry>(1000);
let handle = tokio::spawn(async move {
let stdout = io::stdout();
while let Some(entry) = rx.recv().await {
let mut handle = stdout.lock();
let _ = write_log_entry(&mut handle, &entry, config.format, config.colored);
let _ = writeln!(handle);
}
});
Self {
config,
tx,
handle: Some(handle),
}
}
pub fn log(
&self,
level: LogLevel,
target: &str,
message: &str,
fields: Vec<(String, serde_json::Value)>,
) {
if level >= self.config.min_level {
let entry = LogEntry::new(level, target, message).with_fields(fields);
let _ = self.tx.try_send(entry);
}
}
pub fn trace(&self, target: &str, message: &str, fields: Vec<(String, serde_json::Value)>) {
self.log(LogLevel::Trace, target, message, fields);
}
pub fn debug(&self, target: &str, message: &str, fields: Vec<(String, serde_json::Value)>) {
self.log(LogLevel::Debug, target, message, fields);
}
pub fn info(&self, target: &str, message: &str, fields: Vec<(String, serde_json::Value)>) {
self.log(LogLevel::Info, target, message, fields);
}
pub fn warn(&self, target: &str, message: &str, fields: Vec<(String, serde_json::Value)>) {
self.log(LogLevel::Warn, target, message, fields);
}
pub fn error(&self, target: &str, message: &str, fields: Vec<(String, serde_json::Value)>) {
self.log(LogLevel::Error, target, message, fields);
}
pub async fn flush(&self) {
}
pub async fn shutdown(self) {
drop(self.tx);
if let Some(handle) = self.handle {
let _ = handle.await;
}
}
}
fn write_log_entry<W: Write>(
writer: &mut W,
entry: &LogEntry,
format: LogFormat,
colored: bool,
) -> io::Result<()> {
match format {
LogFormat::Json => {
let json = serde_json::to_string(entry)?;
if colored {
let color = get_level_color(entry.level);
write!(writer, "{}", color)?;
write!(writer, "{}", json)?;
write!(writer, "\x1b[0m")?;
} else {
write!(writer, "{}", json)?;
}
}
LogFormat::Text => {
if colored {
let color = get_level_color(entry.level);
write!(writer, "{}{}", color, entry.timestamp)?;
write!(writer, " {:<5}", entry.level)?;
write!(writer, "\x1b[0m")?;
write!(writer, " [{}]", entry.target)?;
write!(writer, " {}", entry.message)?;
if !entry.fields.is_empty() {
write!(writer, " {{")?;
for (i, (k, v)) in entry.fields.iter().enumerate() {
if i > 0 {
write!(writer, ", ")?;
}
write!(writer, "{}={}", k, v)?;
}
write!(writer, "}}")?;
}
} else {
write!(
writer,
"{} {:<5} [{}] {}",
entry.timestamp, entry.level, entry.target, entry.message
)?;
if !entry.fields.is_empty() {
write!(writer, " {{")?;
for (i, (k, v)) in entry.fields.iter().enumerate() {
if i > 0 {
write!(writer, ", ")?;
}
write!(writer, "{}={}", k, v)?;
}
write!(writer, "}}")?;
}
}
}
}
Ok(())
}
fn get_level_color(level: LogLevel) -> &'static str {
match level {
LogLevel::Trace => "\x1b[90m", LogLevel::Debug => "\x1b[36m", LogLevel::Info => "\x1b[32m", LogLevel::Warn => "\x1b[33m", LogLevel::Error => "\x1b[31m", }
}
static GLOBAL_LOGGER: once_cell::sync::OnceCell<Arc<StructuredLogger>> =
once_cell::sync::OnceCell::new();
pub fn init_global_logger(config: LoggerConfig) -> Result<(), LoggerError> {
let logger = Arc::new(StructuredLogger::new(config));
GLOBAL_LOGGER
.set(logger)
.map_err(|_| LoggerError::AlreadyInitialized)?;
Ok(())
}
pub fn get_global_logger() -> Option<Arc<StructuredLogger>> {
GLOBAL_LOGGER.get().cloned()
}
#[derive(Debug, thiserror::Error)]
pub enum LoggerError {
#[error("Logger already initialized")]
AlreadyInitialized,
}
#[macro_export]
macro_rules! log_info {
($target:expr, $msg:expr $(, $key:expr => $val:expr)* $(,)?) => {{
use $crate::logging::{get_global_logger, LogLevel};
if let Some(logger) = get_global_logger() {
let fields = vec![$(($key, $val.into())),*];
logger.log(LogLevel::Info, $target, $msg, fields);
}
}};
}
#[macro_export]
macro_rules! log_error {
($target:expr, $msg:expr $(, $key:expr => $val:expr)* $(,)?) => {{
use $crate::logging::{get_global_logger, LogLevel};
if let Some(logger) = get_global_logger() {
let fields = vec![$(($key, $val.into())),*];
logger.log(LogLevel::Error, $target, $msg, fields);
}
}};
}
#[macro_export]
macro_rules! log_debug {
($target:expr, $msg:expr $(, $key:expr => $val:expr)* $(,)?) => {{
use $crate::logging::{get_global_logger, LogLevel};
if let Some(logger) = get_global_logger() {
let fields = vec![$(($key, $val.into())),*];
logger.log(LogLevel::Debug, $target, $msg, fields);
}
}};
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_log_entry_creation() {
let entry = LogEntry::new(LogLevel::Info, "test", "Test message")
.with_field("user_id", "123")
.with_field("action", "login");
assert_eq!(entry.level, LogLevel::Info);
assert_eq!(entry.target, "test");
assert_eq!(entry.message, "Test message");
assert_eq!(entry.fields.len(), 2);
}
#[test]
fn test_log_level_display() {
assert_eq!(format!("{}", LogLevel::Info), "INFO");
assert_eq!(format!("{}", LogLevel::Error), "ERROR");
}
#[test]
fn test_log_entry_serialization() {
let entry = LogEntry::new(LogLevel::Warn, "auth", "Failed login attempt")
.with_field("user", "admin")
.with_field("ip", "192.168.1.1");
let json = serde_json::to_string(&entry).unwrap();
assert!(json.contains("\"level\":\"warn\""));
assert!(json.contains("\"target\":\"auth\""));
assert!(json.contains("\"message\":\"Failed login attempt\""));
}
#[tokio::test]
async fn test_structured_logger() {
let config = LoggerConfig {
min_level: LogLevel::Debug,
format: LogFormat::Json,
colored: false,
..Default::default()
};
let logger = StructuredLogger::new(config);
logger.info(
"app",
"Application started",
vec![
(
"version".to_string(),
serde_json::Value::String("0.1.0".to_string()),
),
(
"env".to_string(),
serde_json::Value::String("test".to_string()),
),
],
);
logger.error(
"db",
"Connection failed",
vec![
(
"host".to_string(),
serde_json::Value::String("localhost".to_string()),
),
("port".to_string(), serde_json::Value::Number(5432.into())),
],
);
logger.flush().await;
logger.shutdown().await;
}
#[test]
fn test_logger_config_default_values() {
let config = LoggerConfig::default();
assert_eq!(config.min_level, LogLevel::Info);
assert!(matches!(config.format, LogFormat::Json));
assert!(config.colored);
assert_eq!(config.max_file_size, 10 * 1024 * 1024);
assert_eq!(config.max_files, 5);
}
#[test]
fn test_write_log_entry_text_colored() {
let entry = LogEntry::new(LogLevel::Error, "test", "Error msg");
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("ERROR"));
assert!(output.contains("Error msg"));
assert!(output.contains("test"));
assert!(output.contains("\x1b["));
}
#[test]
fn test_write_log_entry_text_uncolored() {
let entry = LogEntry::new(LogLevel::Info, "app", "Hello");
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, false).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("INFO"));
assert!(output.contains("Hello"));
assert!(!output.contains("\x1b["));
}
#[test]
fn test_write_log_entry_text_uncolored_with_fields() {
let entry = LogEntry::new(LogLevel::Warn, "auth", "Failed login")
.with_field("ip", serde_json::json!("10.0.0.1"));
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, false).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("WARN"), "Should contain WARN level");
assert!(output.contains("ip"), "Should contain field key");
assert!(output.contains("10.0.0.1"), "Should contain field value");
}
#[test]
fn test_write_log_entry_json_uncolored() {
let entry =
LogEntry::new(LogLevel::Debug, "api", "Request received").with_field("path", "/users");
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Json, false).unwrap();
let output = String::from_utf8_lossy(&buf);
let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert_eq!(parsed["level"], "debug");
assert_eq!(parsed["target"], "api");
assert_eq!(parsed["message"], "Request received");
assert!(!output.contains("\x1b["));
}
#[test]
fn test_write_log_entry_json_colored() {
let entry = LogEntry::new(LogLevel::Trace, "db", "Query executed");
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Json, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b["));
assert!(output.contains("trace"));
assert!(output.contains("db"));
}
#[test]
fn test_level_color_trace() {
let mut buf = Vec::new();
let entry = LogEntry::new(LogLevel::Trace, "t", "m");
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[90m"));
}
#[test]
fn test_level_color_debug() {
let mut buf = Vec::new();
let entry = LogEntry::new(LogLevel::Debug, "t", "m");
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[36m"));
}
#[test]
fn test_level_color_info() {
let mut buf = Vec::new();
let entry = LogEntry::new(LogLevel::Info, "t", "m");
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[32m"));
}
#[test]
fn test_level_color_warn() {
let mut buf = Vec::new();
let entry = LogEntry::new(LogLevel::Warn, "t", "m");
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[33m"));
}
#[test]
fn test_level_color_error() {
let mut buf = Vec::new();
let entry = LogEntry::new(LogLevel::Error, "t", "m");
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[31m"));
}
#[test]
fn test_log_entry_with_fields_multiple() {
let entry = LogEntry::new(LogLevel::Info, "test", "msg").with_fields(vec![
("a".to_string(), serde_json::json!(1)),
("b".to_string(), serde_json::json!("two")),
("c".to_string(), serde_json::json!(true)),
]);
assert_eq!(entry.fields.len(), 3);
assert_eq!(entry.fields.get("a"), Some(&serde_json::json!(1)));
}
#[test]
fn test_log_entry_with_fields_empty() {
let entry = LogEntry::new(LogLevel::Info, "test", "msg").with_fields(vec![]);
assert_eq!(entry.fields.len(), 0);
}
#[test]
fn test_log_entry_with_fields_overwrites() {
let entry = LogEntry::new(LogLevel::Info, "test", "msg")
.with_field("key", "first")
.with_field("key", "second");
assert_eq!(entry.fields.len(), 1);
assert_eq!(entry.fields.get("key"), Some(&serde_json::json!("second")));
}
#[tokio::test]
async fn test_structured_logger_trace_filtered_out() {
let config = LoggerConfig {
min_level: LogLevel::Info,
format: LogFormat::Json,
colored: false,
..Default::default()
};
let logger = StructuredLogger::new(config);
logger.trace("test", "should be filtered", vec![]);
logger.flush().await;
logger.shutdown().await;
}
#[tokio::test]
async fn test_structured_logger_debug_emitted() {
let config = LoggerConfig {
min_level: LogLevel::Debug,
format: LogFormat::Json,
colored: false,
..Default::default()
};
let logger = StructuredLogger::new(config);
logger.debug("test", "debug msg", vec![]);
logger.flush().await;
logger.shutdown().await;
}
#[tokio::test]
async fn test_structured_logger_warn_emitted() {
let config = LoggerConfig {
min_level: LogLevel::Warn,
format: LogFormat::Json,
colored: false,
..Default::default()
};
let logger = StructuredLogger::new(config);
logger.warn("test", "warn msg", vec![]);
logger.info("test", "should be filtered", vec![]);
logger.flush().await;
logger.shutdown().await;
}
#[test]
fn test_log_level_ordering() {
assert!(LogLevel::Trace < LogLevel::Debug);
assert!(LogLevel::Debug < LogLevel::Info);
assert!(LogLevel::Info < LogLevel::Warn);
assert!(LogLevel::Warn < LogLevel::Error);
}
#[test]
fn test_log_level_ordering_chained() {
assert!(LogLevel::Trace < LogLevel::Info);
assert!(LogLevel::Debug < LogLevel::Error);
}
#[test]
fn test_log_level_equality() {
assert_eq!(LogLevel::Info, LogLevel::Info);
assert!(LogLevel::Info >= LogLevel::Info);
assert!(LogLevel::Info <= LogLevel::Info);
}
#[test]
fn test_log_level_serde_roundtrip() {
let levels = [
LogLevel::Trace,
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
];
for level in levels {
let json = serde_json::to_string(&level).unwrap();
let restored: LogLevel = serde_json::from_str(&json).unwrap();
assert_eq!(level, restored);
}
}
#[test]
fn test_log_level_serde_lowercase() {
let json = serde_json::to_string(&LogLevel::Info).unwrap();
assert!(json.contains("info"));
}
#[test]
fn test_log_level_deserialize_lowercase() {
let level: LogLevel = serde_json::from_str("\"warn\"").unwrap();
assert_eq!(level, LogLevel::Warn);
}
#[test]
fn test_log_level_display_all() {
assert_eq!(format!("{}", LogLevel::Trace), "TRACE");
assert_eq!(format!("{}", LogLevel::Debug), "DEBUG");
assert_eq!(format!("{}", LogLevel::Info), "INFO");
assert_eq!(format!("{}", LogLevel::Warn), "WARN");
assert_eq!(format!("{}", LogLevel::Error), "ERROR");
}
#[test]
fn test_logger_error_display() {
let err = LoggerError::AlreadyInitialized;
assert!(err.to_string().contains("already initialized"));
}
#[test]
fn test_logger_error_debug() {
let err = LoggerError::AlreadyInitialized;
let debug_str = format!("{:?}", err);
assert!(debug_str.contains("AlreadyInitialized"));
}
#[test]
fn test_write_log_entry_text_colored_with_fields() {
let entry = LogEntry::new(LogLevel::Warn, "auth", "Failed login attempt")
.with_field("user", serde_json::json!("admin"))
.with_field("ip", serde_json::json!("10.0.0.1"));
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("\x1b[33m"),
"Should contain warn color code"
);
assert!(output.contains("\x1b[0m"), "Should contain reset code");
assert!(output.contains("user"), "Should contain field key 'user'");
assert!(
output.contains("admin"),
"Should contain field value 'admin'"
);
assert!(output.contains("ip"), "Should contain field key 'ip'");
assert!(output.contains("10.0.0.1"), "Should contain field value");
assert!(
output.contains("{"),
"Should contain opening brace for fields"
);
assert!(
output.contains("}"),
"Should contain closing brace for fields"
);
}
#[test]
fn test_write_log_entry_text_colored_with_multiple_fields() {
let entry = LogEntry::new(LogLevel::Error, "db", "Query failed")
.with_field("query", serde_json::json!("SELECT *"))
.with_field("duration_ms", serde_json::json!(150))
.with_field("table", serde_json::json!("users"));
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(
output.contains("\x1b[31m"),
"Should contain error color code"
);
assert!(output.contains(", "), "Should contain field separator");
assert!(output.contains("query"));
assert!(output.contains("duration_ms"));
assert!(output.contains("table"));
}
#[tokio::test]
#[serial_test::serial]
async fn test_global_logger_init_and_get() {
let config = LoggerConfig::default();
let init_result = init_global_logger(config);
match init_result {
Ok(()) => {
assert!(
get_global_logger().is_some(),
"get_global_logger should return Some after successful init"
);
}
Err(LoggerError::AlreadyInitialized) => {
assert!(
get_global_logger().is_some(),
"get_global_logger should return Some when already initialized"
);
}
}
}
#[tokio::test]
#[serial_test::serial]
async fn test_global_logger_double_init_fails() {
let config = LoggerConfig::default();
let _first = init_global_logger(config.clone());
let second = init_global_logger(config);
assert!(
matches!(second, Err(LoggerError::AlreadyInitialized)),
"Second init_global_logger call should fail with AlreadyInitialized"
);
}
#[tokio::test]
#[serial_test::serial]
async fn test_get_global_logger_returns_some_after_init() {
let _ = init_global_logger(LoggerConfig::default());
let logger = get_global_logger();
assert!(
logger.is_some(),
"get_global_logger should return Some after initialization"
);
}
#[test]
fn test_write_log_entry_text_uncolored_with_multiple_fields() {
let entry = LogEntry::new(LogLevel::Info, "app", "multi fields")
.with_field("a", serde_json::json!(1))
.with_field("b", serde_json::json!("two"))
.with_field("c", serde_json::json!(true));
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, false).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("INFO"), "Should contain level");
assert!(output.contains("app"), "Should contain target");
assert!(output.contains("multi fields"), "Should contain message");
assert!(output.contains(", "), "Should contain field separator");
assert!(output.contains("a=1"), "Should contain field a");
assert!(output.contains("b=\"two\""), "Should contain field b");
assert!(output.contains("c=true"), "Should contain field c");
assert!(!output.contains("\x1b["), "Should not contain ANSI codes");
}
#[test]
fn test_write_log_entry_text_colored_without_fields() {
let entry = LogEntry::new(LogLevel::Error, "db", "no fields here");
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Text, true).unwrap();
let output = String::from_utf8_lossy(&buf);
assert!(output.contains("\x1b[31m"), "Should contain error color");
assert!(output.contains("no fields here"), "Should contain message");
assert!(
!output.contains("{") || output.contains("\x1b[0m"),
"Should not emit fields block when empty"
);
}
#[test]
fn test_write_log_entry_json_uncolored_with_multiple_fields() {
let entry = LogEntry::new(LogLevel::Warn, "api", "request")
.with_field("method", serde_json::json!("GET"))
.with_field("status", serde_json::json!(200))
.with_field("duration_ms", serde_json::json!(42));
let mut buf = Vec::new();
write_log_entry(&mut buf, &entry, LogFormat::Json, false).unwrap();
let output = String::from_utf8_lossy(&buf);
let parsed: serde_json::Value = serde_json::from_str(output.trim()).unwrap();
assert_eq!(parsed["fields"]["method"], "GET");
assert_eq!(parsed["fields"]["status"], 200);
assert_eq!(parsed["fields"]["duration_ms"], 42);
}
#[test]
fn test_log_entry_with_field_various_value_types() {
let entry = LogEntry::new(LogLevel::Debug, "test", "msg")
.with_field("null_val", serde_json::Value::Null)
.with_field("bool_val", serde_json::json!(true))
.with_field("num_val", serde_json::json!(3.15))
.with_field("arr_val", serde_json::json!([1, 2, 3]))
.with_field("obj_val", serde_json::json!({"nested": "value"}));
assert_eq!(entry.fields.len(), 5);
assert_eq!(entry.fields.get("null_val"), Some(&serde_json::Value::Null));
assert_eq!(
entry.fields.get("arr_val"),
Some(&serde_json::json!([1, 2, 3]))
);
}
#[test]
fn test_logger_config_clone_independent() {
let config = LoggerConfig {
min_level: LogLevel::Debug,
format: LogFormat::Text,
colored: false,
max_file_size: 5 * 1024 * 1024,
max_files: 3,
};
let cloned = config.clone();
assert_eq!(cloned.min_level, config.min_level);
assert_eq!(cloned.format, config.format);
assert_eq!(cloned.colored, config.colored);
assert_eq!(cloned.max_file_size, config.max_file_size);
assert_eq!(cloned.max_files, config.max_files);
}
#[test]
fn test_log_format_equality() {
assert_eq!(LogFormat::Json, LogFormat::Json);
assert_eq!(LogFormat::Text, LogFormat::Text);
assert_ne!(LogFormat::Json, LogFormat::Text);
}
}