Skip to main content

rsnaker/game_logic/logger/
log_configuration.rs

1use 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
13/// Default path to the logging configuration file (separate from game options).
14pub 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/// Structure holding the logging configuration.
29///
30/// Every field is deserializable from a TOML file and has a sensible default
31/// so missing keys do not break loading (`#[serde(default)]`).
32/// Mirrors the boolean / time format options exposed by `tracing_subscriber::fmt::Layer`.
33#[derive(Debug, Deserialize, Clone)]
34#[serde(default)]
35#[allow(clippy::struct_excessive_bools)]
36pub struct LogConfig {
37    /// Logging level (off, error, warn, info, debug, trace)
38    pub level: LogLevel,
39    /// Name of the log file (written in current directory)
40    pub file_name: String,
41    /// Time format string (uses the `time` crate `format_description` syntax)
42    pub time_format: String,
43    /// Disable ANSI colors in the log file
44    pub with_ansi: bool,
45    /// Include the module path (target) in each log line
46    pub with_target: bool,
47    /// Include thread names in each log line
48    pub with_thread_names: bool,
49    /// Include thread ids in each log line
50    pub with_thread_ids: bool,
51    /// Include line numbers in each log line
52    pub with_line_number: bool,
53    /// Include source file name in each log line
54    pub with_file: bool,
55    /// Include the log level in each log line
56    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    /// Load logging configuration from a TOML file.
78    /// Returns the default configuration if the file cannot be opened or parsed.
79    /// Only deserialization is supported (no serialization back to the file).
80    #[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}
91/*
92[ App ]
9394▼ (by update_log_level)
95[ RELOAD_HANDLE ] ───(modify function)───► [ EnvFilter (e.g.: "info") ]
9697▼ (apply the filter)
98[ Log file ]
99Work thanks to modify function: pub fn modify(&self, f: impl FnOnce(&mut L)) -> Result<(), Error>
100Invokes a closure with a mutable reference to the current layer or filter, allowing it to be modified in place.
101So the RELOAD_HANDLE itself is only written once, that the EnvFilter, which is updated as a &mut param
102*/
103static RELOAD_HANDLE: OnceLock<reload::Handle<EnvFilter, Registry>> = OnceLock::new();
104
105/// Initializes the global logger.
106///
107/// Reads the logging configuration (level, file name, formatting booleans and
108/// time format) directly from a TOML file located at `config_path`. If the
109/// file does not exist or cannot be parsed, sensible defaults are used.
110///
111/// Writing a Lib? Only use the tracing crate and its macros (info!, span!). Do not initialize anything.
112/// Writing a Binary? Use tracing-subscriber to build the registry and handlers.
113/// Missing dependency logs? Check your `EnvFilter` string rules and ensure the "log" feature is enabled on tracing-subscriber to catch legacy logs
114/// Returns a `WorkerGuard` that must be kept alive to ensure logs are flushed to the file.
115/// If later wanna do rsyslog (libc): <https://docs.rs/syslog-tracing/0.3.1/syslog_tracing/struct.Syslog.html>
116/// Or the newcomer full rust: <https://crates.io/crates/tracing-rfc-5424>
117pub fn init_logger<P: AsRef<Path>>(
118    config_path: Option<P>,
119) -> (non_blocking::WorkerGuard, LogConfig) {
120    // Read the entire configuration from the TOML file (deserialization only).
121    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    //To only keep my logs, as sled logs are useless (and otherwise collected),
130    // Each dependency log level can be configured with the EnvFilter, e.g.: "sled=info,rsnaker=debug"
131    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    // Try to parse the user-supplied time format string at runtime; not as easy as static: LocalTime::new(format_description!("[hour]:[minute]:[second].[subsecond digits:6]" ));
138    //LocalTime instead of UTC, to have the local time
139    //The generic 2 is the version of this function from time crate (a bit strange but why not)
140    let parsed_format: OwnedFormatItem = format_description::parse_owned::<2>(&config.time_format)
141        .unwrap_or_else(|_| {
142            //std error because logger is not yet initialized at that time
143            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
173/// Updates the global log level dynamically.
174pub 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    //alt: use strum dependency, macro to get at compile time the code,
188    //but it is not really necessary here, as this is the only use case in the whole code
189    //// level.as_ref() could be auto-generated by strum with #[strum(serialize_all = "lowercase")], return &'static str
190    match level {
191        //See pub struct LevelFilter(Option<Level>); of tracing, defining the OFF level to disable logs
192        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}