use {
chrono::{DateTime, FixedOffset, Local, NaiveTime, Timelike, Utc},
flate2::write::GzEncoder,
regex::Regex,
std::{
fmt::Debug,
fs::{self, DirEntry, Permissions},
io::{self, Write as _},
path::{Path, PathBuf},
sync::{PoisonError, RwLock},
thread::JoinHandle,
},
};
#[cfg(feature = "xz")]
use xz2::write::XzEncoder;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, Clone)]
pub enum RotationSize {
Bytes(u64),
KB(u64),
MB(u64),
GB(u64),
}
impl RotationSize {
fn bytes(&self) -> u64 {
match self {
RotationSize::Bytes(b) => *b,
RotationSize::KB(kb) => kb * 1024,
RotationSize::MB(mb) => mb * 1024 * 1024,
RotationSize::GB(gb) => gb * 1024 * 1024 * 1024,
}
}
}
#[derive(Debug, Clone)]
pub enum Compression {
Gzip,
#[cfg(feature = "xz")]
XZ(u32),
}
impl Compression {
fn get_extension(&self) -> &'static str {
match self {
Compression::Gzip => "gz",
#[cfg(feature = "xz")]
Compression::XZ(_) => "xz",
}
}
}
#[derive(Debug, Clone)]
pub enum TimeZone {
UTC,
Local,
Fix(FixedOffset),
}
#[derive(Debug, Clone)]
pub enum RotationAge {
Minutely,
Hourly,
Daily,
}
#[derive(Debug, Clone)]
pub enum Rotation {
SizeBased(RotationSize),
AgeBased(RotationAge),
}
#[derive(Clone)]
struct LogRollerMeta {
directory: PathBuf,
filename: PathBuf,
rotation: Rotation,
time_zone: FixedOffset,
compression: Option<Compression>,
max_keep_files: Option<u64>,
suffix: Option<String>,
file_mode: Option<u32>,
graceful_shutdown: bool,
}
struct LogRollerState {
next_size_based_index: usize,
next_age_based_time: DateTime<FixedOffset>,
curr_file_path: PathBuf,
curr_file_size_bytes: u64,
}
impl LogRollerState {
fn get_next_size_based_index(directory: &PathBuf, filename: &Path) -> usize {
let mut max_suffix = 0;
if !directory.is_dir() {
return max_suffix;
}
if let Ok(files) = std::fs::read_dir(directory) {
for file in files.flatten() {
if let Some(exist_file) = file.file_name().to_str() {
if !exist_file.starts_with(filename.to_string_lossy().as_ref()) {
continue;
}
if let Some(suffix_str) = exist_file.strip_prefix(&format!("{}.", filename.to_string_lossy())) {
if let Some(index_num) = suffix_str.split('.').next() {
if let Ok(suffix) = index_num.parse::<usize>() {
max_suffix = std::cmp::max(max_suffix, suffix);
}
};
}
}
}
}
max_suffix + 1
}
fn get_curr_size_based_file_size(log_path: &Path) -> u64 {
std::fs::metadata(log_path).map_or(0, |m| m.len())
}
}
pub struct LogRoller {
meta: LogRollerMeta,
state: LogRollerState,
writer: RwLock<fs::File>,
compressing_handle: Option<JoinHandle<()>>,
}
impl LogRoller {
fn should_rollover(meta: &LogRollerMeta, state: &LogRollerState) -> Option<PathBuf> {
match &meta.rotation {
Rotation::SizeBased(rotation_size) => {
if state.curr_file_size_bytes >= rotation_size.bytes() {
return Some(meta.directory.join(PathBuf::from(
format!("{}.1", meta.filename.as_path().to_string_lossy(),).to_string(),
)));
}
}
Rotation::AgeBased(rotation_age) => {
let now = meta.now();
let next_time = state.next_age_based_time;
if now >= next_time {
return Some(meta.get_next_age_based_log_path(rotation_age, &next_time));
}
}
}
None
}
}
impl LogRollerMeta {
fn now(&self) -> DateTime<FixedOffset> {
Utc::now().with_timezone(&self.time_zone)
}
#[allow(deprecated)]
fn replace_time(&self, base_datetime: DateTime<FixedOffset>, time_to_replaced: NaiveTime) -> DateTime<FixedOffset> {
DateTime::<FixedOffset>::from_local(
base_datetime.date_naive().and_time(time_to_replaced),
*base_datetime.offset(),
)
}
fn next_time(
&self,
base_datetime: DateTime<FixedOffset>,
rotation_age: RotationAge,
) -> Result<DateTime<FixedOffset>, LogRollerError> {
match rotation_age {
RotationAge::Minutely => {
let d = base_datetime + chrono::Duration::minutes(1);
Ok(self.replace_time(
d,
NaiveTime::from_hms_opt(d.hour(), d.minute(), 0).ok_or(LogRollerError::GetNaiveTimeFailed)?,
))
}
RotationAge::Hourly => {
let d = base_datetime + chrono::Duration::hours(1);
Ok(self.replace_time(
d,
NaiveTime::from_hms_opt(d.hour(), 0, 0).ok_or(LogRollerError::GetNaiveTimeFailed)?,
))
}
RotationAge::Daily => {
let d = base_datetime + chrono::Duration::days(1);
Ok(self.replace_time(
d,
NaiveTime::from_hms_opt(0, 0, 0).ok_or(LogRollerError::GetNaiveTimeFailed)?,
))
}
}
}
fn create_log_file(&self, log_path: &Path) -> Result<fs::File, LogRollerError> {
let mut open_options = fs::OpenOptions::new();
open_options.append(true).create(true);
let mut create_log_file_res = open_options.open(log_path);
if create_log_file_res.is_err() {
if let Some(parent) = log_path.parent() {
fs::create_dir_all(parent)
.map_err(|err| LogRollerError::CreateDirectoryFailed(parent.to_path_buf(), err.to_string()))?;
create_log_file_res = open_options.open(log_path);
}
}
let log_file = create_log_file_res
.map_err(|err| LogRollerError::CreateFileFailed(log_path.to_path_buf(), err.to_string()))?;
self.set_permissions(log_path)?;
Ok(log_file)
}
fn process_old_logs(&self, log_path: &PathBuf) -> Result<(), LogRollerError> {
self.compress(log_path)?;
if let Some(max_keep_files) = self.max_keep_files {
let all_log_files = Self::list_all_files(
&self.directory,
self.filename.as_path().as_os_str().to_string_lossy().as_ref(),
&self.rotation,
&self.suffix,
&self.compression,
)?;
match &self.rotation {
Rotation::SizeBased(_) => {
for file in all_log_files {
if let Some(index) = file
.path()
.file_name()
.and_then(|s| s.to_str())
.and_then(|s| {
let mut parts = s.split('.').collect::<Vec<&str>>();
if self.compression.is_some() {
parts.pop();
}
parts.last().cloned()
})
.and_then(|s| s.parse::<usize>().ok())
{
if index >= max_keep_files as usize {
let path = file.path();
if let Err(err) = fs::remove_file(&path) {
eprintln!("Failed to remove old log file '{}': {}", path.display(), err);
}
}
}
}
}
Rotation::AgeBased(_) => {
if all_log_files.len() > max_keep_files as usize {
for file in all_log_files.iter().take(all_log_files.len() - max_keep_files as usize) {
let path = file.path();
if let Err(err) = fs::remove_file(&path) {
eprintln!("Failed to remove old log file '{}': {}", path.display(), err);
}
}
}
}
}
}
Ok(())
}
fn list_all_files(
directory: &PathBuf,
filename: &str,
rotation: &Rotation,
file_suffix: &Option<String>,
compression: &Option<Compression>,
) -> Result<Vec<DirEntry>, LogRollerError> {
let file_suffix = file_suffix.clone().map(|s| format!("(.{s})?")).unwrap_or_default();
let compression_suffix = compression
.clone()
.map(|c| format!("(.{})?", c.get_extension()))
.unwrap_or_default();
let file_pattern = match rotation {
Rotation::SizeBased(_) => Regex::new(&format!(r"^{filename}(\.\d+)?{file_suffix}{compression_suffix}$"))
.map_err(|err| LogRollerError::InternalError(err.to_string()))?,
Rotation::AgeBased(rotation_age) => {
let pattern = match rotation_age {
RotationAge::Minutely => r"\d{4}-\d{2}-\d{2}-\d{2}-\d{2}",
RotationAge::Hourly => r"\d{4}-\d{2}-\d{2}-\d{2}",
RotationAge::Daily => r"\d{4}-\d{2}-\d{2}",
};
Regex::new(&format!(r"^{filename}\.{pattern}{file_suffix}{compression_suffix}$"))
.map_err(|err| LogRollerError::InternalError(err.to_string()))?
}
};
let files = fs::read_dir(directory).map_err(|err| LogRollerError::InternalError(err.to_string()))?;
let mut all_log_files = Vec::new();
for file in files.flatten() {
let metadata = file.metadata().map_err(LogRollerError::FileIOError)?;
if !metadata.is_file() {
continue;
}
if let Some(file_name) = file.file_name().to_str() {
if file_pattern.is_match(file_name) {
all_log_files.push(file);
}
}
}
all_log_files.sort_by_key(|f| f.file_name());
Ok(all_log_files)
}
fn compress(&self, log_path: &PathBuf) -> Result<(), LogRollerError> {
let compression = match &self.compression {
Some(compression) => compression,
None => {
return Ok(());
}
};
let infile = fs::File::open(log_path).map_err(LogRollerError::FileIOError)?;
let reader = io::BufReader::new(infile);
let compressed_path = PathBuf::from(format!(
"{}.{}",
log_path.to_string_lossy(),
compression.get_extension()
));
let outfile = fs::File::create(&compressed_path).map_err(LogRollerError::FileIOError)?;
let writer = io::BufWriter::new(outfile);
match compression {
Compression::Gzip => {
let mut encoder = GzEncoder::new(writer, flate2::Compression::default());
io::copy(&mut io::Read::take(reader, u64::MAX), &mut encoder)?;
encoder.finish()?;
}
#[cfg(feature = "xz")]
Compression::XZ(level) => {
let mut encoder = XzEncoder::new(writer, *level);
io::copy(&mut io::Read::take(reader, u64::MAX), &mut encoder)?;
encoder.finish()?;
}
}
self.set_permissions(&compressed_path)?;
fs::remove_file(log_path).map_err(LogRollerError::FileIOError)?;
Ok(())
}
fn set_permissions(&self, path: &Path) -> Result<(), LogRollerError> {
if let Some(mode) = self.file_mode {
#[cfg(unix)]
{
let perms = Permissions::from_mode(mode);
fs::set_permissions(path, perms).map_err(|err| LogRollerError::SetFilePermissionsError {
path: path.to_path_buf(),
error: err.to_string(),
})?
}
#[cfg(not(unix))]
{
eprintln!("Warning: Setting file permissions is not supported on non-Unix platforms");
}
}
Ok(())
}
fn refresh_writer(
&self,
writer: &mut fs::File,
old_log_path: PathBuf,
new_log_path: PathBuf,
next_size_based_index: usize,
compression: &Option<Compression>,
) -> Result<JoinHandle<()>, LogRollerError> {
let meta = self.to_owned();
let handle = match &self.rotation {
Rotation::SizeBased(_) => {
let curr_log_path = self.directory.join(&self.filename);
for idx in (1..next_size_based_index).rev() {
let source_file = self
.directory
.join(format!("{}.{}", self.filename.to_string_lossy(), idx));
let target_file = self
.directory
.join(format!("{}.{}", self.filename.to_string_lossy(), idx + 1));
if source_file.exists() {
std::fs::rename(&source_file, &target_file).map_err(|err| LogRollerError::RenameFileError {
from: source_file.clone(),
to: target_file.clone(),
error: err.to_string(),
})?;
}
if let Some(compression) = &compression {
let compressed_source_file = self.directory.join(format!(
"{}.{}.{}",
self.filename.to_string_lossy(),
idx,
compression.get_extension()
));
let compressed_target_file = self.directory.join(format!(
"{}.{}.{}",
self.filename.to_string_lossy(),
idx + 1,
compression.get_extension()
));
if compressed_source_file.exists() {
std::fs::rename(&compressed_source_file, &compressed_target_file).map_err(|err| {
LogRollerError::RenameFileError {
from: compressed_source_file.clone(),
to: compressed_target_file.clone(),
error: err.to_string(),
}
})?;
}
}
}
std::fs::rename(&curr_log_path, &new_log_path).map_err(|err| LogRollerError::RenameFileError {
from: curr_log_path.clone(),
to: new_log_path.clone(),
error: err.to_string(),
})?;
let new_log_file = match self.create_log_file(&curr_log_path) {
Ok(file) => file,
Err(err) => {
eprintln!("Failed to create new log file '{}': {}", curr_log_path.display(), err);
return Err(err);
}
};
if let Err(err) = writer.flush() {
eprintln!("Failed to flush writer: {err}");
return Err(LogRollerError::FileIOError(err));
}
*writer = new_log_file;
std::thread::spawn(move || {
if let Err(err) = meta.process_old_logs(&new_log_path) {
eprintln!(
"Failed to process old log files for '{}': {}",
new_log_path.display(),
err
);
}
})
}
Rotation::AgeBased(_) => {
let new_log_file = match self.create_log_file(&new_log_path) {
Ok(file) => file,
Err(err) => {
eprintln!("Failed to create new log file '{}': {}", new_log_path.display(), err);
return Err(err);
}
};
if let Err(err) = writer.flush() {
eprintln!("Failed to flush writer for '{}': {}", new_log_path.display(), err);
return Err(LogRollerError::FileIOError(err));
}
*writer = new_log_file;
std::thread::spawn(move || {
if let Err(err) = meta.process_old_logs(&old_log_path) {
eprintln!(
"Failed to process old log files for '{}': {}",
old_log_path.display(),
err
);
}
})
}
};
Ok(handle)
}
}
impl LogRollerMeta {
fn new<P: AsRef<Path>>(directory: P, filename: P) -> Self {
LogRollerMeta {
directory: directory.as_ref().to_path_buf(),
filename: filename.as_ref().to_path_buf(),
rotation: Rotation::AgeBased(RotationAge::Daily),
compression: None,
max_keep_files: None,
time_zone: Local::now().offset().to_owned(),
suffix: None,
file_mode: None,
graceful_shutdown: false,
}
}
fn get_next_age_based_log_path(&self, rotation_age: &RotationAge, datetime: &DateTime<FixedOffset>) -> PathBuf {
let path_fn = |pattern: &str| -> PathBuf {
let mut tf = datetime
.format(&format!("{}.{pattern}", self.filename.as_path().to_string_lossy()))
.to_string();
if let Some(suffix) = &self.suffix {
tf = format!("{tf}.{suffix}");
}
self.directory.join(PathBuf::from(tf))
};
match rotation_age {
RotationAge::Minutely => path_fn("%Y-%m-%d-%H-%M"),
RotationAge::Hourly => path_fn("%Y-%m-%d-%H"),
RotationAge::Daily => path_fn("%Y-%m-%d"),
}
}
fn get_curr_log_path(&self) -> PathBuf {
match &self.rotation {
Rotation::SizeBased(_) => self.directory.join(self.filename.as_path()),
Rotation::AgeBased(rotation_age) => self.get_next_age_based_log_path(rotation_age, &self.now()),
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum LogRollerError {
#[error("Failed to create directory '{0}': {1}")]
CreateDirectoryFailed(PathBuf, String),
#[error("Failed to create file '{0}': {1}")]
CreateFileFailed(PathBuf, String),
#[error("Failed to get native time: Invalid time format")]
GetNaiveTimeFailed,
#[error("Invalid rotation type: {0}")]
InvalidRotationType(String),
#[error("Failed to get next file path for '{0}'")]
GetNextFilePathError(PathBuf),
#[error("Failed to rename file from '{from}' to '{to}': {error}")]
RenameFileError { from: PathBuf, to: PathBuf, error: String },
#[error("File IO error: {0}")]
FileIOError(#[from] std::io::Error),
#[error("Should not rotate log file '{0}' at this time")]
ShouldNotRotate(PathBuf),
#[error("Internal error: {0}")]
InternalError(String),
#[error("Failed to set file permissions for '{path}': {error}")]
SetFilePermissionsError { path: PathBuf, error: String },
#[cfg(feature = "xz")]
#[error("Invalid XZ compression level {level}. Must be 0 ≤ n ≤ 9")]
InvalidXZCompressionLevel { level: u32 },
}
pub struct LogRollerBuilder {
meta: LogRollerMeta,
}
impl LogRollerBuilder {
pub fn new<P: AsRef<Path>>(directory: P, filename: P) -> Self {
LogRollerBuilder {
meta: LogRollerMeta::new(directory, filename),
}
}
pub fn time_zone(self, time_zone: TimeZone) -> Self {
Self {
meta: LogRollerMeta {
time_zone: match time_zone {
TimeZone::UTC => Utc::now().fixed_offset().offset().to_owned(),
TimeZone::Local => Local::now().offset().to_owned(),
TimeZone::Fix(fixed_offset) => fixed_offset,
},
..self.meta
},
}
}
pub fn rotation(self, rotation: Rotation) -> Self {
Self {
meta: LogRollerMeta { rotation, ..self.meta },
}
}
pub fn compression(self, compression: Compression) -> Self {
Self {
meta: LogRollerMeta {
compression: Some(compression),
..self.meta
},
}
}
pub fn max_keep_files(self, max_keep_files: u64) -> Self {
Self {
meta: LogRollerMeta {
max_keep_files: Some(max_keep_files),
..self.meta
},
}
}
pub fn suffix(self, suffix: String) -> Self {
Self {
meta: LogRollerMeta {
suffix: Some(suffix),
..self.meta
},
}
}
pub fn file_mode(self, mode: u32) -> Self {
Self {
meta: LogRollerMeta {
file_mode: Some(mode),
..self.meta
},
}
}
pub fn graceful_shutdown(self, graceful_shutdown: bool) -> Self {
Self {
meta: LogRollerMeta {
graceful_shutdown,
..self.meta
},
}
}
pub fn build(self) -> Result<LogRoller, LogRollerError> {
let curr_file_path = self.meta.get_curr_log_path();
let mut next_size_based_index =
LogRollerState::get_next_size_based_index(&self.meta.directory, &self.meta.filename);
if let Some(max_keep_files) = self.meta.max_keep_files {
next_size_based_index = next_size_based_index.min(max_keep_files as usize);
}
#[cfg(feature = "xz")]
if let Some(Compression::XZ(level)) = self.meta.compression {
if level > 9 {
return Err(LogRollerError::InvalidXZCompressionLevel { level });
}
}
Ok(LogRoller {
meta: self.meta.to_owned(),
state: LogRollerState {
next_size_based_index,
next_age_based_time: self.meta.next_time(
self.meta.now(),
match &self.meta.rotation {
Rotation::AgeBased(rotation_age) => rotation_age.to_owned(),
_ => RotationAge::Daily,
},
)?,
curr_file_path: curr_file_path.to_owned(),
curr_file_size_bytes: LogRollerState::get_curr_size_based_file_size(
&self.meta.directory.join(&self.meta.filename),
),
},
writer: RwLock::new(self.meta.create_log_file(&curr_file_path)?),
compressing_handle: None,
})
}
}
#[allow(clippy::io_other_error)]
impl io::Write for LogRoller {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
let writer = self.writer.get_mut().unwrap_or_else(PoisonError::into_inner);
let old_log_path = self.state.curr_file_path.to_owned();
let next_size_based_index = self.state.next_size_based_index;
let compression = self.meta.compression.to_owned();
let bytes = writer.write(buf)?;
self.state.curr_file_size_bytes += bytes as u64;
if let Some(handle) = &self.compressing_handle {
if !handle.is_finished() {
return Ok(bytes);
}
};
if let Some(new_log_path) = Self::should_rollover(&self.meta, &self.state) {
let handle = self
.meta
.refresh_writer(
writer,
old_log_path,
new_log_path.to_owned(),
next_size_based_index,
&compression,
)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?;
self.compressing_handle = Some(handle);
self.state.curr_file_path.clone_from(&new_log_path);
match &self.meta.rotation {
Rotation::SizeBased(_) => {
self.state.curr_file_size_bytes = 0;
self.state.next_size_based_index += 1;
if let Some(max_keep_files) = self.meta.max_keep_files {
self.state.next_size_based_index =
self.state.next_size_based_index.min(max_keep_files as usize);
}
}
Rotation::AgeBased(rotation_age) => {
self.state.curr_file_size_bytes = 0;
self.state.next_age_based_time = self
.meta
.next_time(self.meta.now(), rotation_age.to_owned())
.map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?;
}
}
}
Ok(bytes)
}
fn flush(&mut self) -> io::Result<()> {
self.writer.get_mut().unwrap_or_else(PoisonError::into_inner).flush()?;
if !self.meta.graceful_shutdown {
return Ok(());
}
if let Some(handle) = self.compressing_handle.take() {
let _ = handle.join();
}
Ok(())
}
}
#[cfg(feature = "tracing")]
#[deprecated(
since = "0.1.9",
note = "Use LogRoller directly as an appender with tracing_appender::non_blocking"
)]
type _TracingFeatureIsDeprecated = ();