#[cfg(not(feature = "tsc"))]
use std::time::Instant;
use std::{
borrow::Cow,
fs::{File, OpenOptions},
io::{BufWriter, Write},
path::{Path, PathBuf},
};
#[cfg(feature = "tsc")]
use minstant::Instant;
use time::{Date, Duration, Month, OffsetDateTime, Time, UtcOffset};
use typed_builder::TypedBuilder;
use crate::{local_timezone, LogTimezone};
#[derive(Clone, Copy)]
pub enum Period {
Minute,
Hour,
Day,
Month,
Year,
}
#[cfg(feature = "gzip")]
pub use compression::Compression;
struct Rotate {
start: Instant,
wait: Duration,
period: Period,
expire: Option<Duration>,
}
#[derive(TypedBuilder)]
#[builder(build_method(into = FileAppender), builder_method(vis = ""))]
pub struct FileAppenderBuilder {
#[builder(setter(transform = |x: impl AsRef<Path>| x.as_ref().to_path_buf()))]
path: PathBuf,
#[builder(default, setter(into))]
rotate: Option<Period>,
#[builder(default, setter(into))]
expire: Option<Duration>,
#[builder(default=LogTimezone::Local)]
timezone: LogTimezone,
#[cfg(feature = "gzip")]
#[builder(default, setter(into))]
compress: Option<Compression>,
}
impl From<FileAppenderBuilder> for FileAppender {
fn from(builder: FileAppenderBuilder) -> Self {
match (builder.rotate, builder.expire) {
(Some(period), Some(expire)) => {
let (start, wait) = FileAppender::until(period, &builder.timezone);
let path = FileAppender::file(&builder.path, period, &builder.timezone);
let mut file = BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap(),
);
let p = builder.path.clone();
let del_msg = clean_expire_log(p, period, expire);
if !del_msg.is_empty() {
file.write_fmt(format_args!("Log file deleted: {}", del_msg))
.expect(&format!(
"Write msg to \"{}\" failed",
path.to_string_lossy()
));
}
#[cfg(feature = "gzip")]
compression::spawn_compress_rotated_logs(&builder, &path, period);
FileAppender {
file,
path: builder.path,
rotate: Some(Rotate {
start,
wait,
period,
expire: Some(expire),
}),
timezone: builder.timezone,
#[cfg(feature = "gzip")]
compress: builder.compress,
#[cfg(feature = "gzip")]
current: path,
}
}
(Some(period), None) => {
let (start, wait) = FileAppender::until(period, &builder.timezone);
let path = FileAppender::file(&builder.path, period, &builder.timezone);
let file = BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.unwrap(),
);
#[cfg(feature = "gzip")]
compression::spawn_compress_rotated_logs(&builder, &path, period);
FileAppender {
file,
path: builder.path,
rotate: Some(Rotate {
start,
wait,
period,
expire: None,
}),
timezone: builder.timezone,
#[cfg(feature = "gzip")]
compress: builder.compress,
#[cfg(feature = "gzip")]
current: path,
}
}
_ => {
#[cfg(feature = "gzip")]
assert!(
builder.compress.is_none(),
"compress requires rotate to be configured"
);
FileAppender {
file: BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(&builder.path)
.expect(&format!(
"Fail to create log file: {}",
builder.path.to_string_lossy()
)),
),
#[cfg(feature = "gzip")]
compress: builder.compress,
#[cfg(feature = "gzip")]
current: builder.path.clone(),
path: builder.path,
rotate: None,
timezone: builder.timezone,
}
}
}
}
}
pub struct FileAppender {
file: BufWriter<File>,
path: PathBuf,
rotate: Option<Rotate>,
timezone: LogTimezone,
#[cfg(feature = "gzip")]
compress: Option<Compression>,
#[cfg(feature = "gzip")]
current: PathBuf,
}
impl FileAppender {
pub fn builder() -> FileAppenderBuilderBuilder {
FileAppenderBuilder::builder()
}
fn file<T: AsRef<Path>>(path: T, period: Period, timezone: &LogTimezone) -> PathBuf {
let p = path.as_ref();
let dt = OffsetDateTime::now_utc().to_offset(Self::offset_from_timezone(timezone));
let ts = match period {
Period::Year => format!("{}", dt.year()),
Period::Month => format!("{}{:02}", dt.year(), dt.month() as u8),
Period::Day => format!("{}{:02}{:02}", dt.year(), dt.month() as u8, dt.day()),
Period::Hour => format!(
"{}{:02}{:02}T{:02}",
dt.year(),
dt.month() as u8,
dt.day(),
dt.hour()
),
Period::Minute => format!(
"{}{:02}{:02}T{:02}{:02}",
dt.year(),
dt.month() as u8,
dt.day(),
dt.hour(),
dt.minute()
),
};
if let Some(ext) = p.extension() {
let file_name = p
.file_stem()
.map(|x| format!("{}-{}.{}", x.to_string_lossy(), ts, ext.to_string_lossy()))
.expect("invalid file name");
p.with_file_name(file_name)
} else {
p.with_file_name(format!(
"{}-{}",
p.file_name()
.map(|x| x.to_string_lossy())
.unwrap_or(Cow::from("log")),
ts
))
}
}
fn offset_from_timezone(timezone: &LogTimezone) -> UtcOffset {
match timezone {
LogTimezone::Local => local_timezone(),
LogTimezone::Utc => UtcOffset::UTC,
LogTimezone::Fixed(offset) => offset.clone(),
}
}
fn until(period: Period, timezone: &LogTimezone) -> (Instant, Duration) {
let tm_now = OffsetDateTime::now_utc().to_offset(Self::offset_from_timezone(timezone));
let now = Instant::now();
let tm_next = Self::next(&tm_now, period);
(now, tm_next - tm_now)
}
#[inline]
fn next(now: &OffsetDateTime, period: Period) -> OffsetDateTime {
let tm_next = match period {
Period::Year => Date::from_ordinal_date(now.year() + 1, 1)
.unwrap()
.with_time(Time::MIDNIGHT),
Period::Month => {
let year = if now.month() == Month::December {
now.year() + 1
} else {
now.year()
};
Date::from_calendar_date(year, now.month().next(), 1)
.unwrap()
.with_time(Time::MIDNIGHT)
}
Period::Day => now.date().with_time(Time::MIDNIGHT) + Duration::DAY,
Period::Hour => now.date().with_hms(now.time().hour(), 0, 0).unwrap() + Duration::HOUR,
Period::Minute => {
let time = now.time();
now.date().with_hms(time.hour(), time.minute(), 0).unwrap() + Duration::MINUTE
}
};
tm_next.assume_offset(now.offset())
}
pub fn new<T: AsRef<Path>>(path: T) -> Self {
Self::builder().path(path).build()
}
pub fn rotate<T: AsRef<Path>>(path: T, period: Period) -> Self {
Self::builder().path(path).rotate(period).build()
}
pub fn rotate_with_expire<T: AsRef<Path>>(path: T, period: Period, keep: Duration) -> Self {
Self::builder()
.path(path)
.rotate(period)
.expire(keep)
.build()
}
}
fn clean_expire_log(path: PathBuf, rotate_period: Period, keep_duration: Duration) -> String {
let dir = path.parent().unwrap().to_path_buf();
let dir = if dir.is_dir() {
dir
} else {
PathBuf::from(".")
};
let (stem, ext) = match base_stem_ext(&path) {
Some(x) => x,
None => return String::new(),
};
let to_remove = std::fs::read_dir(dir)
.unwrap()
.filter_map(|f| f.ok())
.filter(|x| x.file_type().map(|x| x.is_file()).unwrap_or(false))
.filter(|x| {
let name = x.file_name();
name.to_str()
.map(|name| match_log_name(name, stem, ext, rotate_period).is_some())
.unwrap_or(false)
})
.filter(|x| {
x.metadata()
.ok()
.and_then(|x| x.modified().ok())
.map(|time| {
time.elapsed()
.map(|elapsed| elapsed > keep_duration)
.unwrap_or(false)
})
.unwrap_or(false)
});
to_remove
.filter(|f| std::fs::remove_file(f.path()).is_ok())
.map(|x| x.file_name().to_string_lossy().to_string())
.collect::<Vec<_>>()
.join(", ")
}
const GZ_SUFFIX: &str = ".gz";
fn base_stem_ext(path: &Path) -> Option<(&str, Option<&str>)> {
let stem = path.file_stem()?.to_str()?;
let ext = match path.extension() {
Some(ext) => Some(ext.to_str()?),
None => None,
};
Some((stem, ext))
}
fn match_log_name<'a>(
file_name: &'a str,
base_stem: &str,
base_ext: Option<&str>,
period: Period,
) -> Option<&'a str> {
let name = file_name.strip_suffix(GZ_SUFFIX).unwrap_or(file_name);
let name = match base_ext {
Some(ext) => name.strip_suffix(ext)?.strip_suffix('.')?,
None => name,
};
let time = name.strip_prefix(base_stem)?.strip_prefix('-')?;
let check = |(ix, x): (usize, char)| match ix {
8 => x == 'T',
_ => x.is_digit(10),
};
let len = match period {
Period::Minute => time.len() == 13,
Period::Hour => time.len() == 11,
Period::Day => time.len() == 8,
Period::Month => time.len() == 6,
Period::Year => time.len() == 4,
};
(len && time.chars().enumerate().all(check)).then_some(time)
}
#[cfg(feature = "gzip")]
mod compression {
use flate2::write::GzEncoder;
use super::*;
#[derive(Clone, Copy)]
#[non_exhaustive]
pub enum Compression {
Gzip,
}
const TMP_SUFFIX: &str = ".tmp";
pub(super) fn compress_gzip(path: &Path) -> std::io::Result<()> {
fn encode(src: &Path, tmp: &Path) -> std::io::Result<()> {
let mut src = File::open(src)?;
let mtime = src.metadata()?.modified().ok();
let mut encoder = GzEncoder::new(
BufWriter::new(File::create(tmp)?),
flate2::Compression::default(),
);
std::io::copy(&mut src, &mut encoder)?;
let out = encoder.finish()?.into_inner().map_err(|e| e.into_error())?;
if let Some(mtime) = mtime {
let _ = out.set_modified(mtime);
}
out.sync_all()
}
let mut name = match path.file_name() {
Some(name) => name.to_os_string(),
None => {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"invalid log file name",
))
}
};
name.push(GZ_SUFFIX);
let gz_path = path.with_file_name(&name);
name.push(TMP_SUFFIX);
let tmp_path = path.with_file_name(name);
if gz_path.exists() {
return Err(std::io::Error::new(
std::io::ErrorKind::AlreadyExists,
"compressed log file already exists",
));
}
if let Err(e) = encode(path, &tmp_path).and_then(|_| std::fs::rename(&tmp_path, &gz_path)) {
let _ = std::fs::remove_file(&tmp_path);
return Err(e);
}
#[cfg(unix)]
if let Some(dir) = gz_path.parent() {
let dir = if dir.as_os_str().is_empty() {
Path::new(".")
} else {
dir
};
if let Ok(dir) = File::open(dir) {
let _ = dir.sync_all();
}
}
std::fs::remove_file(path)
}
pub(super) fn is_expired(path: &Path, keep: Duration) -> bool {
std::fs::metadata(path)
.ok()
.and_then(|m| m.modified().ok())
.and_then(|t| t.elapsed().ok())
.map_or(false, |elapsed| elapsed > keep)
}
pub(super) fn spawn_compress_rotated_logs(
builder: &FileAppenderBuilder,
current: &Path,
period: Period,
) {
if builder.compress.is_some() {
let base = builder.path.clone();
let current = current.to_path_buf();
std::thread::spawn(move || compress_rotated_logs(base, period, current));
}
}
pub(super) fn compress_rotated_logs(path: PathBuf, period: Period, current: PathBuf) {
let dir = match path.parent() {
Some(dir) if dir.is_dir() => dir.to_path_buf(),
_ => PathBuf::from("."),
};
let (stem, ext) = match base_stem_ext(&path) {
Some(x) => x,
None => return,
};
let current_time = match current
.file_name()
.and_then(|x| x.to_str())
.and_then(|name| match_log_name(name, stem, ext, period))
{
Some(time) => time,
None => return,
};
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) => {
eprintln!("ftlog: failed to scan log directory for compression: {}", e);
return;
}
};
for entry in entries.filter_map(|f| f.ok()) {
if !entry.file_type().map(|x| x.is_file()).unwrap_or(false) {
continue;
}
let name = entry.file_name();
let name = match name.to_str() {
Some(name) => name,
None => continue,
};
if let Some(stripped) = name.strip_suffix(TMP_SUFFIX) {
let orphan = stripped.ends_with(GZ_SUFFIX)
&& match_log_name(stripped, stem, ext, period)
.map_or(false, |time| time < current_time);
if orphan {
let _ = std::fs::remove_file(entry.path());
}
continue;
}
let time = match match_log_name(name, stem, ext, period) {
Some(time) => time,
None => continue,
};
if time >= current_time || name.ends_with(GZ_SUFFIX) {
continue;
}
let p = entry.path();
if p.with_file_name(format!("{}{}", name, GZ_SUFFIX)).exists() {
continue;
}
if let Err(e) = compress_gzip(&p) {
eprintln!(
"ftlog: failed to compress log file \"{}\": {}",
p.to_string_lossy(),
e
);
}
}
}
}
impl Write for FileAppender {
fn write(&mut self, record: &[u8]) -> std::io::Result<usize> {
if let Some(Rotate {
start,
wait,
period,
expire: keep,
}) = &mut self.rotate
{
if start.elapsed() > *wait {
self.file.flush()?;
let path = Self::file(&self.path, *period, &self.timezone);
if let Some(keep_duration) = keep {
let keep_duration = keep_duration.clone();
let path = self.path.clone();
let period = period.clone();
std::thread::spawn(move || {
let del_msg = clean_expire_log(path, period, keep_duration);
if !del_msg.is_empty() {
crate::info!("Log file deleted: {}", del_msg);
}
});
};
#[cfg(feature = "gzip")]
let finished = if self.compress.is_some() {
let prev = std::mem::replace(&mut self.current, path.clone());
if prev != path && !keep.map_or(false, |k| compression::is_expired(&prev, k)) {
Some(prev)
} else {
None
}
} else {
None
};
self.file = BufWriter::new(
OpenOptions::new()
.create(true)
.append(true)
.open(path)
.unwrap(),
);
(*start, *wait) = Self::until(*period, &self.timezone);
#[cfg(feature = "gzip")]
if let Some(finished) = finished {
std::thread::spawn(move || {
if let Err(e) = compression::compress_gzip(&finished) {
crate::warn!(
"Failed to compress log file \"{}\": {}",
finished.to_string_lossy(),
e
);
}
});
}
}
};
self.file.write_all(record).map(|_| record.len())
}
#[inline]
fn flush(&mut self) -> std::io::Result<()> {
self.file.flush()
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn log_name_matching() {
let m = |name, period| match_log_name(name, "mylog", Some("log"), period);
assert_eq!(m("mylog-2022.log", Period::Year), Some("2022"));
assert_eq!(m("mylog-202210.log", Period::Month), Some("202210"));
assert_eq!(m("mylog-20221026.log", Period::Day), Some("20221026"));
assert_eq!(
m("mylog-20221026T13.log", Period::Hour),
Some("20221026T13")
);
assert_eq!(
m("mylog-20221026T1351.log", Period::Minute),
Some("20221026T1351")
);
assert_eq!(
match_log_name("mylog-20221026", "mylog", None, Period::Day),
Some("20221026")
);
assert_eq!(m("mylog-20221026.log.gz", Period::Day), Some("20221026"));
assert_eq!(
m("mylog-20221026T13.log.gz", Period::Hour),
Some("20221026T13")
);
assert_eq!(m("mylog-20221026.log", Period::Minute), None);
assert_eq!(
match_log_name("other-20221026.log", "mylog", Some("log"), Period::Day),
None
);
assert_eq!(m("mylog-20221026.txt", Period::Day), None);
assert_eq!(m("mylog-20221026.tar.gz", Period::Day), None);
assert_eq!(m("mylog-20221026", Period::Day), None);
assert_eq!(
match_log_name("mylog-20221026.log", "mylog", None, Period::Day),
None
);
assert_eq!(m("mylog-20221026.log.gz.tmp", Period::Day), None);
assert_eq!(m("mylog.log", Period::Day), None);
}
#[cfg(feature = "gzip")]
#[test]
fn gzip_compress_log() {
use std::io::Read;
use std::time::{Duration as StdDuration, SystemTime};
let dir = std::env::temp_dir().join(format!("ftlog-gzip-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join("mylog-20221026.log");
let content = b"hello ftlog\n".repeat(1000);
std::fs::write(&path, &content).unwrap();
let mtime = SystemTime::now() - StdDuration::from_secs(3600);
File::options()
.write(true)
.open(&path)
.unwrap()
.set_modified(mtime)
.unwrap();
compression::compress_gzip(&path).unwrap();
let gz_path = dir.join("mylog-20221026.log.gz");
assert!(!path.exists(), "original file should be removed");
assert!(gz_path.exists(), "compressed file should be created");
assert!(!dir.join("mylog-20221026.log.gz.tmp").exists());
let gz_mtime = gz_path.metadata().unwrap().modified().unwrap();
let drift = gz_mtime
.duration_since(mtime)
.unwrap_or_else(|e| e.duration());
assert!(
drift < StdDuration::from_secs(2),
"archive should keep source mtime"
);
std::fs::write(&path, b"recreated").unwrap();
let err = compression::compress_gzip(&path).unwrap_err();
assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
assert!(path.exists(), "source must survive when archive exists");
let mut decoded = Vec::new();
flate2::read::GzDecoder::new(File::open(&gz_path).unwrap())
.read_to_end(&mut decoded)
.unwrap();
assert_eq!(decoded, content, "archive content must stay intact");
std::fs::remove_dir_all(&dir).unwrap();
}
#[cfg(feature = "gzip")]
#[test]
fn gzip_compress_rotated_logs_scan() {
let dir = std::env::temp_dir().join(format!("ftlog-gzip-scan-test-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let write = |name: &str| std::fs::write(dir.join(name), b"log content\n").unwrap();
write("app-20260801.log");
write("app-20260802.log");
write("app-20260803.log");
write("app-20260801.bak");
write("app-20260729.log");
write("app-20260729.log.gz");
write("app-20260730.log.gz.tmp");
compression::compress_rotated_logs(
dir.join("app.log"),
Period::Day,
dir.join("app-20260802.log"),
);
let names: Vec<String> = std::fs::read_dir(&dir)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().to_string())
.collect();
let has = |name: &str| names.iter().any(|x| x == name);
assert!(has("app-20260801.log.gz") && !has("app-20260801.log"));
assert!(has("app-20260802.log") && !has("app-20260802.log.gz"));
assert!(has("app-20260803.log") && !has("app-20260803.log.gz"));
assert!(has("app-20260801.bak") && !has("app-20260801.bak.gz"));
assert!(has("app-20260729.log") && has("app-20260729.log.gz"));
assert!(!has("app-20260730.log.gz.tmp"));
std::fs::remove_dir_all(&dir).unwrap();
}
fn format(time: OffsetDateTime) -> String {
format!(
"{:0>4}-{:0>2}-{:0>2}T{:0>2}:{:0>2}:{:0>2}.{:0>3}",
time.year(),
time.month() as u8,
time.day(),
time.hour(),
time.minute(),
time.second(),
time.millisecond()
)
}
#[test]
fn to_wait_ms() {
let now = OffsetDateTime::from_unix_timestamp(1666627200).unwrap();
let tm_next = FileAppender::next(&now, Period::Year);
let tm = OffsetDateTime::from_unix_timestamp(1672531200).unwrap();
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let tm_next = FileAppender::next(&now, Period::Month);
let tm = OffsetDateTime::from_unix_timestamp(1667260800).unwrap();
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let tm_next = FileAppender::next(&now, Period::Day);
let tm = OffsetDateTime::from_unix_timestamp(1666656000).unwrap();
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let tm_next = FileAppender::next(&now, Period::Hour);
let tm = OffsetDateTime::from_unix_timestamp(1666630800).unwrap();
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let tm_next = FileAppender::next(&now, Period::Minute);
let tm = OffsetDateTime::from_unix_timestamp(1666627260).unwrap();
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let date = Date::from_calendar_date(2023, Month::January, 31).unwrap();
let dt = date.with_time(Time::MIDNIGHT).assume_offset(now.offset());
let tm_next = FileAppender::next(&dt, Period::Day);
let tm = dt + Duration::DAY;
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
let date = Date::from_calendar_date(2022, Month::December, 1).unwrap();
let dt = date.with_time(Time::MIDNIGHT).assume_offset(now.offset());
let tm_next = FileAppender::next(&dt, Period::Month);
let tm = Date::from_calendar_date(2023, Month::January, 1)
.unwrap()
.with_hms(0, 0, 0)
.unwrap()
.assume_offset(now.offset());
assert_eq!(tm_next, tm, "{} != {}", format(now), format(tm_next));
}
}