use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum RotationStrategy {
#[default]
Daily,
Hourly,
Minutely,
Never,
}
impl RotationStrategy {
pub fn description(&self) -> &'static str {
match self {
Self::Daily => "Rotates logs once per day at midnight",
Self::Hourly => "Rotates logs every hour",
Self::Minutely => "Rotates logs every minute (for testing)",
Self::Never => "Never rotates - uses single log file",
}
}
pub fn suffix_pattern(&self) -> &'static str {
match self {
Self::Daily => "YYYY-MM-DD",
Self::Hourly => "YYYY-MM-DD-HH",
Self::Minutely => "YYYY-MM-DD-HH-mm",
Self::Never => "(none)",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RotationConfig {
#[serde(default)]
pub strategy: RotationStrategy,
#[serde(default)]
pub max_files: Option<u32>,
#[serde(default)]
pub compress: bool,
}
impl Default for RotationConfig {
fn default() -> Self {
Self {
strategy: RotationStrategy::Daily,
max_files: Some(7), compress: false,
}
}
}
impl RotationConfig {
pub fn new(strategy: RotationStrategy) -> Self {
Self {
strategy,
..Default::default()
}
}
pub fn daily() -> Self {
Self::new(RotationStrategy::Daily)
}
pub fn hourly() -> Self {
Self::new(RotationStrategy::Hourly)
}
pub fn minutely() -> Self {
Self::new(RotationStrategy::Minutely)
}
pub fn never() -> Self {
Self::new(RotationStrategy::Never)
}
pub fn with_max_files(mut self, max: u32) -> Self {
self.max_files = Some(max);
self
}
pub fn keep_all(mut self) -> Self {
self.max_files = None;
self
}
pub fn with_compression(mut self, compress: bool) -> Self {
self.compress = compress;
self
}
pub fn estimated_disk_space(&self, avg_log_size_mb: f64) -> Option<f64> {
self.max_files.map(|max| avg_log_size_mb * max as f64)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RetentionPolicy {
pub max_age_days: Option<u32>,
pub max_total_size_bytes: Option<u64>,
pub max_files: Option<u32>,
}
impl Default for RetentionPolicy {
fn default() -> Self {
Self {
max_age_days: Some(30),
max_total_size_bytes: Some(1024 * 1024 * 1024), max_files: Some(100),
}
}
}
impl RetentionPolicy {
pub fn new() -> Self {
Self::default()
}
pub fn with_max_age_days(mut self, days: u32) -> Self {
self.max_age_days = Some(days);
self
}
pub fn with_max_total_size(mut self, bytes: u64) -> Self {
self.max_total_size_bytes = Some(bytes);
self
}
pub fn with_max_files(mut self, files: u32) -> Self {
self.max_files = Some(files);
self
}
pub fn keep_all() -> Self {
Self {
max_age_days: None,
max_total_size_bytes: None,
max_files: None,
}
}
pub fn minimal() -> Self {
Self {
max_age_days: Some(1),
max_total_size_bytes: Some(10 * 1024 * 1024), max_files: Some(5),
}
}
}
#[derive(Debug, Clone, Default)]
pub struct LogFileStats {
pub file_count: usize,
pub total_size_bytes: u64,
pub oldest_file_timestamp: Option<u64>,
pub newest_file_timestamp: Option<u64>,
}
impl LogFileStats {
pub fn from_directory(
directory: &std::path::Path,
filename_prefix: &str,
) -> std::io::Result<Self> {
let mut stats = Self::default();
if !directory.exists() {
return Ok(stats);
}
for entry in std::fs::read_dir(directory)? {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if !filename.starts_with(filename_prefix) {
continue;
}
stats.file_count += 1;
if let Ok(metadata) = entry.metadata() {
stats.total_size_bytes += metadata.len();
if let Ok(modified) = metadata.modified() {
if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
let timestamp = duration.as_secs();
stats.oldest_file_timestamp = Some(
stats
.oldest_file_timestamp
.map(|t| t.min(timestamp))
.unwrap_or(timestamp),
);
stats.newest_file_timestamp = Some(
stats
.newest_file_timestamp
.map(|t| t.max(timestamp))
.unwrap_or(timestamp),
);
}
}
}
}
Ok(stats)
}
pub fn total_size_human_readable(&self) -> String {
const KB: u64 = 1024;
const MB: u64 = KB * 1024;
const GB: u64 = MB * 1024;
if self.total_size_bytes >= GB {
format!("{:.2} GB", self.total_size_bytes as f64 / GB as f64)
} else if self.total_size_bytes >= MB {
format!("{:.2} MB", self.total_size_bytes as f64 / MB as f64)
} else if self.total_size_bytes >= KB {
format!("{:.2} KB", self.total_size_bytes as f64 / KB as f64)
} else {
format!("{} bytes", self.total_size_bytes)
}
}
pub fn oldest_file_age_days(&self) -> Option<u32> {
self.oldest_file_timestamp.map(|ts| {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
((now.saturating_sub(ts)) / (24 * 60 * 60)) as u32
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rotation_strategy_default() {
assert_eq!(RotationStrategy::default(), RotationStrategy::Daily);
}
#[test]
fn test_rotation_config_builders() {
let daily = RotationConfig::daily();
assert_eq!(daily.strategy, RotationStrategy::Daily);
let hourly = RotationConfig::hourly().with_max_files(24);
assert_eq!(hourly.strategy, RotationStrategy::Hourly);
assert_eq!(hourly.max_files, Some(24));
let never = RotationConfig::never().keep_all();
assert_eq!(never.strategy, RotationStrategy::Never);
assert_eq!(never.max_files, None);
}
#[test]
fn test_rotation_config_serialization() {
let config = RotationConfig::daily().with_max_files(30);
let yaml = serde_yaml::to_string(&config).unwrap();
let parsed: RotationConfig = serde_yaml::from_str(&yaml).unwrap();
assert_eq!(config.strategy, parsed.strategy);
assert_eq!(config.max_files, parsed.max_files);
}
#[test]
fn test_estimated_disk_space() {
let config = RotationConfig::daily().with_max_files(30);
let space = config.estimated_disk_space(100.0); assert_eq!(space, Some(3000.0));
let unlimited = RotationConfig::daily().keep_all();
assert_eq!(unlimited.estimated_disk_space(100.0), None);
}
#[test]
fn test_retention_policy() {
let policy = RetentionPolicy::new()
.with_max_age_days(7)
.with_max_files(10);
assert_eq!(policy.max_age_days, Some(7));
assert_eq!(policy.max_files, Some(10));
}
#[test]
fn test_log_file_stats_human_readable() {
let mut stats = LogFileStats::default();
stats.total_size_bytes = 500;
assert_eq!(stats.total_size_human_readable(), "500 bytes");
stats.total_size_bytes = 1024 * 10;
assert!(stats.total_size_human_readable().contains("KB"));
stats.total_size_bytes = 1024 * 1024 * 50;
assert!(stats.total_size_human_readable().contains("MB"));
stats.total_size_bytes = 1024 * 1024 * 1024 * 2;
assert!(stats.total_size_human_readable().contains("GB"));
}
#[test]
fn test_strategy_descriptions() {
assert!(!RotationStrategy::Daily.description().is_empty());
assert!(!RotationStrategy::Hourly.suffix_pattern().is_empty());
}
}