muse2/
log.rs

1//! The `log` module provides initialisation and configuration of the application's logging system.
2//!
3//! This module sets up logging with various levels (error, warn, info, debug, trace) and optional
4//! colourisation based on terminal support. It also allows configuration of the log level through
5//! environment variables.
6use anyhow::{Result, bail};
7use chrono::Local;
8use fern::colors::{Color, ColoredLevelConfig};
9use fern::{Dispatch, FormatCallback};
10use log::{LevelFilter, Record};
11use std::env;
12use std::fmt::{Arguments, Display};
13use std::fs::OpenOptions;
14use std::io::IsTerminal;
15use std::path::Path;
16use std::sync::OnceLock;
17
18/// A flag indicating whether the logger has been initialised
19static LOGGER_INIT: OnceLock<()> = OnceLock::new();
20
21/// The default log level for the program.
22///
23/// Used as a fallback if the user hasn't specified something else with the MUSE2_LOG_LEVEL
24/// environment variable or the settings.toml file.
25const DEFAULT_LOG_LEVEL: &str = "info";
26
27/// The file name for the log file containing messages about the ordinary operation of MUSE 2.0
28const LOG_INFO_FILE_NAME: &str = "muse2_info.log";
29
30/// The file name for the log file containing warnings and error messages
31const LOG_ERROR_FILE_NAME: &str = "muse2_error.log";
32
33/// Whether the program logger has been initialised
34pub fn is_logger_initialised() -> bool {
35    LOGGER_INIT.get().is_some()
36}
37
38/// Initialise the program logger using the `fern` logging library with colourised output.
39///
40/// The user can specify their preferred logging level via the `settings.toml` file (defaulting to
41/// `info` if not present) or with the `MUSE2_LOG_LEVEL` environment variable. If both are provided,
42/// the environment variable takes precedence.
43///
44/// Possible log level options are:
45///
46/// * `error`
47/// * `warn`
48/// * `info`
49/// * `debug`
50/// * `trace`
51///
52/// # Arguments
53///
54/// * `log_level_from_settings`: The log level specified in `settings.toml`
55/// * `output_path`: The output path for the simulation
56pub fn init(log_level_from_settings: Option<&str>, output_path: &Path) -> Result<()> {
57    // Retrieve the log level from the environment variable or settings, or use the default
58    let log_level = env::var("MUSE2_LOG_LEVEL").unwrap_or_else(|_| {
59        log_level_from_settings
60            .unwrap_or(DEFAULT_LOG_LEVEL)
61            .to_string()
62    });
63
64    // Convert the log level string to a log::LevelFilter
65    let log_level = match log_level.to_lowercase().as_str() {
66        "off" => LevelFilter::Off,
67        "error" => LevelFilter::Error,
68        "warn" => LevelFilter::Warn,
69        "info" => LevelFilter::Info,
70        "debug" => LevelFilter::Debug,
71        "trace" => LevelFilter::Trace,
72        unknown => bail!("Unknown log level: {}", unknown),
73    };
74
75    // Set up colours for log levels
76    let colours = ColoredLevelConfig::new()
77        .error(Color::Red)
78        .warn(Color::Yellow)
79        .info(Color::Green)
80        .debug(Color::Blue)
81        .trace(Color::Magenta);
82
83    // Automatically apply colours only if the output is a terminal
84    let use_colour_stdout = std::io::stdout().is_terminal();
85    let use_colour_stderr = std::io::stderr().is_terminal();
86
87    // Create log files
88    let new_log_file = |file_name| {
89        OpenOptions::new()
90            .write(true)
91            .create(true)
92            .truncate(true)
93            .open(output_path.join(file_name))
94    };
95    let info_log_file = new_log_file(LOG_INFO_FILE_NAME)?;
96    let err_log_file = new_log_file(LOG_ERROR_FILE_NAME)?;
97
98    // Configure the logger
99    let dispatch = Dispatch::new()
100        .chain(
101            // Write non-error messages to stdout
102            Dispatch::new()
103                .filter(|metadata| metadata.level() > LevelFilter::Warn)
104                .format(move |out, message, record| {
105                    write_log_colour(out, message, record, use_colour_stdout, &colours);
106                })
107                .level(log_level)
108                .chain(std::io::stdout()),
109        )
110        .chain(
111            // Write error messages to stderr
112            Dispatch::new()
113                .format(move |out, message, record| {
114                    write_log_colour(out, message, record, use_colour_stderr, &colours);
115                })
116                .level(log_level.min(LevelFilter::Warn))
117                .chain(std::io::stderr()),
118        )
119        .chain(
120            // Write non-error messages to log file
121            Dispatch::new()
122                .filter(|metadata| metadata.level() > LevelFilter::Warn)
123                .format(write_log_plain)
124                .level(log_level.max(LevelFilter::Info))
125                .chain(info_log_file),
126        )
127        .chain(
128            // Write error messages to a different log file
129            Dispatch::new()
130                .format(write_log_plain)
131                .level(LevelFilter::Warn)
132                .chain(err_log_file),
133        );
134
135    // Apply the logger configuration
136    dispatch.apply().expect("Logger already initialised");
137
138    // Set a flag to indicate that the logger has been initialised
139    LOGGER_INIT.set(()).unwrap();
140
141    Ok(())
142}
143
144/// Write to the log in the format we want for MUSE 2.0
145fn write_log<T: Display>(out: FormatCallback, level: T, target: &str, message: &Arguments) {
146    let timestamp = Local::now().format("%H:%M:%S");
147
148    out.finish(format_args!("[{timestamp} {level} {target}] {message}"));
149}
150
151/// Write to the log with no colours
152fn write_log_plain(out: FormatCallback, message: &Arguments, record: &Record) {
153    write_log(out, record.level(), record.target(), message);
154}
155
156/// Write to the log with optional colours
157fn write_log_colour(
158    out: FormatCallback,
159    message: &Arguments,
160    record: &Record,
161    use_colour: bool,
162    colours: &ColoredLevelConfig,
163) {
164    // Format output with or without colour based on `use_colour`
165    if use_colour {
166        write_log(out, colours.color(record.level()), record.target(), message);
167    } else {
168        write_log_plain(out, message, record);
169    }
170}