1use std::fs;
26use std::path::Path;
27
28use serde::{Deserialize, Serialize};
29
30use crate::disk_pressure_report::PressureLevel;
31
32pub const MANAGED_LOG_NAMES: &[&str] = &["daemon.log", "daemon.err"];
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct LogRetentionPolicy {
38 pub max_file_bytes: u64,
40 pub keep_rotated: usize,
42 pub warn_total_bytes: u64,
44 pub critical_total_bytes: u64,
46}
47
48impl Default for LogRetentionPolicy {
49 fn default() -> Self {
50 Self {
51 max_file_bytes: 16 * 1024 * 1024, keep_rotated: 3,
53 warn_total_bytes: 64 * 1024 * 1024, critical_total_bytes: 256 * 1024 * 1024, }
56 }
57}
58
59#[must_use]
61pub fn is_managed_active_log(name: &str) -> bool {
62 MANAGED_LOG_NAMES.contains(&name)
63}
64
65#[must_use]
68pub fn managed_rotated_generation(name: &str) -> Option<(&'static str, usize)> {
69 for base in MANAGED_LOG_NAMES {
70 if let Some(suffix) = name.strip_prefix(&format!("{base}.")) {
71 if let Ok(generation) = suffix.parse::<usize>()
73 && generation >= 1
74 {
75 return Some((base, generation));
76 }
77 }
78 }
79 None
80}
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum LogAction {
86 Keep,
88 Rotate,
90 Prune,
92 SkipUnmanaged,
94}
95
96impl LogAction {
97 #[must_use]
99 pub fn as_str(self) -> &'static str {
100 match self {
101 LogAction::Keep => "keep",
102 LogAction::Rotate => "rotate",
103 LogAction::Prune => "prune",
104 LogAction::SkipUnmanaged => "skip_unmanaged",
105 }
106 }
107}
108
109#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct LogFile {
112 pub name: String,
113 pub bytes: u64,
114}
115
116#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
118pub struct LogFileDecision {
119 pub name: String,
120 pub bytes: u64,
121 pub action: LogAction,
122}
123
124#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct LogRotationPlan {
127 pub decisions: Vec<LogFileDecision>,
128 pub managed_total_bytes: u64,
130 pub unmanaged_total_bytes: u64,
132}
133
134impl LogRotationPlan {
135 #[must_use]
137 pub fn names_for(&self, action: LogAction) -> Vec<&str> {
138 self.decisions
139 .iter()
140 .filter(|d| d.action == action)
141 .map(|d| d.name.as_str())
142 .collect()
143 }
144}
145
146#[must_use]
151pub fn plan_log_rotation(files: &[LogFile], policy: &LogRetentionPolicy) -> LogRotationPlan {
152 let mut decisions = Vec::with_capacity(files.len());
153 let mut managed_total_bytes = 0u64;
154 let mut unmanaged_total_bytes = 0u64;
155
156 for file in files {
157 let action = if is_managed_active_log(&file.name) {
158 managed_total_bytes = managed_total_bytes.saturating_add(file.bytes);
159 if file.bytes > policy.max_file_bytes {
160 LogAction::Rotate
161 } else {
162 LogAction::Keep
163 }
164 } else if let Some((_, generation)) = managed_rotated_generation(&file.name) {
165 managed_total_bytes = managed_total_bytes.saturating_add(file.bytes);
166 if generation > policy.keep_rotated {
169 LogAction::Prune
170 } else {
171 LogAction::Keep
172 }
173 } else {
174 unmanaged_total_bytes = unmanaged_total_bytes.saturating_add(file.bytes);
175 LogAction::SkipUnmanaged
176 };
177 decisions.push(LogFileDecision {
178 name: file.name.clone(),
179 bytes: file.bytes,
180 action,
181 });
182 }
183
184 LogRotationPlan {
185 decisions,
186 managed_total_bytes,
187 unmanaged_total_bytes,
188 }
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193pub struct LogRotationReceipt {
194 pub rotated: Vec<String>,
196 pub pruned: Vec<String>,
198 pub skipped_unmanaged: Vec<String>,
200 pub pruned_bytes: u64,
202}
203
204pub fn rotate_logs(dir: &Path, policy: &LogRetentionPolicy) -> std::io::Result<LogRotationReceipt> {
215 let mut receipt = LogRotationReceipt::default();
216
217 let mut entries: Vec<(String, u64)> = Vec::new();
219 for entry in fs::read_dir(dir)? {
220 let entry = entry?;
221 if !entry.file_type()?.is_file() {
222 continue;
223 }
224 let name = entry.file_name().to_string_lossy().into_owned();
225 let bytes = entry.metadata().map(|m| m.len()).unwrap_or(0);
226 entries.push((name, bytes));
227 }
228
229 for (name, _) in &entries {
231 if !is_managed_active_log(name) && managed_rotated_generation(name).is_none() {
232 receipt.skipped_unmanaged.push(name.clone());
233 }
234 }
235
236 for base in MANAGED_LOG_NAMES {
237 let active = dir.join(base);
238 let over_limit = entries
239 .iter()
240 .find(|(n, _)| n == base)
241 .is_some_and(|(_, b)| *b > policy.max_file_bytes);
242 if !over_limit {
243 continue;
244 }
245
246 let oldest = dir.join(format!("{base}.{}", policy.keep_rotated));
248 if let Ok(meta) = fs::metadata(&oldest) {
249 receipt.pruned_bytes = receipt.pruned_bytes.saturating_add(meta.len());
250 fs::remove_file(&oldest)?;
251 receipt
252 .pruned
253 .push(format!("{base}.{}", policy.keep_rotated));
254 }
255 for generation in (1..policy.keep_rotated).rev() {
257 let from = dir.join(format!("{base}.{generation}"));
258 if from.exists() {
259 fs::rename(&from, dir.join(format!("{base}.{}", generation + 1)))?;
260 }
261 }
262 fs::rename(&active, dir.join(format!("{base}.1")))?;
264 fs::File::create(&active)?;
265 receipt.rotated.push((*base).to_string());
266 }
267
268 Ok(receipt)
269}
270
271#[must_use]
273pub fn assess_log_pressure(managed_total_bytes: u64, policy: &LogRetentionPolicy) -> PressureLevel {
274 if managed_total_bytes >= policy.critical_total_bytes {
275 PressureLevel::Critical
276 } else if managed_total_bytes >= policy.warn_total_bytes {
277 PressureLevel::Warning
278 } else {
279 PressureLevel::Ok
280 }
281}
282
283#[must_use]
286pub fn log_remediation_guidance(level: PressureLevel) -> &'static str {
287 match level {
288 PressureLevel::Ok => "log sizes are within retention limits; no action needed",
289 PressureLevel::Warning => {
290 "daemon logs are growing; rotation will bound them on the next daemon cycle (managed, non-destructive)"
291 }
292 PressureLevel::Critical => {
293 "daemon logs are large; rotation will reclaim space on the next cycle. Do NOT delete log files by hand — RCH rotates only its own daemon.log/daemon.err generations"
294 }
295 PressureLevel::Unknown => {
296 "log sizes could not be determined; check the daemon log directory path"
297 }
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304 use std::io::Write;
305
306 fn small_policy() -> LogRetentionPolicy {
307 LogRetentionPolicy {
308 max_file_bytes: 100,
309 keep_rotated: 2,
310 warn_total_bytes: 500,
311 critical_total_bytes: 1_000,
312 }
313 }
314
315 #[test]
318 fn rotate_by_size_only_when_over_limit() {
319 let files = vec![
320 LogFile {
321 name: "daemon.log".into(),
322 bytes: 150,
323 }, LogFile {
325 name: "daemon.err".into(),
326 bytes: 50,
327 }, ];
329 let plan = plan_log_rotation(&files, &small_policy());
330 assert_eq!(plan.names_for(LogAction::Rotate), vec!["daemon.log"]);
331 assert_eq!(plan.names_for(LogAction::Keep), vec!["daemon.err"]);
332 }
333
334 #[test]
335 fn preserve_recent_generations_prune_old() {
336 let files = vec![
337 LogFile {
338 name: "daemon.log.1".into(),
339 bytes: 10,
340 }, LogFile {
342 name: "daemon.log.2".into(),
343 bytes: 10,
344 }, LogFile {
346 name: "daemon.log.3".into(),
347 bytes: 10,
348 }, ];
350 let plan = plan_log_rotation(&files, &small_policy());
351 assert_eq!(
352 plan.names_for(LogAction::Keep),
353 vec!["daemon.log.1", "daemon.log.2"]
354 );
355 assert_eq!(plan.names_for(LogAction::Prune), vec!["daemon.log.3"]);
356 }
357
358 #[test]
359 fn unmanaged_files_are_reported_never_actioned() {
360 let files = vec![
361 LogFile {
362 name: "daemon.log".into(),
363 bytes: 999,
364 }, LogFile {
366 name: "someone_elses.log".into(),
367 bytes: 999,
368 },
369 LogFile {
370 name: "notes.txt".into(),
371 bytes: 10,
372 },
373 ];
374 let plan = plan_log_rotation(&files, &small_policy());
375 let unmanaged = plan.names_for(LogAction::SkipUnmanaged);
376 assert!(unmanaged.contains(&"someone_elses.log"));
377 assert!(unmanaged.contains(&"notes.txt"));
378 assert_eq!(plan.unmanaged_total_bytes, 1_009);
379 assert!(!plan.names_for(LogAction::Rotate).contains(&"notes.txt"));
381 assert!(
382 !plan
383 .names_for(LogAction::Prune)
384 .contains(&"someone_elses.log")
385 );
386 }
387
388 #[test]
389 fn managed_classification_rules() {
390 assert!(is_managed_active_log("daemon.log"));
391 assert!(is_managed_active_log("daemon.err"));
392 assert!(!is_managed_active_log("daemon.log.1"));
393 assert_eq!(
394 managed_rotated_generation("daemon.err.2"),
395 Some(("daemon.err", 2))
396 );
397 assert_eq!(
398 managed_rotated_generation("daemon.log.1"),
399 Some(("daemon.log", 1))
400 );
401 assert_eq!(managed_rotated_generation("daemon.log.1.gz"), None);
403 assert_eq!(managed_rotated_generation("daemon.log.old"), None);
404 assert_eq!(managed_rotated_generation("other.log.1"), None);
405 }
406
407 fn write_file(dir: &Path, name: &str, bytes: usize) {
410 let mut f = fs::File::create(dir.join(name)).unwrap();
411 f.write_all(&vec![b'x'; bytes]).unwrap();
412 }
413
414 #[test]
415 fn rotate_logs_shifts_generations_and_preserves_recent() {
416 let dir = tempfile::tempdir().unwrap();
417 let p = dir.path();
418 write_file(p, "daemon.log", 200); write_file(p, "daemon.log.1", 10); write_file(p, "daemon.log.2", 10); write_file(p, "daemon.err", 10); let receipt = rotate_logs(p, &small_policy()).unwrap();
424 assert_eq!(receipt.rotated, vec!["daemon.log"]);
425 assert!(receipt.pruned.contains(&"daemon.log.2".to_string()));
426
427 assert_eq!(fs::metadata(p.join("daemon.log")).unwrap().len(), 0);
430 assert_eq!(fs::metadata(p.join("daemon.log.1")).unwrap().len(), 200);
431 assert_eq!(fs::metadata(p.join("daemon.log.2")).unwrap().len(), 10);
432 assert!(!p.join("daemon.log.3").exists());
433 assert_eq!(fs::metadata(p.join("daemon.err")).unwrap().len(), 10);
435 }
436
437 #[test]
438 fn rotate_logs_never_touches_unmanaged_files() {
439 let dir = tempfile::tempdir().unwrap();
440 let p = dir.path();
441 write_file(p, "daemon.log", 200);
442 write_file(p, "important_unmanaged.db", 999);
443 write_file(p, "user_notes.txt", 5);
444
445 let receipt = rotate_logs(p, &small_policy()).unwrap();
446 assert!(
448 receipt
449 .skipped_unmanaged
450 .contains(&"important_unmanaged.db".to_string())
451 );
452 assert!(
453 receipt
454 .skipped_unmanaged
455 .contains(&"user_notes.txt".to_string())
456 );
457 assert_eq!(
458 fs::metadata(p.join("important_unmanaged.db"))
459 .unwrap()
460 .len(),
461 999
462 );
463 assert_eq!(fs::metadata(p.join("user_notes.txt")).unwrap().len(), 5);
464 }
465
466 #[test]
467 fn rotate_logs_noop_when_under_limit() {
468 let dir = tempfile::tempdir().unwrap();
469 let p = dir.path();
470 write_file(p, "daemon.log", 10);
471 let receipt = rotate_logs(p, &small_policy()).unwrap();
472 assert!(receipt.rotated.is_empty());
473 assert!(!p.join("daemon.log.1").exists());
474 }
475
476 #[test]
479 fn log_pressure_levels() {
480 let p = small_policy();
481 assert_eq!(assess_log_pressure(100, &p), PressureLevel::Ok);
482 assert_eq!(assess_log_pressure(600, &p), PressureLevel::Warning);
483 assert_eq!(assess_log_pressure(2_000, &p), PressureLevel::Critical);
484 }
485
486 #[test]
487 fn guidance_never_suggests_destructive_commands() {
488 for level in [
489 PressureLevel::Ok,
490 PressureLevel::Warning,
491 PressureLevel::Critical,
492 PressureLevel::Unknown,
493 ] {
494 let g = log_remediation_guidance(level);
495 assert!(!g.contains("rm "), "guidance must not suggest rm: {g}");
496 assert!(!g.contains("rm -rf"));
497 }
498 assert!(log_remediation_guidance(PressureLevel::Critical).contains("Do NOT delete"));
499 }
500
501 #[test]
502 fn plan_serializes_with_stable_tokens() {
503 let files = vec![LogFile {
504 name: "daemon.log".into(),
505 bytes: 200,
506 }];
507 let plan = plan_log_rotation(&files, &small_policy());
508 let value = serde_json::to_value(&plan).unwrap();
509 assert_eq!(value["decisions"][0]["action"], "rotate");
510 let back: LogRotationPlan = serde_json::from_value(value).unwrap();
511 assert_eq!(back, plan);
512 }
513}