use clap::ValueEnum;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::sync::OnceLock;
use time::format_description;
use time::format_description::OwnedFormatItem;
use tracing_appender::non_blocking;
use tracing_subscriber::fmt::time::LocalTime;
use tracing_subscriber::prelude::*;
use tracing_subscriber::{fmt, reload, EnvFilter, Registry};
pub const DEFAULT_LOG_CONFIG_PATH: &str = "snake_log_config.toml";
#[derive(Debug, Copy, Clone, Deserialize, Serialize, ValueEnum, Default, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LogLevel {
#[default]
Off,
Error,
Warn,
Info,
Debug,
Trace,
}
#[derive(Debug, Deserialize, Clone)]
#[serde(default)]
#[allow(clippy::struct_excessive_bools)]
pub struct LogConfig {
pub level: LogLevel,
pub file_name: String,
pub time_format: String,
pub with_ansi: bool,
pub with_target: bool,
pub with_thread_names: bool,
pub with_thread_ids: bool,
pub with_line_number: bool,
pub with_file: bool,
pub with_level: bool,
}
impl Default for LogConfig {
fn default() -> Self {
Self {
level: LogLevel::Off,
file_name: "snake.log".to_string(),
time_format: "[hour]:[minute]:[second].[subsecond digits:6]".to_string(),
with_ansi: false,
with_target: false,
with_thread_names: true,
with_thread_ids: false,
with_line_number: true,
with_file: true,
with_level: true,
}
}
}
impl LogConfig {
#[must_use]
pub fn load_from_toml<P: AsRef<Path>>(path: P) -> Self {
match fs::read_to_string(path) {
Ok(contents) => toml::from_str(&contents).unwrap_or_else(|e| {
eprintln!("Failed to parse log configuration file: {e}, using defaults");
Self::default()
}),
Err(_) => Self::default(),
}
}
}
static RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
pub fn init_logger<P: AsRef<Path>>(
config_path: Option<P>,
) -> (non_blocking::WorkerGuard, LogConfig) {
let config = if let Some(conf) = config_path {
LogConfig::load_from_toml(conf)
} else {
LogConfig::default()
};
let file_appender = tracing_appender::rolling::never(".", &config.file_name);
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
let filter = EnvFilter::new(format!(
"rsnaker={},sled=off",
log_level_to_str(config.level)
));
let (filter_layer, reload_handle) = reload::Layer::new(filter);
let parsed_format: OwnedFormatItem = format_description::parse_owned::<2>(&config.time_format)
.unwrap_or_else(|_| {
eprintln!(
"Failed to parse time format string: {}, using default",
config.time_format
);
format_description::parse_owned::<2>(&LogConfig::default().time_format)
.expect("Default time format is valid")
});
let layer = fmt::layer()
.with_writer(non_blocking)
.with_ansi(config.with_ansi)
.with_target(config.with_target)
.with_thread_names(config.with_thread_names)
.with_thread_ids(config.with_thread_ids)
.with_line_number(config.with_line_number)
.with_file(config.with_file)
.with_level(config.with_level);
let subscriber = tracing_subscriber::registry().with(filter_layer);
if subscriber
.with(layer.with_timer(LocalTime::new(parsed_format)))
.try_init()
.is_err()
{
eprintln!("Failed to initialize logger: logger already initialized");
} else {
let _ = RELOAD_HANDLE.set(reload_handle);
}
(guard, config)
}
pub fn update_log_level(level: LogLevel) {
if let Some(handle) = RELOAD_HANDLE.get() {
let _ = handle.modify(|filter| {
if let Ok(new_filter) =
EnvFilter::try_new(format!("rsnaker={},sled=off", log_level_to_str(level)))
{
*filter = new_filter;
}
});
}
}
fn log_level_to_str(level: LogLevel) -> &'static str {
match level {
LogLevel::Off => "off",
LogLevel::Error => "error",
LogLevel::Warn => "warn",
LogLevel::Info => "info",
LogLevel::Debug => "debug",
LogLevel::Trace => "trace",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_log_level() {
assert_eq!(LogLevel::default(), LogLevel::Off);
}
#[test]
fn test_log_config_defaults() {
let cfg = LogConfig::default();
assert_eq!(cfg.level, LogLevel::Off);
assert_eq!(cfg.file_name, "snake.log");
assert!(cfg.with_line_number);
assert!(cfg.with_file);
}
#[test]
fn test_log_config_deserialization() {
let path = "test_log_config_deser.toml";
let content = r#"
level = "debug"
file_name = "test.log"
time_format = "[hour]:[minute]:[second]"
with_ansi = true
with_target = true
with_thread_names = false
with_thread_ids = true
with_line_number = false
with_file = false
with_level = false
"#;
fs::write(path, content).unwrap();
let cfg = LogConfig::load_from_toml(path);
assert_eq!(cfg.level, LogLevel::Debug);
assert_eq!(cfg.file_name, "test.log");
assert!(cfg.with_ansi);
assert!(!cfg.with_line_number);
let _ = fs::remove_file(path);
}
#[test]
fn test_log_config_missing_file_uses_defaults() {
let cfg = LogConfig::load_from_toml("non_existent_log_config_file.toml");
assert_eq!(cfg.level, LogLevel::Off);
assert_eq!(cfg.file_name, "snake.log");
}
}