use chrono::{offset::Local, Datelike, Timelike};
use std::fmt;
use std::fs::{File, OpenOptions};
use std::io::Write;
use std::sync::atomic::{AtomicIsize, Ordering};
use std::sync::Mutex;
use std::sync::OnceLock;
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Debug)]
#[repr(C)]
pub enum Level {
UserError = 1,
Critical,
Error,
Warn,
Info,
Debug,
}
impl fmt::Display for Level {
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
match *self {
Level::UserError => write!(f, "USERERROR"),
Level::Critical => write!(f, "CRITICAL"),
Level::Error => write!(f, "ERROR"),
Level::Warn => write!(f, "WARNING"),
Level::Info => write!(f, "INFO"),
Level::Debug => write!(f, "DEBUG"),
}
}
}
struct LogFiles {
logfile: Option<File>,
user_error_logfile: Option<File>,
}
pub struct Logger {
files: Mutex<LogFiles>,
loglevel: AtomicIsize,
}
impl Logger {
pub fn new() -> Logger {
Logger {
files: Mutex::new(LogFiles {
logfile: None,
user_error_logfile: None,
}),
loglevel: AtomicIsize::new(-1_isize),
}
}
pub fn set_logfile(&self, filename: &str) {
let file = OpenOptions::new().create(true).append(true).open(filename);
match file {
Ok(file) => {
let mut files = self.files.lock().expect("Someone poisoned logger's mutex");
files.logfile = Some(file);
}
Err(error) => eprintln!("Couldn't open `{filename}' as a logfile: {error}"),
}
}
pub fn set_user_error_logfile(&self, filename: &str) {
let file = OpenOptions::new().create(true).append(true).open(filename);
match file {
Ok(file) => {
let mut files = self.files.lock().expect("Someone poisoned logger's mutex");
files.user_error_logfile = Some(file)
}
Err(error) => eprintln!("Couldn't open `{filename}' as a user error logfile: {error}"),
}
}
pub fn log(&self, level: Level, message: &str) {
self.log_raw(level, message.as_bytes());
}
pub fn log_raw(&self, level: Level, data: &[u8]) {
if level != Level::UserError && level as isize > self.get_loglevel() {
return;
}
let timestamp = Local::now();
let timestamp = format!(
"[{}-{:02}-{:02} {:02}:{:02}:{:02}] ",
timestamp.year(),
timestamp.month(),
timestamp.day(),
timestamp.hour(),
timestamp.minute(),
timestamp.second()
);
let mut files = self.files.lock().expect("Someone poisoned logger's mutex");
if level as isize <= self.get_loglevel() {
if let Some(ref mut logfile) = files.logfile {
let level = format!("{level}: ");
let _ = logfile.write_all(timestamp.as_bytes());
let _ = logfile.write_all(level.as_bytes());
let _ = logfile.write_all(data);
let _ = logfile.write_all(b"\n");
}
}
if level == Level::UserError {
if let Some(ref mut user_error_logfile) = files.user_error_logfile {
let _ = user_error_logfile.write_all(timestamp.as_bytes());
let _ = user_error_logfile.write_all(data);
let _ = user_error_logfile.write_all(b"\n");
}
}
}
pub fn set_loglevel(&self, level: Level) {
self.loglevel.store(level as isize, Ordering::SeqCst);
}
pub fn unset_loglevel(&self) {
self.loglevel.store(-1_isize, Ordering::SeqCst);
}
pub fn get_loglevel(&self) -> isize {
self.loglevel.load(Ordering::Relaxed)
}
}
impl Default for Logger {
fn default() -> Self {
Self::new()
}
}
static GLOBAL_LOGGER: OnceLock<Logger> = OnceLock::new();
pub fn get_instance() -> &'static Logger {
GLOBAL_LOGGER.get_or_init(Logger::new)
}
#[macro_export]
macro_rules! log {
( $level:expr, $message:expr ) => {
logger::get_instance().log($level, $message);
};
( $level:expr, $format:expr, $( $arg:expr ),+ ) => {
logger::get_instance().log($level, &format!($format, $( $arg ),+));
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{NaiveDateTime, TimeDelta};
use std::io::{self, BufRead, BufReader};
use std::path;
use tempfile::TempDir;
fn setup_logger() -> io::Result<(TempDir, path::PathBuf, path::PathBuf, Logger)> {
let tmp = TempDir::new()?;
let logfile = {
let mut logfile = tmp.path().to_owned();
logfile.push("example.log");
logfile
};
let error_logfile = {
let mut error_logfile = tmp.path().to_owned();
error_logfile.push("error-example.log");
error_logfile
};
assert!(!error_logfile.exists());
let logger = Logger::new();
logger.set_logfile(logfile.to_str().unwrap());
logger.set_user_error_logfile(error_logfile.to_str().unwrap());
Ok((tmp, logfile, error_logfile, logger))
}
fn parse_log_line(line: &str) -> Option<(&str, &str, &str)> {
if !line.starts_with('[') {
return None;
}
let timestamp_end = line
.find(']')
.expect("Failed to find the end of the timestamp");
let timestamp = &line[1..timestamp_end];
if &line[timestamp_end + 1..timestamp_end + 2] != " " {
return None;
}
let level_end = line[timestamp_end + 2..]
.find(':')
.expect("Failed to find the end of the loglevel");
let level = &line[timestamp_end + 2..timestamp_end + 2 + level_end];
let message = &line[timestamp_end + 2 + level_end + 2..];
Some((timestamp, level, message))
}
fn parse_errorlog_line(line: &str) -> Option<(&str, &str)> {
if !line.starts_with('[') {
return None;
}
let timestamp_end = line
.find(']')
.expect("Failed to find the end of the timestamp");
let timestamp = &line[1..timestamp_end];
let message = &line[timestamp_end + 2..];
Some((timestamp, message))
}
fn log_contains_n_lines(logfile: &path::Path, n: usize) -> io::Result<()> {
let file = File::open(logfile)?;
let reader = BufReader::new(file);
assert_eq!(reader.lines().count(), n);
Ok(())
}
struct LogLinesCounter {
messages: Vec<(Level, String)>,
levels: Vec<Option<Level>>,
expected_log_lines: Option<usize>,
expected_errorlog_lines: Option<usize>,
}
impl LogLinesCounter {
pub fn new() -> Self {
LogLinesCounter {
messages: vec![],
levels: vec![],
expected_log_lines: None,
expected_errorlog_lines: None,
}
}
pub fn with_messages(mut self, msgs: Vec<(Level, String)>) -> Self {
self.messages = msgs;
self
}
pub fn at_levels(mut self, levels: Vec<Option<Level>>) -> Self {
self.levels = levels;
self
}
pub fn expected_log_lines_count(mut self, n: usize) -> Self {
self.expected_log_lines = Some(n);
self
}
pub fn expected_errorlog_lines_count(mut self, n: usize) -> Self {
self.expected_errorlog_lines = Some(n);
self
}
pub fn test(&self) -> io::Result<()> {
if self.expected_log_lines.is_none() && self.expected_errorlog_lines.is_none() {
panic!("You failed to specify any assertions on LogLinesCounter");
}
for level in &self.levels {
let (_tmp, logfile, error_logfile, logger) = setup_logger()?;
match *level {
Some(l) => logger.set_loglevel(l),
None => logger.unset_loglevel(),
};
for &(level, ref msg) in &self.messages {
logger.log(level, msg);
}
drop(logger);
if let Some(count) = self.expected_log_lines {
log_contains_n_lines(&logfile, count)?;
}
if let Some(count) = self.expected_errorlog_lines {
log_contains_n_lines(&error_logfile, count)?;
}
}
Ok(())
}
}
#[test]
fn t_set_logfile_creates_a_file() {
let (_tmp, logfile, _error_logfile, _logger) = setup_logger().unwrap();
assert!(logfile.exists());
}
#[test]
fn t_log_writes_message_to_the_file() {
let (_tmp, logfile, _error_logfile, logger) = setup_logger().unwrap();
let messages = vec![
"Hello, world!",
"I'm doing fine, how are you?",
"Time to wrap up, see ya!",
];
logger.set_loglevel(Level::Debug);
let start_time = Local::now();
for msg in &messages {
logger.log(Level::Debug, msg);
}
let finish_time = Local::now();
drop(logger);
log_contains_n_lines(&logfile, 3).unwrap();
let file = File::open(logfile).unwrap();
let reader = BufReader::new(file);
for (line, expected) in reader.lines().zip(messages) {
match line {
Ok(line) => {
let (timestamp_str, _level, message) =
parse_log_line(&line).expect("Failed to split the log line into parts");
let timestamp =
NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse the timestamp from the log file")
.and_local_timezone(Local::now().timezone())
.unwrap();
assert!(timestamp - start_time > TimeDelta::try_seconds(-1).unwrap());
assert!(finish_time >= timestamp);
assert_eq!(message, expected);
}
Err(e) => panic!("Error reading a line from the log: {:?}", e),
}
}
}
#[test]
fn t_different_loglevels_have_different_names() {
let (_tmp, logfile, _error_logfile, logger) = setup_logger().unwrap();
let levels = vec![
(Level::UserError, "USERERROR"),
(Level::Critical, "CRITICAL"),
(Level::Error, "ERROR"),
(Level::Warn, "WARNING"),
(Level::Info, "INFO"),
(Level::Debug, "DEBUG"),
];
let msg = "Some test message";
logger.set_loglevel(Level::Debug);
for &(level, _level_str) in &levels {
logger.log(level, msg);
}
drop(logger);
log_contains_n_lines(&logfile, 6).unwrap();
let file = File::open(logfile).unwrap();
let reader = BufReader::new(file);
for (line, expected) in reader.lines().zip(levels.iter().map(|&(_, l)| l)) {
match line {
Ok(line) => {
let (_timestamp_str, level, _message) =
parse_log_line(&line).expect("Failed to split the log line into parts");
assert_eq!(level, expected);
}
Err(e) => panic!("Error reading a line from the log: {:?}", e),
}
}
}
#[test]
fn t_if_curlevel_is_none_nothing_is_logged() {
let (_tmp, logfile, _error_logfile, logger) = setup_logger().unwrap();
logger.unset_loglevel();
let levels = vec![
(Level::UserError, "USERERROR"),
(Level::Critical, "CRITICAL"),
(Level::Error, "ERROR"),
(Level::Warn, "WARNING"),
(Level::Info, "INFO"),
(Level::Debug, "DEBUG"),
];
let msg = "Some test message";
for &(level, _level_str) in &levels {
logger.log(level, msg);
}
drop(logger);
log_contains_n_lines(&logfile, 0).unwrap();
}
#[test]
fn t_user_errors_are_logged_at_all_curlevels_beside_none() {
let message = (Level::UserError, "hello".to_string());
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(vec![None])
.expected_log_lines_count(0)
.test()
.unwrap();
let levels = vec![
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
Some(Level::Debug),
];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(levels)
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_critical_msgs_are_logged_at_curlevels_starting_with_critical() {
let message = (Level::Critical, "hello".to_string());
let nolog_levels = vec![None, Some(Level::UserError)];
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(nolog_levels)
.expected_log_lines_count(0)
.test()
.unwrap();
let log_levels = vec![
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
Some(Level::Debug),
];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(log_levels)
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_error_msgs_are_logged_at_curlevels_starting_with_error() {
let message = (Level::Error, "hello".to_string());
let nolog_levels = vec![None, Some(Level::UserError), Some(Level::Critical)];
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(nolog_levels)
.expected_log_lines_count(0)
.test()
.unwrap();
let log_levels = vec![
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
Some(Level::Debug),
];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(log_levels)
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_warning_msgs_are_logged_at_curlevels_starting_with_warning() {
let message = (Level::Warn, "hello".to_string());
let nolog_levels = vec![
None,
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
];
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(nolog_levels)
.expected_log_lines_count(0)
.test()
.unwrap();
let log_levels = vec![Some(Level::Warn), Some(Level::Info), Some(Level::Debug)];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(log_levels)
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_info_msgs_are_logged_at_curlevels_starting_with_info() {
let message = (Level::Info, "hello".to_string());
let nolog_levels = vec![
None,
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
];
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(nolog_levels)
.expected_log_lines_count(0)
.test()
.unwrap();
let log_levels = vec![Some(Level::Info), Some(Level::Debug)];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(log_levels)
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_debug_msgs_are_logged_only_at_curlevel_debug() {
let message = (Level::Debug, "hello".to_string());
let nolog_levels = vec![
None,
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
];
LogLinesCounter::new()
.with_messages(vec![message.clone()])
.at_levels(nolog_levels)
.expected_log_lines_count(0)
.test()
.unwrap();
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(vec![Some(Level::Debug)])
.expected_log_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_set_user_error_logfile_creates_a_file() {
let (_tmp, _logfile, error_logfile, _logger) = setup_logger().unwrap();
assert!(error_logfile.exists());
}
#[test]
fn t_writes_to_errorlog_at_all_loglevels() {
let message = (Level::UserError, "hello".to_string());
let log_levels = vec![
None,
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
Some(Level::Debug),
];
LogLinesCounter::new()
.with_messages(vec![message])
.at_levels(log_levels)
.expected_errorlog_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_only_usererrors_are_written_to_errorlog() {
let messages = vec![
(Level::UserError, "hello".to_string()),
(
Level::Critical,
"this shouldn't be written to error-log".to_string(),
),
];
let log_levels = vec![
None,
Some(Level::UserError),
Some(Level::Critical),
Some(Level::Error),
Some(Level::Warn),
Some(Level::Info),
Some(Level::Debug),
];
LogLinesCounter::new()
.with_messages(messages)
.at_levels(log_levels)
.expected_errorlog_lines_count(1)
.test()
.unwrap();
}
#[test]
fn t_log_writes_message_to_the_user_error_logfile() {
let (_tmp, _logfile, error_logfile, logger) = setup_logger().unwrap();
logger.set_loglevel(Level::UserError);
let messages = vec![
"Hello, world!",
"I'm doing fine, how are you?",
"Time to wrap up, see ya!",
];
let start_time = Local::now();
for msg in &messages {
logger.log(Level::UserError, msg);
}
let finish_time = Local::now();
drop(logger);
log_contains_n_lines(&error_logfile, 3).unwrap();
let file = File::open(error_logfile).unwrap();
let reader = BufReader::new(file);
for (line, expected) in reader.lines().zip(messages) {
match line {
Ok(line) => {
let (timestamp_str, message) = parse_errorlog_line(&line)
.expect("Failed to split the error log line into parts");
let timestamp =
NaiveDateTime::parse_from_str(timestamp_str, "%Y-%m-%d %H:%M:%S")
.expect("Failed to parse the timestamp from the log file")
.and_local_timezone(Local::now().timezone())
.unwrap();
assert!(timestamp - start_time > TimeDelta::try_seconds(-1).unwrap());
assert!(finish_time >= timestamp);
assert_eq!(message, expected);
}
Err(e) => panic!("Error reading a line from the log: {:?}", e),
}
}
}
}