file_rotate/suffix.rs
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442
//! Suffix schemes determine the suffix of rotated files
//!
//! This behaviour is fully extensible through the [SuffixScheme] trait, and two behaviours are
//! provided: [AppendCount] and [AppendTimestamp]
//!
use super::now;
use crate::SuffixInfo;
use chrono::{format::ParseErrorKind, offset::Local, Duration, NaiveDateTime};
use std::{
cmp::Ordering,
collections::BTreeSet,
io,
path::{Path, PathBuf},
};
/// Representation of a suffix
/// `Ord + PartialOrd`: sort by age of the suffix. Most recent first (smallest).
pub trait Representation: Ord + ToString + Eq + Clone + std::fmt::Debug {
/// Create path
fn to_path(&self, basepath: &Path) -> PathBuf {
PathBuf::from(format!("{}.{}", basepath.display(), self.to_string()))
}
}
/// How to move files: How to rename, when to delete.
pub trait SuffixScheme {
/// The representation of suffixes that this suffix scheme uses.
/// E.g. if the suffix is a number, you can use `usize`.
type Repr: Representation;
/// `file-rotate` calls this function when the file at `suffix` needs to be rotated, and moves the log file
/// accordingly. Thus, this function should not move any files itself.
///
/// If `suffix` is `None`, it means it's the main log file (with path equal to just `basepath`)
/// that is being rotated.
///
/// Returns the target suffix that the log file should be moved to.
/// If the target suffix already exists, `rotate_file` is called again with `suffix` set to the
/// target suffix. Thus it cascades files by default, and if this is not desired, it's up to
/// `rotate_file` to return a suffix that does not already exist on disk.
///
/// `newest_suffix` is provided just in case it's useful (depending on the particular suffix scheme, it's not always useful)
fn rotate_file(
&mut self,
basepath: &Path,
newest_suffix: Option<&Self::Repr>,
suffix: &Option<Self::Repr>,
) -> io::Result<Self::Repr>;
/// Parse suffix from string.
fn parse(&self, suffix: &str) -> Option<Self::Repr>;
/// Whether either the suffix or the chronological file number indicates that the file is old
/// and should be deleted, depending of course on the file limit.
/// `file_number` starts at 0 for the most recent suffix.
fn too_old(&self, suffix: &Self::Repr, file_number: usize) -> bool;
/// Find all files in the basepath.parent() directory that has path equal to basepath + a valid
/// suffix. Return sorted collection - sorted from most recent to oldest based on the
/// [Ord] implementation of `Self::Repr`.
fn scan_suffixes(&self, basepath: &Path) -> BTreeSet<SuffixInfo<Self::Repr>> {
let mut suffixes = BTreeSet::new();
let filename_prefix = basepath
.file_name()
.expect("basepath.file_name()")
.to_string_lossy();
// We need the parent directory of the given basepath, but this should also work when the path
// only has one segment. Thus we prepend the current working dir if the path is relative:
let basepath = if basepath.is_relative() {
let mut path = std::env::current_dir().unwrap();
path.push(basepath);
path
} else {
basepath.to_path_buf()
};
let parent = basepath.parent().unwrap();
let filenames = std::fs::read_dir(parent)
.unwrap()
.filter_map(|entry| entry.ok())
.filter(|entry| entry.path().is_file())
.map(|entry| entry.file_name());
for filename in filenames {
let filename = filename.to_string_lossy();
if !filename.starts_with(&*filename_prefix) {
continue;
}
let (filename, compressed) = prepare_filename(&*filename);
let suffix_str = filename.strip_prefix(&format!("{}.", filename_prefix));
if let Some(suffix) = suffix_str.and_then(|s| self.parse(s)) {
suffixes.insert(SuffixInfo { suffix, compressed });
}
}
suffixes
}
}
fn prepare_filename(path: &str) -> (&str, bool) {
path.strip_suffix(".gz")
.map(|x| (x, true))
.unwrap_or((path, false))
}
/// Append a number when rotating the file.
/// The greater the number, the older. The oldest files are deleted.
pub struct AppendCount {
max_files: usize,
}
impl AppendCount {
/// New suffix scheme, deleting files when the number of rotated files (i.e. excluding the main
/// file) exceeds `max_files`.
/// For example, if `max_files` is 3, then the files `log`, `log.1`, `log.2`, `log.3` may exist
/// but not `log.4`. In other words, `max_files` determines the largest possible suffix number.
pub fn new(max_files: usize) -> Self {
Self { max_files }
}
}
impl Representation for usize {}
impl SuffixScheme for AppendCount {
type Repr = usize;
fn rotate_file(
&mut self,
_basepath: &Path,
_: Option<&usize>,
suffix: &Option<usize>,
) -> io::Result<usize> {
Ok(match suffix {
Some(suffix) => suffix + 1,
None => 1,
})
}
fn parse(&self, suffix: &str) -> Option<usize> {
suffix.parse::<usize>().ok()
}
fn too_old(&self, _suffix: &usize, file_number: usize) -> bool {
file_number >= self.max_files
}
}
/// Add timestamp from:
pub enum DateFrom {
/// Date yesterday, to represent the timestamps within the log file.
DateYesterday,
/// Date from hour ago, useful with rotate hourly.
DateHourAgo,
/// Date from now.
Now,
}
/// Append current timestamp as suffix when rotating files.
/// If the timestamp already exists, an additional number is appended.
///
/// Current limitations:
/// - Neither `format` nor the base filename can include the character `"."`.
/// - The `format` should ensure that the lexical and chronological orderings are the same
pub struct AppendTimestamp {
/// The format of the timestamp suffix
pub format: &'static str,
/// The file limit, e.g. when to delete an old file - by age (given by suffix) or by number of files
pub file_limit: FileLimit,
/// Add timestamp from DateFrom
pub date_from: DateFrom,
}
impl AppendTimestamp {
/// With format `"%Y%m%dT%H%M%S"`
pub fn default(file_limit: FileLimit) -> Self {
Self {
format: "%Y%m%dT%H%M%S",
file_limit,
date_from: DateFrom::Now,
}
}
/// Create new AppendTimestamp suffix scheme
pub fn with_format(format: &'static str, file_limit: FileLimit, date_from: DateFrom) -> Self {
Self {
format,
file_limit,
date_from,
}
}
}
/// Structured representation of the suffixes of AppendTimestamp.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TimestampSuffix {
/// The timestamp
pub timestamp: String,
/// Optional number suffix if two timestamp suffixes are the same
pub number: Option<usize>,
}
impl Representation for TimestampSuffix {}
impl Ord for TimestampSuffix {
fn cmp(&self, other: &Self) -> Ordering {
// Most recent = smallest (opposite as the timestamp Ord)
// Smallest = most recent. Thus, biggest timestamp first. And then biggest number
match other.timestamp.cmp(&self.timestamp) {
Ordering::Equal => other.number.cmp(&self.number),
unequal => unequal,
}
}
}
impl PartialOrd for TimestampSuffix {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl std::fmt::Display for TimestampSuffix {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> Result<(), std::fmt::Error> {
match self.number {
Some(n) => write!(f, "{}.{}", self.timestamp, n),
None => write!(f, "{}", self.timestamp),
}
}
}
impl SuffixScheme for AppendTimestamp {
type Repr = TimestampSuffix;
fn rotate_file(
&mut self,
_basepath: &Path,
newest_suffix: Option<&TimestampSuffix>,
suffix: &Option<TimestampSuffix>,
) -> io::Result<TimestampSuffix> {
assert!(suffix.is_none());
if suffix.is_none() {
let mut now = now();
match self.date_from {
DateFrom::DateYesterday => {
now = now - Duration::days(1);
}
DateFrom::DateHourAgo => {
now = now - Duration::hours(1);
}
_ => {}
};
let fmt_now = now.format(self.format).to_string();
let number = if let Some(newest_suffix) = newest_suffix {
if newest_suffix.timestamp == fmt_now {
Some(newest_suffix.number.unwrap_or(0) + 1)
} else {
None
}
} else {
None
};
Ok(TimestampSuffix {
timestamp: fmt_now,
number,
})
} else {
// This rotation scheme dictates that only the main log file should ever be renamed.
// In debug build the above assert will catch this.
Err(io::Error::new(
io::ErrorKind::InvalidData,
"Critical error in file-rotate algorithm",
))
}
}
fn parse(&self, suffix: &str) -> Option<Self::Repr> {
let (timestamp_str, n) = if let Some(dot) = suffix.find('.') {
if let Ok(n) = suffix[(dot + 1)..].parse::<usize>() {
(&suffix[..dot], Some(n))
} else {
return None;
}
} else {
(suffix, None)
};
let success = match NaiveDateTime::parse_from_str(timestamp_str, self.format) {
Ok(_) => true,
Err(e) => e.kind() == ParseErrorKind::NotEnough,
};
if success {
Some(TimestampSuffix {
timestamp: timestamp_str.to_string(),
number: n,
})
} else {
None
}
}
fn too_old(&self, suffix: &TimestampSuffix, file_number: usize) -> bool {
match self.file_limit {
FileLimit::MaxFiles(max_files) => file_number >= max_files,
FileLimit::Age(age) => {
let old_timestamp = (Local::now() - age).format(self.format).to_string();
suffix.timestamp < old_timestamp
}
FileLimit::Unlimited => false,
}
}
}
/// How to determine whether a file should be deleted, in the case of [AppendTimestamp].
pub enum FileLimit {
/// Delete the oldest files if number of files is too high
MaxFiles(usize),
/// Delete files whose age exceeds the `Duration` - age is determined by the suffix of the file
Age(Duration),
/// Never delete files
Unlimited,
}
#[cfg(test)]
mod test {
use super::*;
use std::fs::File;
use tempfile::TempDir;
#[test]
fn timestamp_ordering() {
assert!(
TimestampSuffix {
timestamp: "2021".to_string(),
number: None
} < TimestampSuffix {
timestamp: "2020".to_string(),
number: None
}
);
assert!(
TimestampSuffix {
timestamp: "2021".to_string(),
number: Some(1)
} < TimestampSuffix {
timestamp: "2021".to_string(),
number: None
}
);
}
#[test]
fn timestamp_scan_suffixes_base_paths() {
let working_dir = TempDir::new().unwrap();
let working_dir = working_dir.path().join("dir");
let suffix_scheme = AppendTimestamp::default(FileLimit::Age(Duration::weeks(1)));
// Test `scan_suffixes` for different possible paths given to it
// (it used to have a bug taking e.g. "log".parent() --> panic)
for relative_path in ["logs/log", "./log", "log", "../log", "../logs/log"] {
std::fs::create_dir_all(&working_dir).unwrap();
println!("Testing relative path: {}", relative_path);
let relative_path = Path::new(relative_path);
let log_file = working_dir.join(relative_path);
let log_dir = log_file.parent().unwrap();
// Ensure all directories needed exist
std::fs::create_dir_all(log_dir).unwrap();
// We cd into working_dir
std::env::set_current_dir(&working_dir).unwrap();
// Need to create the log file in order to canonicalize it and then get the parent
File::create(working_dir.join(&relative_path)).unwrap();
let canonicalized = relative_path.canonicalize().unwrap();
let relative_dir = canonicalized.parent().unwrap();
File::create(relative_dir.join("log.20210911T121830")).unwrap();
File::create(relative_dir.join("log.20210911T121831.gz")).unwrap();
let paths = suffix_scheme.scan_suffixes(relative_path);
assert_eq!(paths.len(), 2);
// Reset CWD: necessary on Windows only - otherwise we get the error:
// "The process cannot access the file because it is being used by another process."
// (code 32)
std::env::set_current_dir("/").unwrap();
// Cleanup
std::fs::remove_dir_all(&working_dir).unwrap();
}
}
#[test]
fn timestamp_scan_suffixes_formats() {
struct TestCase {
format: &'static str,
suffixes: &'static [&'static str],
incorrect_suffixes: &'static [&'static str],
}
let cases = [
TestCase {
format: "%Y%m%dT%H%M%S",
suffixes: &["20220201T101010", "20220202T101010"],
incorrect_suffixes: &["20220201T1010", "20220201T999999", "2022-02-02"],
},
TestCase {
format: "%Y-%m-%d",
suffixes: &["2022-02-01", "2022-02-02"],
incorrect_suffixes: &[
"abc",
"2022-99-99",
"2022-05",
"2022",
"20220202",
"2022-02-02T112233",
],
},
];
for (i, case) in cases.iter().enumerate() {
println!("Case {}", i);
let tmp_dir = TempDir::new().unwrap();
let dir = tmp_dir.path();
let log_path = dir.join("file");
for suffix in case.suffixes.iter().chain(case.incorrect_suffixes) {
File::create(dir.join(format!("file.{}", suffix))).unwrap();
}
let scheme = AppendTimestamp::with_format(
case.format,
FileLimit::MaxFiles(1),
DateFrom::DateYesterday,
);
// Scan for suffixes
let suffixes_set = scheme.scan_suffixes(&log_path);
// Collect these suffixes, and the expected suffixes, into Vec, and sort
let mut suffixes = suffixes_set
.into_iter()
.map(|x| x.suffix.to_string())
.collect::<Vec<_>>();
suffixes.sort_unstable();
let mut expected_suffixes = case.suffixes.to_vec();
expected_suffixes.sort_unstable();
assert_eq!(suffixes, case.suffixes);
println!("Passed\n");
}
}
}