use std::io::IsTerminal as _;
use anyhow::Result;
use fern::{
colors::{Color, ColoredLevelConfig},
Dispatch, FormatCallback,
};
use super::config::{LogDestination, LogDestinationConfig, LoggingConfig};
#[doc = include_str!("../examples/simple_cli.rs")]
#[macro_export]
macro_rules! init_logging {
($config:expr, $default_level:expr $(,)?) => {{
$crate::_init_logging(
$config,
$default_level,
option_env!("CARGO_BIN_NAME"),
env!("CARGO_CRATE_NAME"),
)
.expect("Failed to initialize logging");
}};
}
pub fn _init_logging(
config: LoggingConfig,
default_level: log::LevelFilter,
cargo_bin_name: Option<&str>,
cargo_crate_name: &str,
) -> Result<()> {
if let Some(main_logger) =
build_main_logger(config, default_level, cargo_bin_name, cargo_crate_name)
{
main_logger.apply()?;
}
Ok(())
}
fn build_main_logger(
config: LoggingConfig,
default_level: log::LevelFilter,
cargo_bin_name: Option<&str>,
cargo_crate_name: &str,
) -> Option<Dispatch> {
if config.destinations().is_empty() {
return None;
}
let process_name = process_name(cargo_bin_name, cargo_crate_name);
let mut main_logger = Dispatch::new();
for destination in config.destinations() {
if let Ok(logger) = build_logger(destination, default_level, process_name.clone()) {
main_logger = main_logger.chain(logger);
}
}
Some(main_logger)
}
fn build_logger(
config: &LogDestinationConfig,
default_level: log::LevelFilter,
process_name: String,
) -> Result<Dispatch> {
let logger = Dispatch::new().level(config.level.unwrap_or(default_level));
let logger = match &config.destination {
LogDestination::Stderr => {
if std::io::stderr().is_terminal() {
logger.format(log_formatter_tty()).chain(std::io::stderr())
} else {
logger.format(log_formatter_file()).chain(std::io::stderr())
}
}
LogDestination::File(path) => logger
.format(log_formatter_file())
.chain(fern::log_file(path)?),
LogDestination::Syslog => {
let syslog_formatter = syslog::Formatter3164 {
facility: syslog::Facility::LOG_USER,
hostname: None,
process: process_name,
pid: std::process::id(),
};
logger.chain(syslog::unix(syslog_formatter)?)
}
};
Ok(logger)
}
fn log_formatter_tty() -> impl Fn(FormatCallback, &std::fmt::Arguments, &log::Record) {
let colors = ColoredLevelConfig::new()
.trace(Color::Magenta)
.debug(Color::Cyan)
.info(Color::Green)
.warn(Color::Yellow)
.error(Color::Red);
move |out: FormatCallback, message: &std::fmt::Arguments, record: &log::Record| {
out.finish(format_args!(
"[{} {} {}] {}",
humantime::format_rfc3339_seconds(std::time::SystemTime::now()),
colors.color(record.level()),
record.target(),
message
))
}
}
fn log_formatter_file() -> impl Fn(FormatCallback, &std::fmt::Arguments, &log::Record) {
move |out: FormatCallback, message: &std::fmt::Arguments, record: &log::Record| {
out.finish(format_args!(
"[{} {} {}] {}",
humantime::format_rfc3339_seconds(std::time::SystemTime::now()),
record.level(),
record.target(),
message
))
}
}
fn process_name(cargo_bin_name: Option<&str>, cargo_crate_name: &str) -> String {
exe_name()
.unwrap_or_else(|| {
cargo_bin_name
.map(str::to_string)
.unwrap_or_else(|| cargo_crate_name.to_string())
})
.to_string()
}
fn exe_name() -> Option<String> {
std::env::current_exe()
.map(|exe_path| {
exe_path
.file_name()
.and_then(std::ffi::OsStr::to_str)
.map(str::to_string)
})
.unwrap_or(None)
}
#[cfg(test)]
mod tests {
use super::*;
use log::LevelFilter;
use predicates::Predicate;
use rstest::rstest;
#[test]
fn test_exe_name() {
let actual_exe_name = exe_name().unwrap();
assert!(
actual_exe_name.starts_with("clap_logflag"),
"exe_name should start with clap_logflag but was {actual_exe_name}"
);
}
#[test]
fn test_process_name() {
let actual_process_name = process_name(None, "cargo_crate_name");
assert!(
actual_process_name.starts_with("clap_logflag"),
"process_name should start with clap_logflag but was {actual_process_name}"
);
}
#[rstest]
fn test_build_stderr_logger(
#[values(
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace
)]
level: LevelFilter,
) {
let config = LogDestinationConfig {
destination: LogDestination::Stderr,
level: None,
};
let logger = build_logger(&config, level, "process_name".to_string())
.unwrap()
.into_log();
assert_eq!(logger.0, level);
}
#[rstest]
fn test_build_file_logger(
#[values(
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace
)]
level: LevelFilter,
) {
let tempdir = assert_fs::TempDir::new().unwrap();
let file = tempdir.path().join("logfile");
let config = LogDestinationConfig {
destination: LogDestination::File(file),
level: None,
};
let logger = build_logger(&config, level, "process_name".to_string())
.unwrap()
.into_log();
assert_eq!(logger.0, level);
}
#[rstest]
fn test_log_formatter_file(
#[values(
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace
)]
level: LevelFilter,
) {
let tempdir = assert_fs::TempDir::new().unwrap();
let file = tempdir.path().join("logfile");
let config = LogDestinationConfig {
destination: LogDestination::File(file.clone()),
level: None,
};
let (actual_level, logger) = build_logger(&config, level, "process_name".to_string())
.unwrap()
.into_log();
assert_eq!(level, actual_level);
logger.log(
&log::Record::builder()
.args(format_args!("test log message"))
.level(level.to_level().unwrap())
.target("my-test")
.build(),
);
logger.flush();
let expected_log_regex = format!(
r"\[{} {level} my-test\] test log message\n",
timestamp_regex()
);
let actually_logged = std::fs::read_to_string(&file).unwrap();
assert!(
predicates::str::is_match(expected_log_regex)
.unwrap()
.eval(&actually_logged),
"actually_logged: \"{actually_logged}\""
);
}
const fn timestamp_regex() -> &'static str {
r"(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"
}
#[rstest]
fn test_build_main_logger_disabled(
#[values(
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace
)]
default_level: LevelFilter,
) {
let config = LoggingConfig::disabled();
let built = build_main_logger(config, default_level, None, "process_name");
assert!(built.is_none());
}
#[rstest]
fn test_build_main_logger_stderr_and_file(
#[values(
LevelFilter::Error,
LevelFilter::Warn,
LevelFilter::Info,
LevelFilter::Debug,
LevelFilter::Trace
)]
default_level: LevelFilter,
) {
let tempdir = assert_fs::TempDir::new().unwrap();
let file = tempdir.path().join("logfile");
let config = LoggingConfig::new(vec![
LogDestinationConfig {
destination: LogDestination::Stderr,
level: None,
},
LogDestinationConfig {
destination: LogDestination::File(file.clone()),
level: None,
},
]);
let (actual_level, logger) = build_main_logger(config, default_level, None, "process_name")
.unwrap()
.into_log();
assert_eq!(actual_level, default_level);
logger.log(
&log::Record::builder()
.args(format_args!("test log message"))
.level(default_level.to_level().unwrap())
.target("my-test")
.build(),
);
logger.flush();
let expected_log_regex = format!(
r"\[{} {default_level} my-test\] test log message\n",
timestamp_regex()
);
let actually_logged = std::fs::read_to_string(&file).unwrap();
assert!(
predicates::str::is_match(expected_log_regex)
.unwrap()
.eval(&actually_logged),
"actually_logged: \"{actually_logged}\""
);
}
}