Skip to main content

rch_common/
log_retention.rs

1//! Daemon log rotation + doctor log-pressure reporting
2//! (bd-session-history-remediation-ocv9i.14.3).
3//!
4//! RCH's own logs must never *become* the disk-pressure problem session history
5//! was trying to diagnose. This module bounds `daemon.log` / `daemon.err` by
6//! size with a fixed number of rotated generations, surfaces current log sizes
7//! for `rch doctor`/status, and emits remediation guidance that respects the
8//! project's no-destructive-command norms (it suggests the managed `rch` action,
9//! never a raw `rm`).
10//!
11//! Two layers, both safe by construction:
12//! - [`plan_log_rotation`] is a **pure** decision over a directory listing: it
13//!   classifies each entry as a managed active log, a managed rotated
14//!   generation, or an **unmanaged** file that must never be touched, and
15//!   decides Keep / Rotate / Prune accordingly.
16//! - [`rotate_logs`] performs that plan against a real directory using only
17//!   renames/removals of files RCH owns (`daemon.log`, `daemon.err`, and their
18//!   `*.N` generations) — an unmanaged file in the same dir is reported but
19//!   never moved or deleted.
20//!
21//! [`assess_log_pressure`] folds the directory's managed total into a
22//! [`PressureLevel`] for the doctor surface, with [`log_remediation_guidance`]
23//! producing norm-respecting advice.
24
25use std::fs;
26use std::path::Path;
27
28use serde::{Deserialize, Serialize};
29
30use crate::disk_pressure_report::PressureLevel;
31
32/// The active daemon log basenames RCH owns and rotates.
33pub const MANAGED_LOG_NAMES: &[&str] = &["daemon.log", "daemon.err"];
34
35/// Size/retention policy for the managed daemon logs.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub struct LogRetentionPolicy {
38    /// Rotate an active log once it exceeds this many bytes.
39    pub max_file_bytes: u64,
40    /// Rotated generations to retain per log (oldest beyond this are pruned).
41    pub keep_rotated: usize,
42    /// Managed total (active + rotated) at/above which pressure is `Warning`.
43    pub warn_total_bytes: u64,
44    /// Managed total at/above which pressure is `Critical`.
45    pub critical_total_bytes: u64,
46}
47
48impl Default for LogRetentionPolicy {
49    fn default() -> Self {
50        Self {
51            max_file_bytes: 16 * 1024 * 1024, // 16 MiB per active log
52            keep_rotated: 3,
53            warn_total_bytes: 64 * 1024 * 1024, // 64 MiB of logs
54            critical_total_bytes: 256 * 1024 * 1024, // 256 MiB of logs
55        }
56    }
57}
58
59/// Whether `name` is one of the managed active log basenames.
60#[must_use]
61pub fn is_managed_active_log(name: &str) -> bool {
62    MANAGED_LOG_NAMES.contains(&name)
63}
64
65/// If `name` is a rotated generation of a managed log (`daemon.log.2`), return
66/// `(base, generation)`. Generation is a positive integer.
67#[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            // Suffix must be a bare positive integer (no `.gz`, no extra dots).
72            if let Ok(generation) = suffix.parse::<usize>()
73                && generation >= 1
74            {
75                return Some((base, generation));
76            }
77        }
78    }
79    None
80}
81
82/// The action planned for one directory entry.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "snake_case")]
85pub enum LogAction {
86    /// A managed file within retention — leave it.
87    Keep,
88    /// A managed active log over the size limit — rotate it.
89    Rotate,
90    /// A managed rotated generation beyond `keep_rotated` — prune it.
91    Prune,
92    /// Not an RCH-managed log — report only; never touch.
93    SkipUnmanaged,
94}
95
96impl LogAction {
97    /// Stable lowercase token.
98    #[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/// One directory entry the planner/rotator considered.
110#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
111pub struct LogFile {
112    pub name: String,
113    pub bytes: u64,
114}
115
116/// The per-entry plan for a directory.
117#[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/// The result of planning a directory.
125#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
126pub struct LogRotationPlan {
127    pub decisions: Vec<LogFileDecision>,
128    /// Total bytes of managed logs (active + rotated).
129    pub managed_total_bytes: u64,
130    /// Total bytes of unmanaged files seen (reported, untouched).
131    pub unmanaged_total_bytes: u64,
132}
133
134impl LogRotationPlan {
135    /// Names decided for a given action.
136    #[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/// Plan rotation for a directory listing. Pure — no I/O. Classifies every entry
147/// and decides Keep/Rotate/Prune/SkipUnmanaged. Unmanaged files are never
148/// actioned (only reported), and managed rotated generations beyond
149/// `keep_rotated` are pruned (oldest = highest generation).
150#[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            // After a rotation the active becomes `.1`, so a generation strictly
167            // greater than keep_rotated will fall off — prune it now.
168            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/// What a real rotation pass did.
192#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
193pub struct LogRotationReceipt {
194    /// Active logs that were rotated (active -> `.1`, generations shifted up).
195    pub rotated: Vec<String>,
196    /// Rotated generations pruned (beyond `keep_rotated`).
197    pub pruned: Vec<String>,
198    /// Unmanaged files seen and deliberately left untouched.
199    pub skipped_unmanaged: Vec<String>,
200    /// Bytes reclaimed by pruning old generations.
201    pub pruned_bytes: u64,
202}
203
204/// Rotate the managed logs in `dir` per `policy`, doing real filesystem work but
205/// touching only RCH-owned files. For each active log over the size limit:
206/// existing generations shift up (`daemon.log.1` -> `.2`, …), generations past
207/// `keep_rotated` are removed, the active log becomes `.1`, and a fresh empty
208/// active log is recreated. Unmanaged files are reported but never moved or
209/// deleted.
210///
211/// Errors are surfaced (not swallowed) so the daemon can log-and-continue; a
212/// best-effort caller should treat failure as "rotation deferred", never as a
213/// reason to delete anything by hand.
214pub fn rotate_logs(dir: &Path, policy: &LogRetentionPolicy) -> std::io::Result<LogRotationReceipt> {
215    let mut receipt = LogRotationReceipt::default();
216
217    // Snapshot the directory once.
218    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    // Record unmanaged files (never touched).
230    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        // Prune the oldest generation that would fall off, then shift up.
247        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        // Shift remaining generations up: .(N-1) -> .N, down to .1 -> .2.
256        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        // Active -> .1, then recreate an empty active log.
263        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/// Fold a managed-log total into a doctor pressure level.
272#[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/// Norm-respecting remediation guidance for a log-pressure level. Never suggests
284/// a destructive raw command; points at the managed rotation path instead.
285#[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    // --- Pure plan ----------------------------------------------------------
316
317    #[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            }, // over 100 -> rotate
324            LogFile {
325                name: "daemon.err".into(),
326                bytes: 50,
327            }, // under -> keep
328        ];
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            }, // keep (<= keep_rotated=2)
341            LogFile {
342                name: "daemon.log.2".into(),
343                bytes: 10,
344            }, // keep
345            LogFile {
346                name: "daemon.log.3".into(),
347                bytes: 10,
348            }, // prune (>2)
349        ];
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            }, // managed, rotate
365            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        // Unmanaged never appears in rotate/prune.
380        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        // Not a bare integer generation => unmanaged.
402        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    // --- Real filesystem rotation ------------------------------------------
408
409    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); // over 100 -> rotate
419        write_file(p, "daemon.log.1", 10); // -> .2
420        write_file(p, "daemon.log.2", 10); // -> would be .3, pruned (keep_rotated=2)
421        write_file(p, "daemon.err", 10); // under -> untouched
422
423        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        // Active recreated empty; .1 is the just-rotated 200-byte log; .2 is the
428        // previous .1; .3 must NOT exist (pruned/never created beyond keep).
429        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        // daemon.err under the limit was left alone.
434        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        // Unmanaged files reported and still present, unchanged.
447        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    // --- Pressure + guidance ------------------------------------------------
477
478    #[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}