use anyhow::{bail, Context, Result};
use log::{LevelFilter, Log, Metadata, Record};
use once_cell::sync::OnceCell;
use std::borrow::Cow;
use std::fs::{File, OpenOptions};
use std::io::{stderr, Write};
use std::path::PathBuf;
use std::str::FromStr;
pub static LOG_FILE: OnceCell<Option<File>> = OnceCell::new();
const LOG_LEVEL_ENV_NAME: &str = "YOUKI_LOG_LEVEL";
const LOG_FORMAT_TEXT: &str = "text";
const LOG_FORMAT_JSON: &str = "json";
enum LogFormat {
Text,
Json,
}
#[cfg(debug_assertions)]
const DEFAULT_LOG_LEVEL: &str = "debug";
#[cfg(not(debug_assertions))]
const DEFAULT_LOG_LEVEL: &str = "warn";
pub fn init(
log_debug_flag: bool,
log_file: Option<PathBuf>,
log_format: Option<String>,
) -> Result<()> {
let level = detect_log_level(log_debug_flag).context("failed to parse log level")?;
let format = detect_log_format(log_format).context("failed to detect log format")?;
let _ = LOG_FILE.get_or_init(|| -> Option<File> {
log_file.map(|path| {
OpenOptions::new()
.create(true)
.write(true)
.truncate(false)
.open(path)
.expect("failed opening log file")
})
});
let logger = YoukiLogger::new(level.to_level(), format);
log::set_boxed_logger(Box::new(logger))
.map(|()| log::set_max_level(level))
.expect("set logger failed");
Ok(())
}
fn detect_log_format(log_format: Option<String>) -> Result<LogFormat> {
match log_format.as_deref() {
None | Some(LOG_FORMAT_TEXT) => Ok(LogFormat::Text),
Some(LOG_FORMAT_JSON) => Ok(LogFormat::Json),
Some(unknown) => bail!("unknown log format: {}", unknown),
}
}
fn detect_log_level(is_debug: bool) -> Result<LevelFilter> {
let filter: Cow<str> = if is_debug {
"debug".into()
} else if let Ok(level) = std::env::var(LOG_LEVEL_ENV_NAME) {
level.into()
} else {
DEFAULT_LOG_LEVEL.into()
};
Ok(LevelFilter::from_str(filter.as_ref())?)
}
struct YoukiLogger {
level: Option<log::Level>,
format: LogFormat,
}
impl YoukiLogger {
pub fn new(level: Option<log::Level>, format: LogFormat) -> Self {
Self { level, format }
}
}
impl Log for YoukiLogger {
fn enabled(&self, metadata: &Metadata) -> bool {
if let Some(level) = self.level {
metadata.level() <= level
} else {
false
}
}
fn log(&self, record: &Record) {
if self.enabled(record.metadata()) {
let log_msg = match self.format {
LogFormat::Text => text_format(record),
LogFormat::Json => json_format(record),
};
if let Some(mut log_file) = LOG_FILE.get().unwrap().as_ref() {
let _ = writeln!(log_file, "{log_msg}");
} else {
let _ = writeln!(stderr(), "{log_msg}");
}
}
}
fn flush(&self) {
if let Some(mut log_file) = LOG_FILE.get().unwrap().as_ref() {
log_file.flush().expect("failed to flush");
} else {
stderr().flush().expect("failed to flush");
}
}
}
fn json_format(record: &log::Record) -> String {
serde_json::to_string(&serde_json::json!({
"level": record.level().to_string(),
"time": chrono::Local::now().to_rfc3339(),
"message": record.args(),
}))
.expect("serde::to_string with string keys will not fail")
}
fn text_format(record: &log::Record) -> String {
let log_msg = match (record.file(), record.line()) {
(Some(file), Some(line)) => format!(
"[{} {}:{}] {} {}\r",
record.level(),
file,
line,
chrono::Local::now().to_rfc3339(),
record.args()
),
(_, _) => format!(
"[{}] {} {}\r",
record.level(),
chrono::Local::now().to_rfc3339(),
record.args()
),
};
log_msg
}
#[cfg(test)]
mod tests {
use serial_test::serial;
use super::*;
use libcontainer::utils::create_temp_dir;
use std::{env, path::Path};
struct LogLevelGuard {
original_level: Option<String>,
}
impl LogLevelGuard {
fn new(level: &str) -> Result<Self> {
let original_level = env::var(LOG_LEVEL_ENV_NAME).ok();
env::set_var(LOG_LEVEL_ENV_NAME, level);
Ok(Self { original_level })
}
}
impl Drop for LogLevelGuard {
fn drop(self: &mut LogLevelGuard) {
if let Some(level) = self.original_level.as_ref() {
env::set_var(LOG_LEVEL_ENV_NAME, level);
} else {
env::remove_var(LOG_LEVEL_ENV_NAME);
}
}
}
#[test]
fn test_detect_log_level_is_debug() {
let _guard = LogLevelGuard::new("error").unwrap();
assert_eq!(detect_log_level(true).unwrap(), LevelFilter::Debug)
}
#[test]
#[serial]
fn test_detect_log_level_default() {
let _guard = LogLevelGuard::new("error").unwrap();
env::remove_var(LOG_LEVEL_ENV_NAME);
if cfg!(debug_assertions) {
assert_eq!(detect_log_level(false).unwrap(), LevelFilter::Debug)
} else {
assert_eq!(detect_log_level(false).unwrap(), LevelFilter::Warn)
}
}
#[test]
#[serial]
fn test_detect_log_level_from_env() {
let _guard = LogLevelGuard::new("error").unwrap();
assert_eq!(detect_log_level(false).unwrap(), LevelFilter::Error)
}
#[test]
fn test_logfile() {
let temp_dir = create_temp_dir("logfile").expect("failed to create tempdir for logfile");
let log_file = Path::join(temp_dir.path(), "test.log");
init(true, Some(log_file.to_owned()), None).expect("failed to initialize logger");
assert!(
log_file
.as_path()
.metadata()
.expect("failed to get logfile metadata")
.len()
== 0,
"a new logfile should be empty"
);
log::info!("testing this");
assert!(
log_file
.as_path()
.metadata()
.expect("failed to get logfile metadata")
.len()
> 0,
"some log should be written into the logfile"
);
}
}