Skip to main content

mabi_core/logging/
rotation.rs

1//! Log rotation configuration and utilities.
2//!
3//! This module provides configuration for log file rotation strategies,
4//! supporting both time-based and size-based rotation through `tracing-appender`.
5
6use serde::{Deserialize, Serialize};
7
8/// Rotation strategy for log files.
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
10#[serde(rename_all = "lowercase")]
11pub enum RotationStrategy {
12    /// Rotate logs daily.
13    #[default]
14    Daily,
15    /// Rotate logs hourly.
16    Hourly,
17    /// Rotate logs every minute (mainly for testing).
18    Minutely,
19    /// Never rotate - single log file.
20    Never,
21}
22
23impl RotationStrategy {
24    /// Get a human-readable description of the rotation strategy.
25    pub fn description(&self) -> &'static str {
26        match self {
27            Self::Daily => "Rotates logs once per day at midnight",
28            Self::Hourly => "Rotates logs every hour",
29            Self::Minutely => "Rotates logs every minute (for testing)",
30            Self::Never => "Never rotates - uses single log file",
31        }
32    }
33
34    /// Get the expected file suffix pattern for this strategy.
35    pub fn suffix_pattern(&self) -> &'static str {
36        match self {
37            Self::Daily => "YYYY-MM-DD",
38            Self::Hourly => "YYYY-MM-DD-HH",
39            Self::Minutely => "YYYY-MM-DD-HH-mm",
40            Self::Never => "(none)",
41        }
42    }
43}
44
45/// Log rotation configuration.
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct RotationConfig {
48    /// Rotation strategy.
49    #[serde(default)]
50    pub strategy: RotationStrategy,
51
52    /// Maximum number of rotated files to keep.
53    /// None means keep all files indefinitely.
54    #[serde(default)]
55    pub max_files: Option<u32>,
56
57    /// Whether to compress rotated files (future feature).
58    #[serde(default)]
59    pub compress: bool,
60}
61
62impl Default for RotationConfig {
63    fn default() -> Self {
64        Self {
65            strategy: RotationStrategy::Daily,
66            max_files: Some(7), // Keep 7 days by default
67            compress: false,
68        }
69    }
70}
71
72impl RotationConfig {
73    /// Create a new rotation config with the given strategy.
74    pub fn new(strategy: RotationStrategy) -> Self {
75        Self {
76            strategy,
77            ..Default::default()
78        }
79    }
80
81    /// Create a daily rotation config.
82    pub fn daily() -> Self {
83        Self::new(RotationStrategy::Daily)
84    }
85
86    /// Create an hourly rotation config.
87    pub fn hourly() -> Self {
88        Self::new(RotationStrategy::Hourly)
89    }
90
91    /// Create a minutely rotation config (for testing).
92    pub fn minutely() -> Self {
93        Self::new(RotationStrategy::Minutely)
94    }
95
96    /// Create a never-rotate config.
97    pub fn never() -> Self {
98        Self::new(RotationStrategy::Never)
99    }
100
101    /// Set the maximum number of rotated files to keep.
102    pub fn with_max_files(mut self, max: u32) -> Self {
103        self.max_files = Some(max);
104        self
105    }
106
107    /// Keep all rotated files indefinitely.
108    pub fn keep_all(mut self) -> Self {
109        self.max_files = None;
110        self
111    }
112
113    /// Enable compression of rotated files (future feature).
114    pub fn with_compression(mut self, compress: bool) -> Self {
115        self.compress = compress;
116        self
117    }
118
119    /// Calculate the approximate disk space needed for log retention.
120    ///
121    /// # Arguments
122    /// * `avg_log_size_mb` - Estimated average log file size in MB
123    ///
124    /// # Returns
125    /// Estimated disk space needed in MB, or None if max_files is unlimited.
126    pub fn estimated_disk_space(&self, avg_log_size_mb: f64) -> Option<f64> {
127        self.max_files.map(|max| avg_log_size_mb * max as f64)
128    }
129}
130
131/// Represents the retention policy for log files.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct RetentionPolicy {
134    /// Maximum age of log files in days.
135    pub max_age_days: Option<u32>,
136
137    /// Maximum total size of log files in bytes.
138    pub max_total_size_bytes: Option<u64>,
139
140    /// Maximum number of log files.
141    pub max_files: Option<u32>,
142}
143
144impl Default for RetentionPolicy {
145    fn default() -> Self {
146        Self {
147            max_age_days: Some(30),
148            max_total_size_bytes: Some(1024 * 1024 * 1024), // 1 GB
149            max_files: Some(100),
150        }
151    }
152}
153
154impl RetentionPolicy {
155    /// Create a new retention policy.
156    pub fn new() -> Self {
157        Self::default()
158    }
159
160    /// Set maximum age in days.
161    pub fn with_max_age_days(mut self, days: u32) -> Self {
162        self.max_age_days = Some(days);
163        self
164    }
165
166    /// Set maximum total size.
167    pub fn with_max_total_size(mut self, bytes: u64) -> Self {
168        self.max_total_size_bytes = Some(bytes);
169        self
170    }
171
172    /// Set maximum number of files.
173    pub fn with_max_files(mut self, files: u32) -> Self {
174        self.max_files = Some(files);
175        self
176    }
177
178    /// Create a retention policy that keeps everything.
179    pub fn keep_all() -> Self {
180        Self {
181            max_age_days: None,
182            max_total_size_bytes: None,
183            max_files: None,
184        }
185    }
186
187    /// Create a minimal retention policy for testing.
188    pub fn minimal() -> Self {
189        Self {
190            max_age_days: Some(1),
191            max_total_size_bytes: Some(10 * 1024 * 1024), // 10 MB
192            max_files: Some(5),
193        }
194    }
195}
196
197/// Statistics about log files for a given directory.
198#[derive(Debug, Clone, Default)]
199pub struct LogFileStats {
200    /// Total number of log files.
201    pub file_count: usize,
202    /// Total size of all log files in bytes.
203    pub total_size_bytes: u64,
204    /// Oldest file timestamp (Unix timestamp).
205    pub oldest_file_timestamp: Option<u64>,
206    /// Newest file timestamp (Unix timestamp).
207    pub newest_file_timestamp: Option<u64>,
208}
209
210impl LogFileStats {
211    /// Calculate statistics for log files in a directory.
212    pub fn from_directory(
213        directory: &std::path::Path,
214        filename_prefix: &str,
215    ) -> std::io::Result<Self> {
216        let mut stats = Self::default();
217
218        if !directory.exists() {
219            return Ok(stats);
220        }
221
222        for entry in std::fs::read_dir(directory)? {
223            let entry = entry?;
224            let path = entry.path();
225
226            if !path.is_file() {
227                continue;
228            }
229
230            let filename = path
231                .file_name()
232                .and_then(|n| n.to_str())
233                .unwrap_or_default();
234
235            if !filename.starts_with(filename_prefix) {
236                continue;
237            }
238
239            stats.file_count += 1;
240
241            if let Ok(metadata) = entry.metadata() {
242                stats.total_size_bytes += metadata.len();
243
244                if let Ok(modified) = metadata.modified() {
245                    if let Ok(duration) = modified.duration_since(std::time::UNIX_EPOCH) {
246                        let timestamp = duration.as_secs();
247
248                        stats.oldest_file_timestamp = Some(
249                            stats
250                                .oldest_file_timestamp
251                                .map(|t| t.min(timestamp))
252                                .unwrap_or(timestamp),
253                        );
254
255                        stats.newest_file_timestamp = Some(
256                            stats
257                                .newest_file_timestamp
258                                .map(|t| t.max(timestamp))
259                                .unwrap_or(timestamp),
260                        );
261                    }
262                }
263            }
264        }
265
266        Ok(stats)
267    }
268
269    /// Get total size in a human-readable format.
270    pub fn total_size_human_readable(&self) -> String {
271        const KB: u64 = 1024;
272        const MB: u64 = KB * 1024;
273        const GB: u64 = MB * 1024;
274
275        if self.total_size_bytes >= GB {
276            format!("{:.2} GB", self.total_size_bytes as f64 / GB as f64)
277        } else if self.total_size_bytes >= MB {
278            format!("{:.2} MB", self.total_size_bytes as f64 / MB as f64)
279        } else if self.total_size_bytes >= KB {
280            format!("{:.2} KB", self.total_size_bytes as f64 / KB as f64)
281        } else {
282            format!("{} bytes", self.total_size_bytes)
283        }
284    }
285
286    /// Get the age of the oldest file in days.
287    pub fn oldest_file_age_days(&self) -> Option<u32> {
288        self.oldest_file_timestamp.map(|ts| {
289            let now = std::time::SystemTime::now()
290                .duration_since(std::time::UNIX_EPOCH)
291                .map(|d| d.as_secs())
292                .unwrap_or(0);
293            ((now.saturating_sub(ts)) / (24 * 60 * 60)) as u32
294        })
295    }
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    #[test]
303    fn test_rotation_strategy_default() {
304        assert_eq!(RotationStrategy::default(), RotationStrategy::Daily);
305    }
306
307    #[test]
308    fn test_rotation_config_builders() {
309        let daily = RotationConfig::daily();
310        assert_eq!(daily.strategy, RotationStrategy::Daily);
311
312        let hourly = RotationConfig::hourly().with_max_files(24);
313        assert_eq!(hourly.strategy, RotationStrategy::Hourly);
314        assert_eq!(hourly.max_files, Some(24));
315
316        let never = RotationConfig::never().keep_all();
317        assert_eq!(never.strategy, RotationStrategy::Never);
318        assert_eq!(never.max_files, None);
319    }
320
321    #[test]
322    fn test_rotation_config_serialization() {
323        let config = RotationConfig::daily().with_max_files(30);
324        let yaml = serde_yaml::to_string(&config).unwrap();
325        let parsed: RotationConfig = serde_yaml::from_str(&yaml).unwrap();
326
327        assert_eq!(config.strategy, parsed.strategy);
328        assert_eq!(config.max_files, parsed.max_files);
329    }
330
331    #[test]
332    fn test_estimated_disk_space() {
333        let config = RotationConfig::daily().with_max_files(30);
334        let space = config.estimated_disk_space(100.0); // 100 MB per day
335        assert_eq!(space, Some(3000.0)); // 30 * 100 = 3000 MB
336
337        let unlimited = RotationConfig::daily().keep_all();
338        assert_eq!(unlimited.estimated_disk_space(100.0), None);
339    }
340
341    #[test]
342    fn test_retention_policy() {
343        let policy = RetentionPolicy::new()
344            .with_max_age_days(7)
345            .with_max_files(10);
346
347        assert_eq!(policy.max_age_days, Some(7));
348        assert_eq!(policy.max_files, Some(10));
349    }
350
351    #[test]
352    fn test_log_file_stats_human_readable() {
353        let mut stats = LogFileStats::default();
354
355        stats.total_size_bytes = 500;
356        assert_eq!(stats.total_size_human_readable(), "500 bytes");
357
358        stats.total_size_bytes = 1024 * 10;
359        assert!(stats.total_size_human_readable().contains("KB"));
360
361        stats.total_size_bytes = 1024 * 1024 * 50;
362        assert!(stats.total_size_human_readable().contains("MB"));
363
364        stats.total_size_bytes = 1024 * 1024 * 1024 * 2;
365        assert!(stats.total_size_human_readable().contains("GB"));
366    }
367
368    #[test]
369    fn test_strategy_descriptions() {
370        assert!(!RotationStrategy::Daily.description().is_empty());
371        assert!(!RotationStrategy::Hourly.suffix_pattern().is_empty());
372    }
373}