mabi_core/logging/
rotation.rs1use serde::{Deserialize, Serialize};
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
10#[serde(rename_all = "lowercase")]
11pub enum RotationStrategy {
12 #[default]
14 Daily,
15 Hourly,
17 Minutely,
19 Never,
21}
22
23impl RotationStrategy {
24 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 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
47pub struct RotationConfig {
48 #[serde(default)]
50 pub strategy: RotationStrategy,
51
52 #[serde(default)]
55 pub max_files: Option<u32>,
56
57 #[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), compress: false,
68 }
69 }
70}
71
72impl RotationConfig {
73 pub fn new(strategy: RotationStrategy) -> Self {
75 Self {
76 strategy,
77 ..Default::default()
78 }
79 }
80
81 pub fn daily() -> Self {
83 Self::new(RotationStrategy::Daily)
84 }
85
86 pub fn hourly() -> Self {
88 Self::new(RotationStrategy::Hourly)
89 }
90
91 pub fn minutely() -> Self {
93 Self::new(RotationStrategy::Minutely)
94 }
95
96 pub fn never() -> Self {
98 Self::new(RotationStrategy::Never)
99 }
100
101 pub fn with_max_files(mut self, max: u32) -> Self {
103 self.max_files = Some(max);
104 self
105 }
106
107 pub fn keep_all(mut self) -> Self {
109 self.max_files = None;
110 self
111 }
112
113 pub fn with_compression(mut self, compress: bool) -> Self {
115 self.compress = compress;
116 self
117 }
118
119 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#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct RetentionPolicy {
134 pub max_age_days: Option<u32>,
136
137 pub max_total_size_bytes: Option<u64>,
139
140 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), max_files: Some(100),
150 }
151 }
152}
153
154impl RetentionPolicy {
155 pub fn new() -> Self {
157 Self::default()
158 }
159
160 pub fn with_max_age_days(mut self, days: u32) -> Self {
162 self.max_age_days = Some(days);
163 self
164 }
165
166 pub fn with_max_total_size(mut self, bytes: u64) -> Self {
168 self.max_total_size_bytes = Some(bytes);
169 self
170 }
171
172 pub fn with_max_files(mut self, files: u32) -> Self {
174 self.max_files = Some(files);
175 self
176 }
177
178 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 pub fn minimal() -> Self {
189 Self {
190 max_age_days: Some(1),
191 max_total_size_bytes: Some(10 * 1024 * 1024), max_files: Some(5),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Default)]
199pub struct LogFileStats {
200 pub file_count: usize,
202 pub total_size_bytes: u64,
204 pub oldest_file_timestamp: Option<u64>,
206 pub newest_file_timestamp: Option<u64>,
208}
209
210impl LogFileStats {
211 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 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 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); assert_eq!(space, Some(3000.0)); 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}