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