use camino::Utf8PathBuf;
use serde::Deserialize;
use serde::Serialize;
use slog::Drain;
use slog::Level;
use slog::Logger;
use std::fs::OpenOptions;
use std::io::LineWriter;
use std::{io, path::Path};
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "kebab-case", tag = "mode")]
pub enum ConfigLogging {
StderrTerminal { level: ConfigLoggingLevel },
File {
level: ConfigLoggingLevel,
path: Utf8PathBuf,
if_exists: ConfigLoggingIfExists,
},
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigLoggingLevel {
Trace,
Debug,
Info,
Warn,
Error,
Critical,
}
impl From<&ConfigLoggingLevel> for Level {
fn from(config_level: &ConfigLoggingLevel) -> Level {
match config_level {
ConfigLoggingLevel::Trace => Level::Trace,
ConfigLoggingLevel::Debug => Level::Debug,
ConfigLoggingLevel::Info => Level::Info,
ConfigLoggingLevel::Warn => Level::Warning,
ConfigLoggingLevel::Error => Level::Error,
ConfigLoggingLevel::Critical => Level::Critical,
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum ConfigLoggingIfExists {
Fail,
Truncate,
Append,
}
impl ConfigLogging {
pub fn to_logger<S: AsRef<str>>(
&self,
log_name: S,
) -> Result<Logger, io::Error> {
match self {
ConfigLogging::StderrTerminal { level } => {
let decorator = slog_term::TermDecorator::new().build();
let drain =
slog_term::FullFormat::new(decorator).build().fuse();
Ok(async_root_logger(level, drain))
}
ConfigLogging::File { level, path, if_exists } => {
let mut open_options = std::fs::OpenOptions::new();
open_options.write(true);
open_options.create(true);
match if_exists {
ConfigLoggingIfExists::Fail => {
open_options.create_new(true);
}
ConfigLoggingIfExists::Append => {
open_options.append(true);
}
ConfigLoggingIfExists::Truncate => {
open_options.truncate(true);
}
}
let drain = log_drain_for_file(
&open_options,
Path::new(path),
log_name.as_ref().to_string(),
)?;
Ok(async_root_logger(level, drain))
}
}
}
}
fn async_root_logger<T>(level: &ConfigLoggingLevel, drain: T) -> slog::Logger
where
T: slog::Drain + Send + 'static,
<T as slog::Drain>::Err: std::fmt::Debug,
{
let level_drain = slog::LevelFilter(drain, Level::from(level)).fuse();
let async_drain = slog_async::Async::new(level_drain).build().fuse();
slog::Logger::root(async_drain, o!())
}
fn log_drain_for_file(
open_options: &OpenOptions,
path: &Path,
log_name: String,
) -> Result<slog::Fuse<slog_json::Json<LineWriter<std::fs::File>>>, io::Error> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = LineWriter::new(open_options.open(path)?);
eprintln!("note: configured to log to \"{}\"", path.display());
let log_name_box = Box::new(log_name);
let log_name_leaked = Box::leak(log_name_box);
Ok(slog_bunyan::with_name(log_name_leaked, file).build().fuse())
}
#[cfg(test)]
mod test {
use crate::test_util::read_bunyan_log;
use crate::test_util::read_config;
use crate::test_util::verify_bunyan_records;
use crate::test_util::verify_bunyan_records_sequential;
use crate::test_util::BunyanLogRecordSpec;
use crate::ConfigLogging;
use libc;
use slog::Logger;
use std::fs;
use std::path::Path;
use std::{io, path::PathBuf};
fn temp_path(label: &str) -> PathBuf {
let arg0str = std::env::args().next().expect("expected process arg0");
let arg0 = Path::new(&arg0str)
.file_name()
.expect("expected arg0 filename")
.to_str()
.expect("expected arg0 filename to be valid Unicode");
let pid = std::process::id();
let mut pathbuf = std::env::temp_dir();
pathbuf.push(format!("{}.{}.{}", arg0, pid, label));
pathbuf
}
fn read_config_and_create_logger(
label: &str,
contents: &str,
) -> Result<Logger, io::Error> {
let config = read_config::<ConfigLogging>(label, contents).unwrap();
let result = config.to_logger("test-logger");
if let Err(ref error) = result {
eprintln!("error message creating logger: {}", error);
}
result
}
#[test]
fn test_config_bad_log_mode() {
let bad_config = r##" mode = "bonkers" "##;
let error = read_config::<ConfigLogging>("bad_log_mode", bad_config)
.unwrap_err()
.to_string();
assert!(error.starts_with(
"unknown variant `bonkers`, expected `stderr-terminal` or `file` \
for key `mode`"
));
}
#[test]
fn test_config_bad_terminal_no_level() {
let bad_config = r##" mode = "stderr-terminal" "##;
assert_eq!(
read_config::<ConfigLogging>("bad_terminal_no_level", bad_config)
.unwrap_err()
.to_string(),
"missing field `level`"
);
}
#[test]
fn test_config_bad_terminal_bad_level() {
let bad_config = r##"
mode = "stderr-terminal"
level = "everything"
"##;
assert_eq!(
read_config::<ConfigLogging>("bad_terminal_bad_level", bad_config)
.unwrap_err()
.to_string(),
"unknown variant `everything`, expected one of `trace`, `debug`, \
`info`, `warn`, `error`, `critical`"
);
}
#[test]
fn test_config_stderr_terminal() {
let config = r##"
mode = "stderr-terminal"
level = "warn"
"##;
let config =
read_config::<ConfigLogging>("stderr-terminal", config).unwrap();
config.to_logger("test-logger").unwrap();
}
#[test]
fn test_config_bad_file_no_file() {
let bad_config = r##"
mode = "file"
level = "warn"
"##;
let error =
read_config::<ConfigLogging>("bad_file_no_file", bad_config)
.unwrap_err()
.to_string();
assert_eq!(error, "missing field `path`");
}
#[test]
fn test_config_bad_file_no_level() {
let bad_config = r##"
mode = "file"
path = "nonexistent"
"##;
let error =
read_config::<ConfigLogging>("bad_file_no_level", bad_config)
.unwrap_err()
.to_string();
assert_eq!(error, "missing field `level`");
}
struct LogTest {
directory: PathBuf,
cleanup_list: Vec<LogTestCleanup>,
}
#[derive(Debug)]
enum LogTestCleanup {
Directory(PathBuf),
File(PathBuf),
}
impl LogTest {
fn setup(label: &str) -> LogTest {
let directory_path = temp_path(label);
if let Err(e) = fs::create_dir_all(&directory_path) {
panic!(
"unexpected failure creating directories leading up to \
{}: {}",
directory_path.as_path().display(),
e
);
}
LogTest {
directory: directory_path.clone(),
cleanup_list: vec![LogTestCleanup::Directory(directory_path)],
}
}
fn will_create_dir(&mut self, path: &str) -> PathBuf {
let mut pathbuf = self.directory.clone();
pathbuf.push(path);
self.cleanup_list.push(LogTestCleanup::Directory(pathbuf.clone()));
pathbuf
}
fn will_create_file(&mut self, path: &str) -> PathBuf {
let mut pathbuf = self.directory.clone();
pathbuf.push(path);
self.cleanup_list.push(LogTestCleanup::File(pathbuf.clone()));
pathbuf
}
}
impl Drop for LogTest {
fn drop(&mut self) {
for path in self.cleanup_list.iter().rev() {
let maybe_error = match path {
LogTestCleanup::Directory(p) => fs::remove_dir(p),
LogTestCleanup::File(p) => fs::remove_file(p),
};
if let Err(e) = maybe_error {
panic!("unexpected failure removing {:?}", e);
}
}
}
}
#[test]
fn test_config_bad_file_bad_path_type() {
let mut logtest = LogTest::setup("bad_file_bad_path_type_dir");
let path = logtest.will_create_dir("log_file_as_dir");
fs::create_dir(&path).unwrap();
let escaped_path =
path.display().to_string().escape_default().to_string();
let bad_config = format!(
r#"
mode = "file"
level = "warn"
if_exists = "append"
path = "{}"
"#,
escaped_path
);
let error = read_config_and_create_logger(
"bad_file_bad_path_type",
&bad_config,
)
.unwrap_err();
if cfg!(windows) {
assert_eq!(error.kind(), std::io::ErrorKind::PermissionDenied);
} else {
assert_eq!(error.raw_os_error(), Some(libc::EISDIR));
}
}
#[test]
fn test_config_bad_file_path_exists_fail() {
let mut logtest = LogTest::setup("bad_file_path_exists_fail_dir");
let logpath = logtest.will_create_file("log.out");
fs::write(&logpath, "").expect("writing empty file");
let escaped_path =
logpath.display().to_string().escape_default().to_string();
let bad_config = format!(
r#"
mode = "file"
level = "warn"
if_exists = "fail"
path = "{}"
"#,
escaped_path
);
let error = read_config_and_create_logger(
"bad_file_bad_path_exists_fail",
&bad_config,
)
.unwrap_err();
assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists);
}
#[test]
fn test_config_file() {
let mut logtest = LogTest::setup("file_dir");
let logpath = logtest.will_create_file("log.out");
let time_before = chrono::offset::Utc::now();
let escaped_path =
logpath.display().to_string().escape_default().to_string();
let config = format!(
r#"
mode = "file"
level = "warn"
if_exists = "fail"
path = "{}"
"#,
escaped_path
);
{
let log = read_config_and_create_logger("file", &config).unwrap();
debug!(log, "message1_debug");
warn!(log, "message1_warn");
error!(log, "message1_error");
}
let config = format!(
r#"
mode = "file"
level = "warn"
if_exists = "append"
path = "{}"
"#,
escaped_path
);
{
let log = read_config_and_create_logger("file", &config).unwrap();
warn!(log, "message2");
}
let time_after = chrono::offset::Utc::now();
let log_records = read_bunyan_log(&logpath);
let expected_hostname = hostname::get().unwrap().into_string().unwrap();
verify_bunyan_records(
log_records.iter(),
&BunyanLogRecordSpec {
name: Some("test-logger".to_string()),
hostname: Some(expected_hostname.clone()),
v: Some(0),
pid: Some(std::process::id()),
},
);
verify_bunyan_records_sequential(
log_records.iter(),
Some(&time_before),
Some(&time_after),
);
assert_eq!(log_records.len(), 3);
assert_eq!(log_records[0].msg, "message1_warn");
assert_eq!(log_records[1].msg, "message1_error");
assert_eq!(log_records[2].msg, "message2");
let time_before = time_after;
let time_after = chrono::offset::Utc::now();
let config = format!(
r#"
mode = "file"
level = "trace"
if_exists = "truncate"
path = "{}"
"#,
escaped_path
);
{
let log = read_config_and_create_logger("file", &config).unwrap();
debug!(log, "message3_debug");
warn!(log, "message3_warn");
error!(log, "message3_error");
}
let log_records = read_bunyan_log(&logpath);
verify_bunyan_records(
log_records.iter(),
&BunyanLogRecordSpec {
name: Some("test-logger".to_string()),
hostname: Some(expected_hostname),
v: Some(0),
pid: Some(std::process::id()),
},
);
verify_bunyan_records_sequential(
log_records.iter(),
Some(&time_before),
Some(&time_after),
);
assert_eq!(log_records.len(), 3);
assert_eq!(log_records[0].msg, "message3_debug");
assert_eq!(log_records[1].msg, "message3_warn");
assert_eq!(log_records[2].msg, "message3_error");
}
}