Skip to main content

ralph_core/diagnostics/
log_rotation.rs

1use chrono::Local;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6const MAX_LOG_FILES: usize = 5;
7
8/// Rotates log files in the given directory, keeping at most `max_files` entries.
9///
10/// Lists `ralph-*.log` files, sorts lexicographically (which gives timestamp order),
11/// and deletes the oldest if count exceeds `max_files - 1` (to make room for a new one).
12pub fn rotate_logs(logs_dir: &Path, max_files: usize) -> io::Result<()> {
13    if !logs_dir.exists() {
14        return Ok(());
15    }
16
17    let mut log_files: Vec<PathBuf> = fs::read_dir(logs_dir)?
18        .filter_map(|entry| {
19            let entry = entry.ok()?;
20            let name = entry.file_name().to_string_lossy().to_string();
21            if name.starts_with("ralph-")
22                && Path::new(&name)
23                    .extension()
24                    .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
25            {
26                Some(entry.path())
27            } else {
28                None
29            }
30        })
31        .collect();
32
33    log_files.sort();
34
35    // Delete oldest files to make room for a new one
36    let to_keep = max_files.saturating_sub(1);
37    if log_files.len() > to_keep {
38        let to_remove = log_files.len() - to_keep;
39        for path in &log_files[..to_remove] {
40            let _ = fs::remove_file(path);
41        }
42    }
43
44    Ok(())
45}
46
47/// Creates a new timestamped log file in `.ralph/diagnostics/logs/`.
48///
49/// Creates the directory if needed, rotates old logs, and returns the file handle
50/// and path of the newly created log file.
51pub fn create_log_file(base_path: &Path) -> io::Result<(fs::File, PathBuf)> {
52    let logs_dir = base_path.join(".ralph").join("diagnostics").join("logs");
53    fs::create_dir_all(&logs_dir)?;
54
55    rotate_logs(&logs_dir, MAX_LOG_FILES)?;
56
57    let timestamp = Local::now().format("%Y-%m-%dT%H-%M-%S-%3f");
58    let pid = std::process::id();
59    let log_path = logs_dir.join(format!("ralph-{}-{}.log", timestamp, pid));
60    let file = fs::File::create(&log_path)?;
61
62    Ok((file, log_path))
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use tempfile::TempDir;
69
70    #[test]
71    fn test_rotate_empty_dir() {
72        let tmp = TempDir::new().unwrap();
73        let logs_dir = tmp.path().join("logs");
74        fs::create_dir_all(&logs_dir).unwrap();
75
76        rotate_logs(&logs_dir, 5).unwrap();
77
78        let count = fs::read_dir(&logs_dir).unwrap().count();
79        assert_eq!(count, 0);
80    }
81
82    #[test]
83    fn test_rotate_under_limit() {
84        let tmp = TempDir::new().unwrap();
85        let logs_dir = tmp.path().join("logs");
86        fs::create_dir_all(&logs_dir).unwrap();
87
88        for i in 0..3 {
89            fs::write(
90                logs_dir.join(format!("ralph-2025-01-0{}T12-00-00.log", i + 1)),
91                "test",
92            )
93            .unwrap();
94        }
95
96        rotate_logs(&logs_dir, 5).unwrap();
97
98        let count: Vec<_> = fs::read_dir(&logs_dir)
99            .unwrap()
100            .filter_map(|e| e.ok())
101            .collect();
102        assert_eq!(count.len(), 3);
103    }
104
105    #[test]
106    fn test_rotate_at_limit() {
107        let tmp = TempDir::new().unwrap();
108        let logs_dir = tmp.path().join("logs");
109        fs::create_dir_all(&logs_dir).unwrap();
110
111        // 5 files, max_files=5 means we keep 4 to make room for a new one
112        for i in 0..5 {
113            fs::write(
114                logs_dir.join(format!("ralph-2025-01-0{}T12-00-00.log", i + 1)),
115                "test",
116            )
117            .unwrap();
118        }
119
120        rotate_logs(&logs_dir, 5).unwrap();
121
122        let remaining: Vec<String> = fs::read_dir(&logs_dir)
123            .unwrap()
124            .filter_map(|e| e.ok())
125            .map(|e| e.file_name().to_string_lossy().to_string())
126            .collect();
127        assert_eq!(remaining.len(), 4);
128        // Oldest file should be removed
129        assert!(!remaining.contains(&"ralph-2025-01-01T12-00-00.log".to_string()));
130    }
131
132    #[test]
133    fn test_rotate_over_limit() {
134        let tmp = TempDir::new().unwrap();
135        let logs_dir = tmp.path().join("logs");
136        fs::create_dir_all(&logs_dir).unwrap();
137
138        for i in 0..8 {
139            fs::write(
140                logs_dir.join(format!("ralph-2025-01-{:02}T12-00-00.log", i + 1)),
141                "test",
142            )
143            .unwrap();
144        }
145
146        rotate_logs(&logs_dir, 5).unwrap();
147
148        let mut remaining: Vec<String> = fs::read_dir(&logs_dir)
149            .unwrap()
150            .filter_map(|e| e.ok())
151            .map(|e| e.file_name().to_string_lossy().to_string())
152            .collect();
153        remaining.sort();
154        assert_eq!(remaining.len(), 4);
155        // Only the 4 newest should remain
156        assert_eq!(remaining[0], "ralph-2025-01-05T12-00-00.log");
157        assert_eq!(remaining[3], "ralph-2025-01-08T12-00-00.log");
158    }
159
160    #[test]
161    fn test_rotate_ignores_non_matching_files() {
162        let tmp = TempDir::new().unwrap();
163        let logs_dir = tmp.path().join("logs");
164        fs::create_dir_all(&logs_dir).unwrap();
165
166        // Create 6 ralph log files + some non-matching files
167        for i in 0..6 {
168            fs::write(
169                logs_dir.join(format!("ralph-2025-01-{:02}T12-00-00.log", i + 1)),
170                "test",
171            )
172            .unwrap();
173        }
174        fs::write(logs_dir.join("other.log"), "keep me").unwrap();
175        fs::write(logs_dir.join("ralph.txt"), "keep me too").unwrap();
176        fs::write(logs_dir.join("not-ralph-log.log"), "and me").unwrap();
177
178        rotate_logs(&logs_dir, 5).unwrap();
179
180        let remaining: Vec<String> = fs::read_dir(&logs_dir)
181            .unwrap()
182            .filter_map(|e| e.ok())
183            .map(|e| e.file_name().to_string_lossy().to_string())
184            .collect();
185
186        // 4 ralph logs + 3 non-matching files
187        assert_eq!(remaining.len(), 7);
188        assert!(remaining.contains(&"other.log".to_string()));
189        assert!(remaining.contains(&"ralph.txt".to_string()));
190        assert!(remaining.contains(&"not-ralph-log.log".to_string()));
191    }
192
193    #[test]
194    fn test_rotate_nonexistent_dir() {
195        let tmp = TempDir::new().unwrap();
196        let logs_dir = tmp.path().join("does-not-exist");
197
198        // Should succeed without error
199        rotate_logs(&logs_dir, 5).unwrap();
200    }
201
202    #[test]
203    fn test_create_log_file_creates_directory() {
204        let tmp = TempDir::new().unwrap();
205
206        let (_, path) = create_log_file(tmp.path()).unwrap();
207
208        assert!(path.exists());
209        assert!(tmp.path().join(".ralph/diagnostics/logs").exists());
210        let name = path.file_name().unwrap().to_str().unwrap();
211        assert!(name.starts_with("ralph-"));
212        assert!(
213            Path::new(name)
214                .extension()
215                .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
216        );
217    }
218
219    #[test]
220    fn test_create_log_file_rotates() {
221        let tmp = TempDir::new().unwrap();
222        let logs_dir = tmp.path().join(".ralph/diagnostics/logs");
223        fs::create_dir_all(&logs_dir).unwrap();
224
225        // Pre-populate with 5 files
226        for i in 0..5 {
227            fs::write(
228                logs_dir.join(format!("ralph-2025-01-{:02}T12-00-00.log", i + 1)),
229                "old",
230            )
231            .unwrap();
232        }
233
234        let (_, _) = create_log_file(tmp.path()).unwrap();
235
236        let count = fs::read_dir(&logs_dir)
237            .unwrap()
238            .filter_map(|e| e.ok())
239            .filter(|e| {
240                let name = e.file_name().to_string_lossy().to_string();
241                name.starts_with("ralph-")
242                    && Path::new(&name)
243                        .extension()
244                        .is_some_and(|ext| ext.eq_ignore_ascii_case("log"))
245            })
246            .count();
247        // 4 old (after rotation) + 1 new = 5
248        assert!(count <= 5);
249    }
250}