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::mpsc,
thread::JoinHandle,
},
};
#[cfg(feature = "xz")]
use xz2::write::XzEncoder;
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
struct PostRotationJob {
log_path: PathBuf,
meta: LogRollerMeta,
}
fn join_error_to_string(payload: Box<dyn std::any::Any + Send>) -> String {
match payload.downcast::<String>() {
Ok(message) => *message,
Err(payload) => match payload.downcast::<&'static str>() {
Ok(message) => (*message).to_string(),
Err(_) => "unknown panic payload".to_string(),
},
}
}
fn run_rotation_worker(rx: mpsc::Receiver<PostRotationJob>) {
const IDLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
loop {
match rx.recv_timeout(IDLE_TIMEOUT) {
Ok(job) => {
if let Err(err) = job.meta.process_old_logs(&job.log_path) {
eprintln!(
"Failed to process old log files for '{}': {}",
job.log_path.display(),
err
);
}
while let Ok(job) = rx.try_recv() {
if let Err(err) = job.meta.process_old_logs(&job.log_path) {
eprintln!(
"Failed to process old log files for '{}': {}",
job.log_path.display(),
err
);
}
}
}
Err(mpsc::RecvTimeoutError::Timeout) => break,
Err(mpsc::RecvTimeoutError::Disconnected) => break,
}
}
}
#[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,
file_pattern: Option<Regex>,
is_filename_template: bool,
}
struct LogRollerState {
next_pending_sequence: u64,
next_age_based_time: DateTime<FixedOffset>,
curr_file_path: PathBuf,
curr_file_size_bytes: u64,
}
impl LogRollerState {
fn get_next_pending_sequence(directory: &Path, filename: &Path) -> u64 {
let mut max_suffix: u64 = 0;
if !directory.is_dir() {
return max_suffix;
}
let prefix = format!("{}.pending.", filename.to_string_lossy());
if let Ok(files) = std::fs::read_dir(directory) {
for file in files.flatten() {
if let Some(name) = file.file_name().to_str() {
if let Some(suffix_str) = name.strip_prefix(&prefix) {
if let Ok(suffix) = suffix_str.parse::<u64>() {
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: fs::File,
worker_tx: Option<mpsc::Sender<PostRotationJob>>,
worker_handle: Option<JoinHandle<()>>,
}
impl LogRoller {
#[inline]
fn should_rollover(meta: &LogRollerMeta, state: &LogRollerState, pending_bytes: u64) -> Option<PathBuf> {
match &meta.rotation {
Rotation::SizeBased(rotation_size) => {
if state.curr_file_size_bytes.saturating_add(pending_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
}
fn post_rotation(&mut self, log_path: PathBuf) {
let meta = self.meta.clone();
if let Some(tx) = self.worker_tx.take() {
match tx.send(PostRotationJob { log_path, meta }) {
Ok(()) => {
self.worker_tx = Some(tx);
}
Err(mpsc::SendError(job)) => {
Self::join_worker(&mut self.worker_handle, "worker restart");
let (tx, rx) = mpsc::channel();
self.worker_handle = Some(std::thread::spawn(move || run_rotation_worker(rx)));
self.worker_tx = Some(tx.clone());
if let Err(mpsc::SendError(job)) = tx.send(job) {
eprintln!(
"Failed to resubmit post-rotation job for '{}' after restarting worker",
job.log_path.display()
);
}
}
}
} else {
let (tx, rx) = mpsc::channel();
self.worker_handle = Some(std::thread::spawn(move || run_rotation_worker(rx)));
self.worker_tx = Some(tx.clone());
if let Err(mpsc::SendError(job)) = tx.send(PostRotationJob { log_path, meta }) {
eprintln!(
"Failed to submit post-rotation job for '{}' to a newly spawned worker",
job.log_path.display()
);
}
}
}
fn join_worker(handle: &mut Option<JoinHandle<()>>, context: &str) {
if let Some(h) = handle.take() {
if let Err(err) = h.join() {
eprintln!(
"Rotation worker panicked during {context}: {}",
join_error_to_string(err)
);
}
}
}
}
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: &Path) -> Result<(), LogRollerError> {
match &self.rotation {
Rotation::SizeBased(_) => self.publish_size_based_log(log_path),
Rotation::AgeBased(_) => {
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.file_pattern.as_ref().expect("file_pattern not initialized"),
)?;
let processed: Vec<_> = match &self.compression {
Some(c) => all_log_files
.iter()
.filter(|f| {
f.file_name()
.to_string_lossy()
.ends_with(&format!(".{}", c.get_extension()))
})
.collect(),
None => all_log_files.iter().collect(),
};
if processed.len() > max_keep_files as usize {
for file in processed.iter().take(processed.len() - max_keep_files as usize) {
let path = file.path();
if let Err(err) = Self::remove_file_if_exists(&path) {
eprintln!("Failed to remove old log file '{}': {}", path.display(), err);
}
}
}
}
Ok(())
}
}
}
fn list_all_files(directory: &Path, file_pattern: &Regex) -> Result<Vec<DirEntry>, LogRollerError> {
let files = match fs::read_dir(directory) {
Ok(files) => files,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(LogRollerError::InternalError(err.to_string())),
};
let mut all_log_files = Vec::new();
for file in files.flatten() {
let file_name = file.file_name();
let file_name_str = match file_name.to_str() {
Some(name) => name,
None => continue,
};
if !file_pattern.is_match(file_name_str) {
continue;
}
let metadata = match file.metadata() {
Ok(metadata) => metadata,
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
Err(err) => return Err(LogRollerError::FileIOError(err)),
};
if !metadata.is_file() {
continue;
}
all_log_files.push(file);
}
all_log_files.sort_by_key(|f| f.file_name());
Ok(all_log_files)
}
fn compress_to_path(&self, log_path: &Path, compressed_path: &Path) -> 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 mut reader = io::BufReader::new(infile);
let outfile = fs::File::create(compressed_path).map_err(LogRollerError::FileIOError)?;
let writer = outfile;
match compression {
Compression::Gzip => {
let mut encoder = GzEncoder::new(writer, flate2::Compression::default());
io::copy(&mut reader, &mut encoder)?;
encoder.finish()?;
}
#[cfg(feature = "xz")]
Compression::XZ(level) => {
let mut encoder = XzEncoder::new(writer, *level);
io::copy(&mut reader, &mut encoder)?;
encoder.finish()?;
}
}
self.set_permissions(compressed_path)?;
Ok(())
}
fn compress(&self, log_path: &Path) -> Result<(), LogRollerError> {
let compression = match &self.compression {
Some(c) => c,
None => return Ok(()),
};
let compressed_path = PathBuf::from(format!(
"{}.{}",
log_path.to_string_lossy(),
compression.get_extension()
));
self.compress_to_path(log_path, &compressed_path)?;
Self::remove_file_if_exists(log_path)?;
Ok(())
}
fn rename_if_exists(from: &Path, to: &Path) -> Result<(), LogRollerError> {
match fs::rename(from, to) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(LogRollerError::RenameFileError {
from: from.to_path_buf(),
to: to.to_path_buf(),
error: err.to_string(),
}),
}
}
fn remove_file_if_exists(path: &Path) -> Result<(), LogRollerError> {
match fs::remove_file(path) {
Ok(()) => Ok(()),
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(LogRollerError::FileIOError(err)),
}
}
fn get_size_based_archive_path(&self, index: usize) -> PathBuf {
self.directory
.join(format!("{}.{index}", self.filename.to_string_lossy()))
}
fn get_size_based_compressed_archive_path(&self, index: usize, compression: &Compression) -> PathBuf {
self.directory.join(format!(
"{}.{index}.{}",
self.filename.to_string_lossy(),
compression.get_extension()
))
}
fn get_size_based_compression_temp_path(&self, pending_log_path: &Path, compression: &Compression) -> PathBuf {
PathBuf::from(format!(
"{}.{}.tmp",
pending_log_path.to_string_lossy(),
compression.get_extension()
))
}
fn parse_size_based_archive_index(file_name: &str) -> Option<usize> {
let mut parts: Vec<&str> = file_name.split('.').collect();
if parts.len() < 2 {
return None;
}
if parts.last().and_then(|segment| segment.parse::<usize>().ok()).is_none() {
parts.pop();
}
parts.last().and_then(|segment| segment.parse::<usize>().ok())
}
fn max_size_based_archive_index(&self) -> Result<usize, LogRollerError> {
let all_log_files = Self::list_all_files(
&self.directory,
self.file_pattern.as_ref().expect("file_pattern not initialized"),
)?;
let mut max_index = 0;
for file in all_log_files {
if let Some(index) = file
.path()
.file_name()
.and_then(|s| s.to_str())
.and_then(Self::parse_size_based_archive_index)
{
max_index = max_index.max(index);
}
}
Ok(max_index)
}
fn shift_size_based_archives(&self) -> Result<(), LogRollerError> {
let max_index = self.max_size_based_archive_index()?;
for idx in (1..=max_index).rev() {
let source_file = self.get_size_based_archive_path(idx);
let target_file = self.get_size_based_archive_path(idx + 1);
Self::rename_if_exists(&source_file, &target_file)?;
if let Some(compression) = &self.compression {
let compressed_source_file = self.get_size_based_compressed_archive_path(idx, compression);
let compressed_target_file = self.get_size_based_compressed_archive_path(idx + 1, compression);
Self::rename_if_exists(&compressed_source_file, &compressed_target_file)?;
}
}
Ok(())
}
fn prune_size_based_archives(&self) -> Result<(), LogRollerError> {
let Some(max_keep_files) = self.max_keep_files else {
return Ok(());
};
let all_log_files = Self::list_all_files(
&self.directory,
self.file_pattern.as_ref().expect("file_pattern not initialized"),
)?;
for file in all_log_files {
if let Some(index) = file
.path()
.file_name()
.and_then(|s| s.to_str())
.and_then(Self::parse_size_based_archive_index)
{
if index > max_keep_files as usize {
let path = file.path();
if let Err(err) = Self::remove_file_if_exists(&path) {
eprintln!("Failed to remove old log file '{}': {}", path.display(), err);
}
}
}
}
Ok(())
}
fn publish_size_based_log(&self, pending_log_path: &Path) -> Result<(), LogRollerError> {
match &self.compression {
Some(compression) => {
let compressed_pending_path = self.get_size_based_compression_temp_path(pending_log_path, compression);
self.compress_to_path(pending_log_path, &compressed_pending_path)?;
self.shift_size_based_archives()?;
let target_path = self.get_size_based_compressed_archive_path(1, compression);
fs::rename(&compressed_pending_path, &target_path).map_err(|err| LogRollerError::RenameFileError {
from: compressed_pending_path.clone(),
to: target_path.clone(),
error: err.to_string(),
})?;
Self::remove_file_if_exists(pending_log_path)?;
}
None => {
self.shift_size_based_archives()?;
let target_path = self.get_size_based_archive_path(1);
Self::rename_if_exists(pending_log_path, &target_path)?;
}
}
self.prune_size_based_archives()
}
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,
rotated_log_path: PathBuf,
) -> Result<PathBuf, LogRollerError> {
match &self.rotation {
Rotation::SizeBased(_) => {
let curr_log_path = self.directory.join(&self.filename);
std::fs::rename(&curr_log_path, &rotated_log_path).map_err(|err| LogRollerError::RenameFileError {
from: curr_log_path.clone(),
to: rotated_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;
Ok(rotated_log_path)
}
Rotation::AgeBased(_) => {
let new_log_file = match self.create_log_file(&rotated_log_path) {
Ok(file) => file,
Err(err) => {
eprintln!(
"Failed to create new log file '{}': {}",
rotated_log_path.display(),
err
);
return Err(err);
}
};
if let Err(err) = writer.flush() {
eprintln!("Failed to flush writer for '{}': {}", rotated_log_path.display(), err);
return Err(LogRollerError::FileIOError(err));
}
*writer = new_log_file;
Ok(old_log_path)
}
}
}
}
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,
file_pattern: None,
is_filename_template: false,
}
}
fn build_file_pattern(
filename: &str,
rotation: &Rotation,
file_suffix: &Option<String>,
compression: &Option<Compression>,
is_filename_template: bool,
) -> Result<Regex, LogRollerError> {
let file_suffix = file_suffix.as_ref().map(|s| format!("(.{s})?")).unwrap_or_default();
let compression_suffix = compression
.as_ref()
.map(|c| format!("(.{})?", c.get_extension()))
.unwrap_or_default();
match rotation {
Rotation::SizeBased(_) => {
let escaped_filename = regex::escape(filename);
Regex::new(&format!(r"^{escaped_filename}\.\d+{file_suffix}{compression_suffix}$"))
.map_err(|err| LogRollerError::InternalError(err.to_string()))
}
Rotation::AgeBased(rotation_age) => {
let pattern = if is_filename_template {
Self::template_to_regex(filename)
} else {
let escaped_filename = regex::escape(filename);
let date_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}",
};
format!("{escaped_filename}\\.{date_pattern}")
};
Regex::new(&format!(r"^{pattern}{file_suffix}{compression_suffix}$"))
.map_err(|err| LogRollerError::InternalError(err.to_string()))
}
}
}
fn has_strftime_tokens(s: &str) -> bool {
s.contains('%')
}
fn token_level(token: char) -> Option<u32> {
match token {
'Y' => Some(1),
'm' => Some(2),
'd' => Some(3),
'H' => Some(4),
'M' => Some(5),
'S' => Some(6),
_ => None,
}
}
fn validate_template(template: &str, rotation_age: &RotationAge) -> Result<(), LogRollerError> {
if template.contains('/') || template.contains('\\') {
return Err(LogRollerError::InvalidFilenameTemplate(
"filename template must not contain path separators".into(),
));
}
let mut finest_level: u32 = 0;
let mut finest_token: Option<char> = None;
let mut chars = template.chars();
while let Some(ch) = chars.next() {
if ch == '%' {
match chars.next() {
Some(tok) => match Self::token_level(tok) {
Some(level) => {
if level > finest_level {
finest_level = level;
finest_token = Some(tok);
}
}
None => {
return Err(LogRollerError::InvalidFilenameTemplate(format!(
"unsupported strftime token: %{tok}"
)));
}
},
None => {
return Err(LogRollerError::InvalidFilenameTemplate(
"stray '%' at end of filename template".into(),
));
}
}
}
}
let required = match rotation_age {
RotationAge::Daily => 3,
RotationAge::Hourly => 4,
RotationAge::Minutely => 5,
};
if finest_level == 0 {
return Err(LogRollerError::InvalidFilenameTemplate(
"filename contains '%' but no recognised strftime tokens".into(),
));
}
if finest_level < required {
let age_name = match rotation_age {
RotationAge::Daily => "daily",
RotationAge::Hourly => "hourly",
RotationAge::Minutely => "minutely",
};
let needed_token = match rotation_age {
RotationAge::Daily => "%d",
RotationAge::Hourly => "%H",
RotationAge::Minutely => "%M",
};
let found = finest_token.map_or_else(String::new, |t| format!("%{t}"));
return Err(LogRollerError::InvalidFilenameTemplate(format!(
"filename template needs {needed_token} (or finer) for {age_name} rotation, \
but the finest token found is {found}",
)));
}
Ok(())
}
fn template_to_regex(template: &str) -> String {
let mut regex = String::new();
let mut chars = template.chars();
while let Some(ch) = chars.next() {
if ch == '%' {
match chars.next() {
Some('Y') => regex.push_str(r"\d{4}"),
Some('m' | 'd' | 'H' | 'M' | 'S') => regex.push_str(r"\d{2}"),
Some(other) => {
regex.push_str(®ex::escape(&format!("%{other}")));
}
None => break,
}
} else {
regex.push_str(®ex::escape(&ch.to_string()));
}
}
regex
}
fn get_next_age_based_log_path(&self, rotation_age: &RotationAge, datetime: &DateTime<FixedOffset>) -> PathBuf {
let mut tf = if self.is_filename_template {
datetime
.format(self.filename.as_path().to_string_lossy().as_ref())
.to_string()
} else {
let pattern = match rotation_age {
RotationAge::Minutely => "%Y-%m-%d-%H-%M",
RotationAge::Hourly => "%Y-%m-%d-%H",
RotationAge::Daily => "%Y-%m-%d",
};
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))
}
fn get_pending_log_path(&self, sequence: u64) -> PathBuf {
self.directory
.join(format!("{}.pending.{sequence}", self.filename.to_string_lossy()))
}
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("Invalid filename template: {0}")]
InvalidFilenameTemplate(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(mut self) -> Result<LogRoller, LogRollerError> {
let filename_str = self.meta.filename.as_path().to_string_lossy();
let is_template = LogRollerMeta::has_strftime_tokens(filename_str.as_ref());
if is_template {
match &self.meta.rotation {
Rotation::AgeBased(rotation_age) => {
LogRollerMeta::validate_template(filename_str.as_ref(), rotation_age)?;
}
_ => {
return Err(LogRollerError::InvalidFilenameTemplate(
"filename templates are only supported with age-based rotation".into(),
));
}
}
}
self.meta.is_filename_template = is_template;
self.meta.file_pattern = Some(LogRollerMeta::build_file_pattern(
filename_str.as_ref(),
&self.meta.rotation,
&self.meta.suffix,
&self.meta.compression,
self.meta.is_filename_template,
)?);
let curr_file_path = self.meta.get_curr_log_path();
let next_pending_sequence =
LogRollerState::get_next_pending_sequence(&self.meta.directory, &self.meta.filename);
#[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_pending_sequence,
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: self.meta.create_log_file(&curr_file_path)?,
worker_tx: None,
worker_handle: None,
})
}
}
#[allow(clippy::io_other_error)]
impl io::Write for LogRoller {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
if let Some(new_log_path) = Self::should_rollover(&self.meta, &self.state, buf.len() as u64) {
let old_log_path = self.state.curr_file_path.clone();
let rotated_log_path = match &self.meta.rotation {
Rotation::SizeBased(_) => {
let pending_log_path = self.meta.get_pending_log_path(self.state.next_pending_sequence);
self.state.next_pending_sequence += 1;
pending_log_path
}
Rotation::AgeBased(_) => new_log_path.to_owned(),
};
let rotated_path = self
.meta
.refresh_writer(&mut self.writer, old_log_path, rotated_log_path)
.map_err(|err| io::Error::new(io::ErrorKind::Other, err.to_string()))?;
self.post_rotation(rotated_path);
match &self.meta.rotation {
Rotation::SizeBased(_) => {
self.state.curr_file_size_bytes = 0;
self.state.curr_file_path = self.meta.get_curr_log_path();
}
Rotation::AgeBased(rotation_age) => {
self.state.curr_file_size_bytes = 0;
self.state.curr_file_path = new_log_path;
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()))?;
}
}
}
self.writer.write_all(buf)?;
self.state.curr_file_size_bytes = self.state.curr_file_size_bytes.saturating_add(buf.len() as u64);
Ok(buf.len())
}
fn flush(&mut self) -> io::Result<()> {
self.writer.flush()?;
if self.meta.graceful_shutdown {
self.worker_tx = None;
Self::join_worker(&mut self.worker_handle, "graceful flush");
}
Ok(())
}
}
impl Drop for LogRoller {
fn drop(&mut self) {
if let Err(e) = self.writer.flush() {
eprintln!("LogRoller: failed to flush writer on drop: {e}");
}
self.worker_tx = None;
if self.meta.graceful_shutdown {
Self::join_worker(&mut self.worker_handle, "drop");
}
}
}
#[cfg(feature = "tracing")]
#[deprecated(
since = "0.1.9",
note = "Use LogRoller directly as an appender with tracing_appender::non_blocking"
)]
type _TracingFeatureIsDeprecated = ();