use log::{LevelFilter, Log, Metadata, Record};
use std::io::{self, Write};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, Instant};
static LOGGER: ProgressLogger = ProgressLogger;
static LOG_START: OnceLock<Instant> = OnceLock::new();
static LOG_WRITE_LOCK: Mutex<()> = Mutex::new(());
struct ProgressLogger;
impl Log for ProgressLogger {
fn enabled(&self, metadata: &Metadata<'_>) -> bool {
metadata.level() <= log::max_level()
}
fn log(&self, record: &Record<'_>) {
if !self.enabled(record.metadata()) {
return;
}
let lines = format_log_record(record);
let log_lock_guard = LOG_WRITE_LOCK.lock().unwrap_or_else(|p| p.into_inner());
let mut stderr = io::stderr().lock();
for line in lines {
writeln!(stderr, "{line}").ok();
}
drop(log_lock_guard);
}
fn flush(&self) {}
}
fn format_log_record(record: &Record<'_>) -> Vec<String> {
let elapsed = LOG_START.get_or_init(Instant::now).elapsed();
let prefix = format!("[{}]", human_elapsed(elapsed));
sanitize_log_message(&record.args().to_string())
.lines()
.map(|line| format!("{prefix} {line}"))
.collect()
}
fn sanitize_log_message(message: &str) -> String {
let mut sanitized = String::with_capacity(message.len());
let mut chars = message.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\x1b' => {
strip_escape_sequence(&mut chars);
}
'\r' => sanitized.push('\n'),
'\n' | '\t' => sanitized.push(ch),
ch if ch.is_control() => {}
ch => sanitized.push(ch),
}
}
sanitized
}
fn strip_escape_sequence<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
match chars.next() {
Some('[') => {
for seq_ch in chars.by_ref() {
if ('@'..='~').contains(&seq_ch) {
break;
}
}
}
Some(']') => strip_string_escape(chars),
Some('P' | 'X' | '^' | '_') => strip_string_escape(chars),
Some(_) | None => {}
}
}
fn strip_string_escape<I>(chars: &mut std::iter::Peekable<I>)
where
I: Iterator<Item = char>,
{
while let Some(seq_ch) = chars.next() {
if seq_ch == '\x07' {
break;
}
if seq_ch == '\x1b' && chars.next_if_eq(&'\\').is_some() {
break;
}
}
}
fn human_elapsed(elapsed: Duration) -> String {
let total_secs = elapsed.as_secs();
let hours = total_secs / 3600;
let minutes = (total_secs / 60) % 60;
let seconds = total_secs % 60;
if hours > 0 {
format!("{hours}h {minutes:02}m {seconds:02}s")
} else if minutes > 0 {
format!("{minutes}m {seconds:02}s")
} else {
format!("{seconds}s")
}
}
const DEFAULT_LOG_LEVEL: LevelFilter = LevelFilter::Warn;
fn resolve_log_level() -> LevelFilter {
log_level_from_overrides(None, None)
}
fn parse_log_level(value: &str) -> Option<LevelFilter> {
match value.trim().to_ascii_lowercase().as_str() {
"off" | "none" | "silent" => Some(LevelFilter::Off),
"error" => Some(LevelFilter::Error),
"warn" | "warning" => Some(LevelFilter::Warn),
"info" => Some(LevelFilter::Info),
"debug" => Some(LevelFilter::Debug),
"trace" | "all" => Some(LevelFilter::Trace),
_ => None,
}
}
fn log_level_from_overrides(primary: Option<&str>, fallback: Option<&str>) -> LevelFilter {
primary
.and_then(parse_log_level)
.or_else(|| fallback.and_then(parse_log_level))
.unwrap_or(DEFAULT_LOG_LEVEL)
}
pub fn parse_level_directive(raw: &str) -> Option<LevelFilter> {
parse_log_level(raw)
}
pub const fn default_log_level() -> LevelFilter {
DEFAULT_LOG_LEVEL
}
pub fn set_log_level(spelling: &str) -> LevelFilter {
let level = log_level_from_overrides(Some(spelling), None);
log::set_max_level(level);
level
}
pub fn init_logging() {
init_logging_at(resolve_log_level());
}
pub fn init_logging_at(level: LevelFilter) {
LOG_START.get_or_init(Instant::now);
if log::set_logger(&LOGGER).is_err() {
}
log::set_max_level(level);
gam_gpu::log_backend_inventory_once();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_level_is_warn_when_no_overrides() {
assert_eq!(log_level_from_overrides(None, None), LevelFilter::Warn);
assert_eq!(DEFAULT_LOG_LEVEL, LevelFilter::Warn);
}
#[test]
fn primary_override_takes_precedence_over_fallback() {
assert_eq!(
log_level_from_overrides(Some("info"), Some("trace")),
LevelFilter::Info
);
assert_eq!(
log_level_from_overrides(Some("off"), Some("debug")),
LevelFilter::Off
);
}
#[test]
fn fallback_used_when_primary_absent_or_unrecognized() {
assert_eq!(
log_level_from_overrides(None, Some("debug")),
LevelFilter::Debug
);
assert_eq!(
log_level_from_overrides(Some("loud"), Some("trace")),
LevelFilter::Trace
);
}
#[test]
fn set_log_level_installs_explicit_level_and_defaults_on_typo() {
assert_eq!(set_log_level("debug"), LevelFilter::Debug);
assert_eq!(log::max_level(), LevelFilter::Debug);
assert_eq!(set_log_level("verbose"), DEFAULT_LOG_LEVEL);
assert_eq!(log::max_level(), DEFAULT_LOG_LEVEL);
log::set_max_level(DEFAULT_LOG_LEVEL);
}
#[test]
fn unrecognized_values_fall_back_to_default_not_off() {
assert_eq!(
log_level_from_overrides(Some("verbose"), None),
DEFAULT_LOG_LEVEL
);
assert_eq!(
log_level_from_overrides(Some("yes"), Some("loud")),
DEFAULT_LOG_LEVEL
);
}
#[test]
fn parse_level_directive_matches_internal_parser() {
for spelling in ["off", "error", "warn", "info", "debug", "trace", "", "garbage"] {
assert_eq!(parse_level_directive(spelling), parse_log_level(spelling));
}
}
#[test]
fn default_log_level_accessor_matches_const() {
assert_eq!(default_log_level(), DEFAULT_LOG_LEVEL);
assert_eq!(default_log_level(), LevelFilter::Warn);
}
#[test]
fn parsing_is_case_and_whitespace_insensitive() {
assert_eq!(parse_log_level(" INFO "), Some(LevelFilter::Info));
assert_eq!(parse_log_level("Warn"), Some(LevelFilter::Warn));
assert_eq!(parse_log_level("TRACE"), Some(LevelFilter::Trace));
assert_eq!(parse_log_level("off"), Some(LevelFilter::Off));
assert_eq!(parse_log_level("warning"), Some(LevelFilter::Warn));
assert_eq!(parse_log_level("silent"), Some(LevelFilter::Off));
assert_eq!(parse_log_level(""), None);
}
}