use super::read_var;
use std::fmt;
pub const LOG_LEVEL_VAR: &str = "LOGLEVEL";
pub const DEFAULT_LOG_LEVEL: LogLevel = LogLevel::Info;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
#[repr(u8)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
#[must_use]
#[inline]
pub fn as_str(self) -> &'static str {
match self {
LogLevel::Trace => "TRACE",
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO",
LogLevel::Warn => "WARN",
LogLevel::Error => "ERROR",
}
}
#[must_use]
pub fn parse(raw: &str) -> Option<Self> {
match raw.trim().to_ascii_uppercase().as_str() {
"TRACE" => Some(LogLevel::Trace),
"DEBUG" => Some(LogLevel::Debug),
"INFO" => Some(LogLevel::Info),
"WARN" => Some(LogLevel::Warn),
"ERROR" => Some(LogLevel::Error),
_ => None,
}
}
}
impl fmt::Display for LogLevel {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ResolvedLogLevel {
pub level: LogLevel,
pub rejected: Option<String>,
}
#[must_use]
pub fn resolve_log_level(raw: Option<&str>) -> ResolvedLogLevel {
match raw {
None => ResolvedLogLevel {
level: DEFAULT_LOG_LEVEL,
rejected: None,
},
Some(raw) if raw.trim().is_empty() => ResolvedLogLevel {
level: DEFAULT_LOG_LEVEL,
rejected: None,
},
Some(raw) => match LogLevel::parse(raw) {
Some(level) => ResolvedLogLevel {
level,
rejected: None,
},
None => ResolvedLogLevel {
level: DEFAULT_LOG_LEVEL,
rejected: Some(raw.to_string()),
},
},
}
}
#[must_use]
pub fn resolve_log_level_from_env() -> ResolvedLogLevel {
resolve_log_level(read_var(LOG_LEVEL_VAR).as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_an_unset_level_resolves_to_info() {
let resolved = resolve_log_level(None);
assert_eq!(resolved.level, LogLevel::Info);
assert_eq!(
resolved.rejected, None,
"nothing was set, so nothing is wrong"
);
}
#[test]
fn test_a_blank_level_resolves_to_the_default() {
for blank in ["", " "] {
let resolved = resolve_log_level(Some(blank));
assert_eq!(resolved.level, DEFAULT_LOG_LEVEL, "{blank:?}");
assert_eq!(
resolved.rejected, None,
"{blank:?} is a knob nobody set, not a value to complain about"
);
}
}
#[test]
fn test_every_level_name_is_accepted_case_insensitively() {
for (raw, expected) in [
("trace", LogLevel::Trace),
("TRACE", LogLevel::Trace),
("Debug", LogLevel::Debug),
("info", LogLevel::Info),
("warn", LogLevel::Warn),
(" Error ", LogLevel::Error),
] {
let resolved = resolve_log_level(Some(raw));
assert_eq!(resolved.level, expected, "for {raw:?}");
assert_eq!(resolved.rejected, None, "for {raw:?}");
}
}
#[test]
fn test_warn_admits_nothing_below_it() {
let resolved = resolve_log_level(Some("WARN"));
assert_eq!(resolved.level, LogLevel::Warn);
}
#[test]
fn test_an_unrecognised_level_falls_back_and_is_reported() {
let resolved = resolve_log_level(Some("verbose"));
assert_eq!(resolved.level, DEFAULT_LOG_LEVEL);
assert_eq!(resolved.rejected, Some("verbose".to_string()));
}
#[test]
fn test_every_name_lands_on_its_level_upstream() {
const UPSTREAM_ARMS: [&str; 4] = ["DEBUG", "ERROR", "WARN", "TRACE"];
for level in [
LogLevel::Trace,
LogLevel::Debug,
LogLevel::Info,
LogLevel::Warn,
LogLevel::Error,
] {
let name = level.as_str();
if level == LogLevel::Info {
assert!(
!UPSTREAM_ARMS.contains(&name),
"INFO reaches upstream through its fallback arm, not a named one"
);
} else {
assert!(
UPSTREAM_ARMS.contains(&name),
"{name} is not an arm upstream matches; it would silently log at INFO"
);
}
}
}
#[test]
fn test_the_env_variable_is_read_by_its_documented_name() {
use once_cell::sync::Lazy;
use std::sync::Mutex;
static ENV_MUTEX: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
let _guard = match ENV_MUTEX.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
assert_eq!(LOG_LEVEL_VAR, "LOGLEVEL");
#[allow(unused_unsafe)]
unsafe {
std::env::set_var(LOG_LEVEL_VAR, "error");
}
assert_eq!(resolve_log_level_from_env().level, LogLevel::Error);
#[allow(unused_unsafe)]
unsafe {
std::env::remove_var(LOG_LEVEL_VAR);
}
assert_eq!(resolve_log_level_from_env().level, DEFAULT_LOG_LEVEL);
}
}