#![doc = include_str!("../README.md")]
use std::time::{Duration, Instant};
use thiserror::Error;
mod format;
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum LoggerError {
#[error("a global logger is already initialized")]
AlreadyInitialized,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
pub enum ColorChoice {
Never,
#[default]
Auto,
Always,
}
impl ColorChoice {
fn write_style(self) -> env_logger::WriteStyle {
match self {
Self::Never => env_logger::WriteStyle::Never,
Self::Auto => env_logger::WriteStyle::Auto,
Self::Always => env_logger::WriteStyle::Always,
}
}
}
#[derive(Debug)]
#[must_use = "call .init() to enable logging"]
pub struct LoggerBuilder {
level: LevelFilter,
format: format::LineFormat,
}
impl Default for LoggerBuilder {
fn default() -> Self {
Self::new()
}
}
impl LoggerBuilder {
pub fn new() -> Self {
Self {
level: LevelFilter::Info,
format: format::LineFormat::default(),
}
}
pub fn level(mut self, level: LevelFilter) -> Self {
self.level = level;
self
}
pub fn color(mut self, choice: ColorChoice) -> Self {
self.format.color = choice;
self
}
pub fn source_location(mut self, source_location: bool) -> Self {
self.format.source_location = source_location;
self
}
pub fn init(&self) -> Result<(), LoggerError> {
let start = Instant::now();
self.env_builder(move || start.elapsed())
.try_init()
.map_err(|_| LoggerError::AlreadyInitialized)
}
fn env_builder(
&self,
elapsed: impl Fn() -> Duration + Send + Sync + 'static,
) -> env_logger::Builder {
let formatter = format::LineFormatter::new(self.format, elapsed);
let mut builder = env_logger::Builder::new();
builder.target(env_logger::Target::Stderr);
builder.filter_level(self.level);
builder.write_style(self.format.color.write_style());
builder.format(move |buf, record| formatter.format(buf, record));
builder
}
}
pub use log::LevelFilter;
pub use log::{debug, error, info, trace, warn};
#[cfg(test)]
mod tests {
use super::*;
use env_logger::Target;
use log::{Level, Log, Record};
use std::io::Write;
use std::sync::{Arc, Mutex};
#[derive(Clone, Default)]
struct Capture(Arc<Mutex<Vec<u8>>>);
impl Capture {
fn contents(&self) -> String {
String::from_utf8(self.0.lock().unwrap().clone()).unwrap()
}
}
impl Write for Capture {
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
self.0.lock().unwrap().write(data)
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn piped(builder: &LoggerBuilder, elapsed: Duration) -> (env_logger::Logger, Capture) {
let capture = Capture::default();
let env_logger = builder
.env_builder(move || elapsed)
.target(Target::Pipe(Box::new(capture.clone())))
.build();
(env_logger, capture)
}
fn emit(log: &impl Log, level: Level, message: &str) {
log.log(
&Record::builder()
.level(level)
.target("my_crate")
.file(Some("src/lib.rs"))
.line(Some(42))
.args(format_args!("{message}"))
.build(),
);
}
fn lines(capture: &Capture) -> Vec<String> {
capture.contents().lines().map(str::to_owned).collect()
}
#[test]
fn test_default_level_filters_below_info() {
let (log, capture) = piped(&LoggerBuilder::new(), Duration::ZERO);
emit(&log, Level::Trace, "dropped");
emit(&log, Level::Debug, "dropped");
emit(&log, Level::Info, "kept");
emit(&log, Level::Error, "kept too");
assert_eq!(
lines(&capture),
[
"[00:00:00.000 | INFO | my_crate | src/lib.rs:42 ] kept",
"[00:00:00.000 | ERROR | my_crate | src/lib.rs:42 ] kept too",
]
);
}
#[test]
fn test_explicit_level_is_applied() {
let builder = LoggerBuilder::new().level(LevelFilter::Warn);
let (log, capture) = piped(&builder, Duration::ZERO);
emit(&log, Level::Info, "dropped");
emit(&log, Level::Warn, "kept");
assert_eq!(
lines(&capture),
["[00:00:00.000 | WARN | my_crate | src/lib.rs:42 ] kept"]
);
}
#[test]
fn test_trace_level_emits_everything() {
let builder = LoggerBuilder::new().level(LevelFilter::Trace);
let (log, capture) = piped(&builder, Duration::ZERO);
emit(&log, Level::Trace, "finest");
assert_eq!(
lines(&capture),
["[00:00:00.000 | TRACE | my_crate | src/lib.rs:42 ] finest"]
);
}
#[test]
fn test_source_location_off_omits_the_segment() {
let builder = LoggerBuilder::new().source_location(false);
let (log, capture) = piped(&builder, Duration::ZERO);
emit(&log, Level::Info, "short");
assert_eq!(
lines(&capture),
["[00:00:00.000 | INFO | my_crate ] short"]
);
}
#[test]
fn test_elapsed_time_comes_from_the_clock() {
let (log, capture) = piped(&LoggerBuilder::new(), Duration::from_millis(125_120));
emit(&log, Level::Info, "later");
assert_eq!(
lines(&capture),
["[00:02:05.120 | INFO | my_crate | src/lib.rs:42 ] later"]
);
}
fn is_colored(text: &str) -> bool {
text.contains('\x1b')
}
#[test]
fn test_color_always_colors_a_line_that_is_not_going_to_a_terminal() {
let builder = LoggerBuilder::new().color(ColorChoice::Always);
let (log, capture) = piped(&builder, Duration::ZERO);
emit(&log, Level::Error, "colored");
let output = capture.contents();
assert!(is_colored(&output), "expected color, got {output:?}");
assert!(output.contains("[00:00:00.000 | ERROR | my_crate | src/lib.rs:42 ] colored"));
}
#[test]
fn test_color_auto_leaves_a_line_that_is_not_going_to_a_terminal_plain() {
let (log, capture) = piped(&LoggerBuilder::new(), Duration::ZERO);
emit(&log, Level::Error, "plain");
let output = capture.contents();
assert!(!is_colored(&output), "unexpected color in {output:?}");
}
const STDERR_CHILD: &str = "CAESIUM_STDERR_CHILD";
const CHILD_COLORS: [(Option<ColorChoice>, &str); 4] = [
(None, "unset"),
(Some(ColorChoice::Never), "never"),
(Some(ColorChoice::Auto), "auto"),
(Some(ColorChoice::Always), "always"),
];
#[test]
#[ignore = "helper run by test_init_writes_lines_to_stderr_not_stdout and the color tests"]
fn stderr_child() {
let Some(value) = std::env::var_os(STDERR_CHILD) else {
return;
};
let (color, _) = CHILD_COLORS
.into_iter()
.find(|(_, name)| value == *name)
.unwrap_or_else(|| panic!("unknown color choice {value:?}"));
let mut builder = LoggerBuilder::new().source_location(false);
if let Some(color) = color {
builder = builder.color(color);
}
builder.init().unwrap();
info!("to stderr");
}
struct ChildOutput {
stdout: String,
stderr: String,
}
impl ChildOutput {
fn stderr_line(&self) -> &str {
self.stderr
.lines()
.find(|line| line.contains("to stderr"))
.unwrap_or_else(|| panic!("line missing from stderr: {:?}", self.stderr))
}
fn is_colored(&self) -> bool {
is_colored(self.stderr_line())
}
}
fn run_child(color: Option<ColorChoice>, env: &[(&str, &str)]) -> ChildOutput {
let (_, value) = CHILD_COLORS
.into_iter()
.find(|(choice, _)| *choice == color)
.expect("every color choice is in CHILD_COLORS");
let output = std::process::Command::new(std::env::current_exe().unwrap())
.args(["tests::stderr_child", "--exact", "--ignored", "--nocapture"])
.env_remove("NO_COLOR")
.env_remove("CLICOLOR")
.env_remove("CLICOLOR_FORCE")
.env(STDERR_CHILD, value)
.envs(env.iter().copied())
.output()
.unwrap();
assert!(output.status.success(), "child failed: {output:?}");
ChildOutput {
stdout: String::from_utf8(output.stdout).unwrap(),
stderr: String::from_utf8(output.stderr).unwrap(),
}
}
#[test]
fn test_init_writes_lines_to_stderr_not_stdout() {
let output = run_child(None, &[]);
assert!(
output
.stderr_line()
.ends_with("| INFO | caesium::tests ] to stderr"),
"{:?}",
output.stderr
);
assert!(
!output.stdout.contains("to stderr"),
"line written to stdout: {:?}",
output.stdout
);
}
#[test]
fn test_color_auto_colors_lines_when_stderr_supports_color() {
let output = run_child(Some(ColorChoice::Auto), &[("CLICOLOR_FORCE", "1")]);
assert!(output.is_colored(), "{:?}", output.stderr);
}
#[test]
fn test_color_auto_leaves_lines_plain_when_stderr_does_not_support_color() {
let output = run_child(Some(ColorChoice::Auto), &[]);
assert!(!output.is_colored(), "{:?}", output.stderr);
}
#[test]
fn test_color_auto_respects_no_color() {
let output = run_child(
Some(ColorChoice::Auto),
&[("CLICOLOR_FORCE", "1"), ("NO_COLOR", "1")],
);
assert!(!output.is_colored(), "{:?}", output.stderr);
}
#[test]
fn test_color_never_leaves_lines_plain_even_when_stderr_supports_color() {
let output = run_child(Some(ColorChoice::Never), &[("CLICOLOR_FORCE", "1")]);
assert!(!output.is_colored(), "{:?}", output.stderr);
}
#[test]
fn test_color_defaults_to_auto() {
let output = run_child(None, &[("CLICOLOR_FORCE", "1")]);
assert!(output.is_colored(), "{:?}", output.stderr);
}
#[test]
fn test_init_fails_once_a_logger_is_initialized() {
let builder = LoggerBuilder::new();
let _ = builder.init();
let second = builder.init();
assert!(
matches!(second, Err(LoggerError::AlreadyInitialized)),
"expected AlreadyInitialized, got {second:?}"
);
}
#[test]
fn test_already_initialized_has_a_message_and_no_source() {
let error = LoggerError::AlreadyInitialized;
assert_eq!(error.to_string(), "a global logger is already initialized");
assert!(std::error::Error::source(&error).is_none());
}
#[test]
fn test_builder_implements_debug() {
let debug = format!("{:?}", LoggerBuilder::new());
assert!(debug.starts_with("LoggerBuilder"), "got {debug:?}");
}
}