use std::{
fs::{File, OpenOptions},
io::{self, BufWriter, Write},
path::{Path, PathBuf},
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex, RwLock,
},
time,
};
use flate2::{write::GzEncoder, Compression};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum LogLevel {
Trace,
Debug,
Info,
Warn,
Error,
}
impl LogLevel {
pub fn as_str(&self) -> &'static str {
match self {
LogLevel::Trace => "TRACE",
LogLevel::Debug => "DEBUG",
LogLevel::Info => "INFO",
LogLevel::Warn => "WARN",
LogLevel::Error => "ERROR",
}
}
}
pub struct LogEvent<'a> {
pub level: LogLevel,
pub message: &'a str,
pub timestamp: std::time::SystemTime,
}
pub trait Layout: Send + Sync {
fn format(&self, event: &LogEvent) -> String;
}
pub struct SimpleLayout;
impl Layout for SimpleLayout {
fn format(&self, event: &LogEvent) -> String {
format!("[{:<5}] {} {}", event.level.as_str(), get_now_str(event.timestamp), event.message)
}
}
pub trait Appender: Send + Sync {
fn append(&self, event: &LogEvent);
}
pub struct ConsoleAppender {
layout: Box<dyn Layout>,
}
impl ConsoleAppender {
pub fn new(layout: Box<dyn Layout>) -> Self {
Self { layout }
}
}
impl Appender for ConsoleAppender {
fn append(&self, event: &LogEvent) {
println!("{}", self.layout.format(event));
}
}
pub struct FileAppender {
layout: Box<dyn Layout>,
path: PathBuf,
max_size: Option<u64>,
max_files: Option<usize>,
compressed: bool,
roll: AtomicU64,
state: Mutex<Option<FileState>>,
}
struct FileState {
writer: BufWriter<File>,
written: u64,
}
impl FileState {
fn open(path: &Path) -> io::Result<Self> {
let file = OpenOptions::new().create(true).append(true).open(path)?;
let written = file.metadata().map_or(0, |m| m.len());
Ok(Self {
writer: BufWriter::new(file),
written,
})
}
}
pub struct FileAppenderBuilder {
path: PathBuf,
layout: Box<dyn Layout>,
max_size: Option<u64>,
max_files: Option<usize>,
compressed: bool,
}
impl FileAppenderBuilder {
pub fn max_size(mut self, bytes: u64) -> Self {
self.max_size = Some(bytes);
self
}
pub fn max_files(mut self, count: usize) -> Self {
self.max_files = Some(count);
self
}
pub fn compressed(mut self) -> Self {
self.compressed = true;
self
}
pub fn build(self) -> io::Result<FileAppender> {
let state = FileState::open(&self.path)?;
Ok(FileAppender {
layout: self.layout,
path: self.path,
max_size: self.max_size,
max_files: self.max_files,
compressed: self.compressed,
roll: AtomicU64::new(1),
state: Mutex::new(Some(state)),
})
}
}
impl FileAppender {
pub fn open(path: impl AsRef<Path>, layout: Box<dyn Layout>) -> io::Result<Self> {
Self::builder(path, layout).build()
}
pub fn builder(path: impl AsRef<Path>, layout: Box<dyn Layout>) -> FileAppenderBuilder {
FileAppenderBuilder {
path: path.as_ref().to_path_buf(),
layout,
max_size: None,
max_files: None,
compressed: false,
}
}
fn rotate(&self, guard: &mut std::sync::MutexGuard<'_, Option<FileState>>) -> io::Result<()> {
if let Some(mut state) = guard.take() {
state.writer.flush()?;
}
let roll = self.roll.fetch_add(1, Ordering::Relaxed);
let rotated = self.rotated_path(roll);
if let Err(e) = std::fs::rename(&self.path, &rotated) {
**guard = Some(FileState::open(&self.path)?);
return Err(e);
}
**guard = Some(FileState::open(&self.path)?);
if self.compressed {
let _ = self.compress(&rotated);
}
self.enforce_retention();
Ok(())
}
fn compress(&self, source: &Path) -> io::Result<()> {
let gz_path = PathBuf::from(format!("{}.gz", source.display()));
let file = File::create(&gz_path)?;
let mut encoder = GzEncoder::new(file, Compression::default());
let mut reader = File::open(source)?;
io::copy(&mut reader, &mut encoder)?;
encoder.finish()?;
std::fs::remove_file(source)
}
fn rotated_path(&self, roll: u64) -> PathBuf {
let ts = chrono::Local::now().format("%Y%m%d-%H%M%S");
let mut candidate = PathBuf::from(format!("{}.{}-{}", self.path.display(), ts, roll));
let mut n = roll + 1;
while self.rotation_name_taken(&candidate) {
candidate = PathBuf::from(format!("{}.{}-{}", self.path.display(), ts, n));
n += 1;
}
candidate
}
fn rotation_name_taken(&self, candidate: &Path) -> bool {
candidate.exists()
|| (self.compressed
&& PathBuf::from(format!("{}.gz", candidate.display())).exists())
}
fn enforce_retention(&self) {
let Some(max_files) = self.max_files else { return };
let dir = self
.path
.parent()
.filter(|p| !p.as_os_str().is_empty())
.unwrap_or_else(|| Path::new("."));
let prefix = format!(
"{}.",
self.path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default()
);
let Ok(entries) = std::fs::read_dir(dir) else { return };
let mut rotated: Vec<PathBuf> = entries
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with(&prefix))
})
.collect();
if rotated.len() <= max_files {
return;
}
rotated.sort_by_key(|path| retention_key(path, &prefix));
for old in rotated.iter().take(rotated.len() - max_files) {
let _ = std::fs::remove_file(old);
}
}
}
fn retention_key(path: &Path, prefix: &str) -> (String, u64) {
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default();
let name = name.strip_suffix(".gz").unwrap_or(name);
let rest = name.strip_prefix(prefix).unwrap_or(name);
if rest.len() > 15 && rest.as_bytes()[15] == b'-' {
(rest[..15].to_string(), rest[16..].parse().unwrap_or(0))
} else {
(rest.to_string(), 0)
}
}
impl Appender for FileAppender {
fn append(&self, event: &LogEvent) {
let line = self.layout.format(event);
let line_len = line.len() as u64 + 1;
let Ok(mut guard) = self.state.lock() else { return };
let needs_roll = self.max_size.is_some_and(|max| {
guard
.as_ref()
.is_some_and(|state| state.written > 0 && state.written + line_len > max)
});
if needs_roll && self.rotate(&mut guard).is_err() {
return;
}
let Some(state) = guard.as_mut() else { return };
if writeln!(state.writer, "{line}").is_ok() {
state.written += line_len;
let _ = state.writer.flush();
}
}
}
static LOGGER: RwLock<Option<Arc<Logger>>> = RwLock::new(None);
pub struct Logger {
level: LogLevel,
appenders: Vec<Box<dyn Appender>>,
}
pub struct LoggerBuilder {
level: LogLevel,
appenders: Vec<Box<dyn Appender>>,
}
impl LoggerBuilder {
pub fn level(mut self, level: LogLevel) -> Self {
self.level = level;
self
}
pub fn appender(mut self, appender: Box<dyn Appender>) -> Self {
self.appenders.push(appender);
self
}
pub fn build(self) -> Logger {
Logger {
level: self.level,
appenders: self.appenders,
}
}
pub fn init(self) {
Logger::set_logger(self.build());
}
}
fn get_now_str(sys_time: time::SystemTime) -> String {
use chrono::{Local, DateTime};
let dt = DateTime::<Local>::from(sys_time);
dt.format("%Y-%m-%d %H:%M:%S").to_string()
}
impl Logger {
pub fn builder(level: LogLevel) -> LoggerBuilder {
LoggerBuilder {
level,
appenders: Vec::new(),
}
}
pub fn set_logger(logger: Logger) {
*LOGGER.write().unwrap_or_else(|e| e.into_inner()) = Some(Arc::new(logger));
}
pub fn init(level: LogLevel) {
Logger::set_logger(Logger {
level,
appenders: vec![Box::new(ConsoleAppender::new(Box::new(SimpleLayout)))],
});
}
pub fn get() -> Option<Arc<Logger>> {
LOGGER.read().unwrap_or_else(|e| e.into_inner()).clone()
}
pub fn is_enabled(&self, level: LogLevel) -> bool {
level >= self.level
}
pub fn log(&self, level: LogLevel, message: &str) {
if self.is_enabled(level) {
let event = LogEvent {
level,
message,
timestamp: std::time::SystemTime::now(),
};
for appender in &self.appenders {
appender.append(&event);
}
}
}
}
#[macro_export]
macro_rules! trace {
($($arg:tt)*) => {
if let Some(logger) = $crate::Logger::get() {
if logger.is_enabled($crate::LogLevel::Trace) {
logger.log($crate::LogLevel::Trace, &format!($($arg)*));
}
}
};
}
#[macro_export]
macro_rules! debug {
($($arg:tt)*) => {
if let Some(logger) = $crate::Logger::get() {
if logger.is_enabled($crate::LogLevel::Debug) {
logger.log($crate::LogLevel::Debug, &format!($($arg)*));
}
}
};
}
#[macro_export]
macro_rules! info {
($($arg:tt)*) => {
if let Some(logger) = $crate::Logger::get() {
if logger.is_enabled($crate::LogLevel::Info) {
logger.log($crate::LogLevel::Info, &format!($($arg)*));
}
}
};
}
#[macro_export]
macro_rules! warn {
($($arg:tt)*) => {
if let Some(logger) = $crate::Logger::get() {
if logger.is_enabled($crate::LogLevel::Warn) {
logger.log($crate::LogLevel::Warn, &format!($($arg)*));
}
}
};
}
#[macro_export]
macro_rules! error {
($($arg:tt)*) => {
if let Some(logger) = $crate::Logger::get() {
if logger.is_enabled($crate::LogLevel::Error) {
logger.log($crate::LogLevel::Error, &format!($($arg)*));
}
}
};
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Read;
use std::sync::Arc;
static TEST_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn init_reconfigures_logger() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Logger::init(LogLevel::Error);
assert_eq!(Logger::get().unwrap().level, LogLevel::Error);
Logger::init(LogLevel::Trace);
assert_eq!(Logger::get().unwrap().level, LogLevel::Trace);
}
#[test]
fn is_enabled_filters_by_level() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
Logger::init(LogLevel::Info);
let logger = Logger::get().unwrap();
assert!(!logger.is_enabled(LogLevel::Trace));
assert!(!logger.is_enabled(LogLevel::Debug));
assert!(logger.is_enabled(LogLevel::Info));
assert!(logger.is_enabled(LogLevel::Warn));
assert!(logger.is_enabled(LogLevel::Error));
}
#[test]
fn custom_appender_receives_events() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
struct CaptureAppender(Arc<Mutex<Vec<String>>>);
impl Appender for CaptureAppender {
fn append(&self, event: &LogEvent) {
self.0.lock().unwrap().push(event.message.to_string());
}
}
let buffer = Arc::new(Mutex::new(Vec::new()));
Logger::builder(LogLevel::Info)
.appender(Box::new(CaptureAppender(buffer.clone())))
.init();
crate::info!("hello {}", 42);
crate::debug!("filtered at Info level");
let messages = buffer.lock().unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(messages[0], "hello 42");
}
#[test]
fn file_appender_writes_to_disk() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = std::env::temp_dir().join(format!("log4rc-test-{}.log", std::process::id()));
let _ = std::fs::remove_file(&path);
Logger::builder(LogLevel::Info)
.appender(Box::new(FileAppender::open(&path, Box::new(SimpleLayout)).unwrap()))
.init();
crate::info!("disk message");
crate::debug!("filtered");
let contents = std::fs::read_to_string(&path).unwrap();
assert!(contents.contains("disk message"));
assert!(!contents.contains("filtered"));
let _ = std::fs::remove_file(&path);
}
#[test]
fn file_appender_rotates_at_size_limit() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir();
let prefix = format!("log4rc-roll-test-{}", std::process::id());
let path = dir.join(format!("{prefix}.log"));
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
if name.to_str().is_some_and(|n| n.starts_with(&prefix)) {
let _ = std::fs::remove_file(entry.path());
}
}
Logger::builder(LogLevel::Info)
.appender(Box::new(
FileAppender::builder(&path, Box::new(SimpleLayout))
.max_size(100)
.build()
.unwrap(),
))
.init();
for i in 0..10 {
crate::info!("message number {i} with padding to make the line longer");
}
let mut total_lines = 0;
let mut file_count = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
file_count += 1;
total_lines += std::fs::read_to_string(entry.path())
.unwrap()
.lines()
.count();
let _ = std::fs::remove_file(entry.path());
}
assert_eq!(total_lines, 10, "all messages must survive rotation");
assert!(file_count > 1, "expected rotation to produce multiple files");
}
#[test]
fn rotated_files_are_compressed() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir();
let prefix = format!("log4rc-gzip-test-{}", std::process::id());
let path = dir.join(format!("{prefix}.log"));
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
if name.to_str().is_some_and(|n| n.starts_with(&prefix)) {
let _ = std::fs::remove_file(entry.path());
}
}
Logger::builder(LogLevel::Info)
.appender(Box::new(
FileAppender::builder(&path, Box::new(SimpleLayout))
.max_size(100)
.compressed()
.build()
.unwrap(),
))
.init();
for i in 0..10 {
crate::info!("message number {i} with padding to make the line longer");
}
let current = std::fs::read_to_string(&path).unwrap();
assert!(current.contains("message number 9"));
let mut recovered = String::new();
let mut rotated_count = 0;
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
if name != format!("{prefix}.log") {
rotated_count += 1;
assert!(
name.ends_with(".gz"),
"rotated file was not compressed: {name}"
);
let file = std::fs::File::open(entry.path()).unwrap();
let mut decoder = flate2::read::GzDecoder::new(file);
decoder.read_to_string(&mut recovered).unwrap();
}
let _ = std::fs::remove_file(entry.path());
}
assert!(rotated_count > 0, "expected at least one rotation");
for i in 0..9 {
assert!(
recovered.contains(&format!("message number {i}")),
"rotated logs are missing message {i}"
);
}
}
#[test]
fn file_appender_retains_only_max_files_rotations() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let dir = std::env::temp_dir();
let prefix = format!("log4rc-retain-test-{}", std::process::id());
let path = dir.join(format!("{prefix}.log"));
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
if name.to_str().is_some_and(|n| n.starts_with(&prefix)) {
let _ = std::fs::remove_file(entry.path());
}
}
Logger::builder(LogLevel::Info)
.appender(Box::new(
FileAppender::builder(&path, Box::new(SimpleLayout))
.max_size(100)
.max_files(2)
.build()
.unwrap(),
))
.init();
for i in 0..10 {
crate::info!("message number {i} with padding to make the line longer");
}
let current = std::fs::read_to_string(&path).unwrap();
assert!(current.contains("message number 9"));
let mut rotated_count = 0;
let mut rotated_contents = String::new();
for entry in std::fs::read_dir(&dir).unwrap().flatten() {
let name = entry.file_name();
let Some(name) = name.to_str() else { continue };
if !name.starts_with(&prefix) {
continue;
}
if name != format!("{prefix}.log") {
rotated_count += 1;
rotated_contents.push_str(&std::fs::read_to_string(entry.path()).unwrap());
}
let _ = std::fs::remove_file(entry.path());
}
assert!(
rotated_count <= 2,
"expected at most 2 rotated files, found {rotated_count}"
);
assert!(
rotated_contents.contains("message number 8"),
"the newest rotated file must be retained"
);
assert!(
!rotated_contents.contains("message number 0"),
"the oldest rotated file must be pruned"
);
}
#[test]
fn concurrent_threads_log_to_file_without_loss() {
let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let path = std::env::temp_dir().join(format!("log4rc-threaded-{}.log", std::process::id()));
let _ = std::fs::remove_file(&path);
Logger::builder(LogLevel::Info)
.appender(Box::new(FileAppender::open(&path, Box::new(SimpleLayout)).unwrap()))
.init();
let threads: Vec<_> = (0..4)
.map(|t| {
std::thread::spawn(move || {
for i in 0..25 {
crate::info!("thread {} message {}", t, i);
}
})
})
.collect();
for thread in threads {
thread.join().unwrap();
}
let contents = std::fs::read_to_string(&path).unwrap();
assert_eq!(contents.lines().count(), 100);
let _ = std::fs::remove_file(&path);
}
}