ralph_core/diagnostics/
log_rotation.rs1use chrono::Local;
2use std::fs;
3use std::io;
4use std::path::{Path, PathBuf};
5
6const MAX_LOG_FILES: usize = 5;
7
8pub 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 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
47pub 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 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 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 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 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 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 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 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 assert!(count <= 5);
249 }
250}