rsnaker/game_logic/logger/
log_configuration.rs1use clap::ValueEnum;
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::Path;
5use std::sync::OnceLock;
6use time::format_description;
7use time::format_description::OwnedFormatItem;
8use tracing_appender::non_blocking;
9use tracing_subscriber::fmt::time::LocalTime;
10use tracing_subscriber::prelude::*;
11use tracing_subscriber::{fmt, reload, EnvFilter, Registry};
12
13pub const DEFAULT_LOG_CONFIG_PATH: &str = "snake_log_config.toml";
15
16#[derive(Debug, Copy, Clone, Deserialize, Serialize, ValueEnum, Default, PartialEq, Eq)]
17#[serde(rename_all = "lowercase")]
18pub enum LogLevel {
19 #[default]
20 Off,
21 Error,
22 Warn,
23 Info,
24 Debug,
25 Trace,
26}
27
28#[derive(Debug, Deserialize, Clone)]
34#[serde(default)]
35#[allow(clippy::struct_excessive_bools)]
36pub struct LogConfig {
37 pub level: LogLevel,
39 pub file_name: String,
41 pub time_format: String,
43 pub with_ansi: bool,
45 pub with_target: bool,
47 pub with_thread_names: bool,
49 pub with_thread_ids: bool,
51 pub with_line_number: bool,
53 pub with_file: bool,
55 pub with_level: bool,
57}
58
59impl Default for LogConfig {
60 fn default() -> Self {
61 Self {
62 level: LogLevel::Off,
63 file_name: "snake.log".to_string(),
64 time_format: "[hour]:[minute]:[second].[subsecond digits:6]".to_string(),
65 with_ansi: false,
66 with_target: false,
67 with_thread_names: true,
68 with_thread_ids: false,
69 with_line_number: true,
70 with_file: true,
71 with_level: true,
72 }
73 }
74}
75
76impl LogConfig {
77 #[must_use]
81 pub fn load_from_toml<P: AsRef<Path>>(path: P) -> Self {
82 match fs::read_to_string(path) {
83 Ok(contents) => toml::from_str(&contents).unwrap_or_else(|e| {
84 eprintln!("Failed to parse log configuration file: {e}, using defaults");
85 Self::default()
86 }),
87 Err(_) => Self::default(),
88 }
89 }
90}
91static RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
104
105pub fn init_logger<P: AsRef<Path>>(
118 config_path: Option<P>,
119) -> (non_blocking::WorkerGuard, LogConfig) {
120 let config = if let Some(conf) = config_path {
122 LogConfig::load_from_toml(conf)
123 } else {
124 LogConfig::default()
125 };
126
127 let file_appender = tracing_appender::rolling::never(".", &config.file_name);
128 let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
129 let filter = EnvFilter::new(format!(
132 "rsnaker={},sled=off",
133 log_level_to_str(config.level)
134 ));
135 let (filter_layer, reload_handle) = reload::Layer::new(filter);
136
137 let parsed_format: OwnedFormatItem = format_description::parse_owned::<2>(&config.time_format)
141 .unwrap_or_else(|_| {
142 eprintln!(
144 "Failed to parse time format string: {}, using default",
145 config.time_format
146 );
147 format_description::parse_owned::<2>(&LogConfig::default().time_format)
148 .expect("Default time format is valid")
149 });
150 let layer = fmt::layer()
151 .with_writer(non_blocking)
152 .with_ansi(config.with_ansi)
153 .with_target(config.with_target)
154 .with_thread_names(config.with_thread_names)
155 .with_thread_ids(config.with_thread_ids)
156 .with_line_number(config.with_line_number)
157 .with_file(config.with_file)
158 .with_level(config.with_level);
159
160 let subscriber = tracing_subscriber::registry().with(filter_layer);
161 if subscriber
162 .with(layer.with_timer(LocalTime::new(parsed_format)))
163 .try_init()
164 .is_err()
165 {
166 eprintln!("Failed to initialize logger: logger already initialized");
167 } else {
168 let _ = RELOAD_HANDLE.set(reload_handle);
169 }
170 (guard, config)
171}
172
173pub fn update_log_level(level: LogLevel) {
175 if let Some(handle) = RELOAD_HANDLE.get() {
176 let _ = handle.modify(|filter| {
177 if let Ok(new_filter) =
178 EnvFilter::try_new(format!("rsnaker={},sled=off", log_level_to_str(level)))
179 {
180 *filter = new_filter;
181 }
182 });
183 }
184}
185
186fn log_level_to_str(level: LogLevel) -> &'static str {
187 match level {
191 LogLevel::Off => "off",
193 LogLevel::Error => "error",
194 LogLevel::Warn => "warn",
195 LogLevel::Info => "info",
196 LogLevel::Debug => "debug",
197 LogLevel::Trace => "trace",
198 }
199}
200
201#[cfg(test)]
202mod tests {
203 use super::*;
204
205 #[test]
206 fn test_default_log_level() {
207 assert_eq!(LogLevel::default(), LogLevel::Off);
208 }
209
210 #[test]
211 fn test_log_config_defaults() {
212 let cfg = LogConfig::default();
213 assert_eq!(cfg.level, LogLevel::Off);
214 assert_eq!(cfg.file_name, "snake.log");
215 assert!(cfg.with_line_number);
216 assert!(cfg.with_file);
217 }
218
219 #[test]
220 fn test_log_config_deserialization() {
221 let path = "test_log_config_deser.toml";
222 let content = r#"
223level = "debug"
224file_name = "test.log"
225time_format = "[hour]:[minute]:[second]"
226with_ansi = true
227with_target = true
228with_thread_names = false
229with_thread_ids = true
230with_line_number = false
231with_file = false
232with_level = false
233"#;
234 fs::write(path, content).unwrap();
235 let cfg = LogConfig::load_from_toml(path);
236 assert_eq!(cfg.level, LogLevel::Debug);
237 assert_eq!(cfg.file_name, "test.log");
238 assert!(cfg.with_ansi);
239 assert!(!cfg.with_line_number);
240 let _ = fs::remove_file(path);
241 }
242
243 #[test]
244 fn test_log_config_missing_file_uses_defaults() {
245 let cfg = LogConfig::load_from_toml("non_existent_log_config_file.toml");
246 assert_eq!(cfg.level, LogLevel::Off);
247 assert_eq!(cfg.file_name, "snake.log");
248 }
249}