1use std::fs::{create_dir_all, OpenOptions};
2use std::io::Write;
3use std::path::PathBuf;
4use std::process;
5use std::sync::OnceLock;
6use std::time::{Duration, SystemTime};
7
8use tracing_subscriber::fmt::time::UtcTime;
9use tracing_subscriber::{fmt, EnvFilter};
10
11static LOG_PATH: OnceLock<Option<PathBuf>> = OnceLock::new();
12const MAX_LOG_FILES: usize = 12;
13const MAX_LOG_BYTES: u64 = 5 * 1024 * 1024;
14const MAX_LOG_AGE_DAYS: u64 = 14;
15
16pub fn init() {
17 let log_path = LOG_PATH.get_or_init(resolve_log_path).clone();
18
19 let Some(log_path) = log_path else {
20 return;
21 };
22
23 let _ = rotate_current_log_if_too_large(&log_path, MAX_LOG_BYTES);
24 let _ = prune_old_logs(&log_path, MAX_LOG_FILES);
25 let _ = prune_logs_older_than(
26 &log_path,
27 Duration::from_secs(60 * 60 * 24 * MAX_LOG_AGE_DAYS),
28 );
29
30 let Ok(file) = open_log_file(&log_path) else {
31 return;
32 };
33
34 let filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("sac=debug"));
35 let _ = file;
36 let writer_path = log_path.clone();
37
38 let _ = fmt()
39 .with_env_filter(filter)
40 .with_writer(move || open_log_file(&writer_path).expect("log file should be openable"))
41 .with_ansi(false)
42 .with_target(true)
43 .with_file(true)
44 .with_line_number(true)
45 .with_timer(UtcTime::rfc_3339())
46 .try_init();
47}
48
49pub fn current_log_path() -> Option<PathBuf> {
50 LOG_PATH.get().cloned().flatten()
51}
52
53pub fn append_test_log_line(message: &str) -> std::io::Result<()> {
54 let Some(path) = resolve_log_path() else {
55 return Ok(());
56 };
57 let mut file = open_log_file(&path)?;
58 writeln!(file, "{message}")
59}
60
61pub fn list_log_files() -> std::io::Result<Vec<PathBuf>> {
62 let Some(logs_dir) = crate::paths::sac_logs_dir() else {
63 return Ok(Vec::new());
64 };
65 let mut entries = if !logs_dir.exists() {
66 Vec::new()
67 } else {
68 std::fs::read_dir(&logs_dir)?
69 .filter_map(|entry| entry.ok())
70 .filter(|entry| {
71 entry
72 .file_name()
73 .to_str()
74 .is_some_and(|name| name.starts_with("sac-") && name.ends_with(".log"))
75 })
76 .map(|entry| entry.path())
77 .collect::<Vec<_>>()
78 };
79 entries.sort();
80 Ok(entries)
81}
82
83pub fn tail_current_log_lines(limit: usize) -> std::io::Result<Vec<String>> {
84 let Some(path) = current_log_path().or_else(resolve_log_path) else {
85 return Ok(Vec::new());
86 };
87 if !path.exists() {
88 return Ok(Vec::new());
89 }
90 let content = std::fs::read_to_string(path)?;
91 let lines = content.lines().map(str::to_string).collect::<Vec<_>>();
92 let start = lines.len().saturating_sub(limit);
93 Ok(lines[start..].to_vec())
94}
95
96pub fn log_file_count() -> std::io::Result<usize> {
97 Ok(list_log_files()?.len())
98}
99
100fn resolve_log_path() -> Option<PathBuf> {
101 crate::paths::sac_log_path_for_pid(process::id())
102}
103
104fn open_log_file(path: &PathBuf) -> std::io::Result<std::fs::File> {
105 if let Some(parent) = path.parent() {
106 create_dir_all(parent)?;
107 }
108 OpenOptions::new().create(true).append(true).open(path)
109}
110
111fn rotate_current_log_if_too_large(path: &PathBuf, max_bytes: u64) -> std::io::Result<()> {
112 let Ok(metadata) = std::fs::metadata(path) else {
113 return Ok(());
114 };
115 if metadata.len() < max_bytes {
116 return Ok(());
117 }
118
119 let timestamp = SystemTime::now()
120 .duration_since(SystemTime::UNIX_EPOCH)
121 .unwrap_or_default()
122 .as_secs();
123 let stem = path
124 .file_stem()
125 .and_then(|stem| stem.to_str())
126 .unwrap_or("sac-log");
127 let rotated = path.with_file_name(format!("{}-{}.log", stem, timestamp));
128 std::fs::rename(path, rotated)?;
129 Ok(())
130}
131
132fn prune_old_logs(current_path: &PathBuf, keep: usize) -> std::io::Result<()> {
133 let Some(logs_dir) = current_path.parent() else {
134 return Ok(());
135 };
136
137 let current_name = current_path.file_name().map(|name| name.to_owned());
138 let mut entries = std::fs::read_dir(logs_dir)?
139 .filter_map(|entry| entry.ok())
140 .filter(|entry| {
141 entry
142 .file_type()
143 .map(|kind| kind.is_file())
144 .unwrap_or(false)
145 })
146 .filter(|entry| {
147 entry
148 .file_name()
149 .to_str()
150 .is_some_and(|name| name.starts_with("sac-") && name.ends_with(".log"))
151 })
152 .collect::<Vec<_>>();
153
154 entries.sort_by_key(|entry| {
155 entry
156 .metadata()
157 .and_then(|meta| meta.modified())
158 .unwrap_or(std::time::SystemTime::UNIX_EPOCH)
159 });
160
161 let mut removable = entries.len().saturating_sub(keep.saturating_sub(1));
162 for entry in entries {
163 if removable == 0 {
164 break;
165 }
166 if current_name
167 .as_ref()
168 .is_some_and(|name| *name == entry.file_name())
169 {
170 continue;
171 }
172 let _ = std::fs::remove_file(entry.path());
173 removable = removable.saturating_sub(1);
174 }
175
176 Ok(())
177}
178
179fn prune_logs_older_than(current_path: &PathBuf, max_age: Duration) -> std::io::Result<()> {
180 let Some(logs_dir) = current_path.parent() else {
181 return Ok(());
182 };
183 let current_name = current_path.file_name().map(|name| name.to_owned());
184 let cutoff = SystemTime::now()
185 .checked_sub(max_age)
186 .unwrap_or(SystemTime::UNIX_EPOCH);
187
188 for entry in std::fs::read_dir(logs_dir)?.filter_map(|entry| entry.ok()) {
189 if current_name
190 .as_ref()
191 .is_some_and(|name| *name == entry.file_name())
192 {
193 continue;
194 }
195 let is_log = entry
196 .file_name()
197 .to_str()
198 .is_some_and(|name| name.starts_with("sac-") && name.ends_with(".log"));
199 if !is_log {
200 continue;
201 }
202 let modified = match entry.metadata().and_then(|meta| meta.modified()) {
203 Ok(modified) => modified,
204 Err(_) => continue,
205 };
206 if modified < cutoff {
207 let _ = std::fs::remove_file(entry.path());
208 }
209 }
210
211 Ok(())
212}
213
214#[cfg(test)]
215mod tests {
216 use super::*;
217 use crate::test_env_lock;
218 use std::ffi::OsString;
219
220 fn restore_env(name: &str, value: Option<OsString>) {
221 match value {
222 Some(value) => unsafe { std::env::set_var(name, value) },
223 None => unsafe { std::env::remove_var(name) },
224 }
225 }
226
227 #[test]
228 fn prune_old_logs_keeps_current_and_newest_files() {
229 let root = std::env::temp_dir().join(format!(
230 "sac_log_prune_{}",
231 std::time::SystemTime::now()
232 .duration_since(std::time::UNIX_EPOCH)
233 .expect("time went backwards")
234 .as_nanos()
235 ));
236 std::fs::create_dir_all(&root).unwrap();
237
238 let names = ["sac-1.log", "sac-2.log", "sac-3.log", "sac-4.log"];
239 for name in names {
240 std::fs::write(root.join(name), name).unwrap();
241 std::thread::sleep(std::time::Duration::from_millis(5));
242 }
243
244 let current = root.join("sac-4.log");
245 prune_old_logs(¤t, 2).unwrap();
246
247 let mut remaining = std::fs::read_dir(&root)
248 .unwrap()
249 .filter_map(|entry| entry.ok())
250 .map(|entry| entry.file_name().to_string_lossy().into_owned())
251 .collect::<Vec<_>>();
252 remaining.sort();
253
254 assert_eq!(remaining, vec!["sac-4.log"]);
255
256 let _ = std::fs::remove_dir_all(root);
257 }
258
259 #[test]
260 fn append_test_log_line_creates_and_writes_to_log_file() {
261 let _guard = test_env_lock();
262 let original_sac_home = std::env::var_os("SAC_HOME");
263 let root = std::env::temp_dir().join(format!(
264 "sac_log_smoke_{}",
265 std::time::SystemTime::now()
266 .duration_since(std::time::UNIX_EPOCH)
267 .expect("time went backwards")
268 .as_nanos()
269 ));
270 std::fs::create_dir_all(&root).unwrap();
271 unsafe {
272 std::env::set_var("SAC_HOME", &root);
273 }
274
275 let path = crate::paths::sac_log_path_for_pid(std::process::id()).unwrap();
276 std::fs::create_dir_all(path.parent().unwrap()).unwrap();
277 append_test_log_line("smoke-log-entry").unwrap();
278
279 let content = std::fs::read_to_string(path).unwrap();
280 assert!(content.contains("smoke-log-entry"));
281
282 restore_env("SAC_HOME", original_sac_home);
283 let _ = std::fs::remove_dir_all(root);
284 }
285
286 #[test]
287 fn list_log_files_and_tail_current_log_lines_work() {
288 let _guard = test_env_lock();
289 let original_sac_home = std::env::var_os("SAC_HOME");
290 let root = std::env::temp_dir().join(format!(
291 "sac_log_list_tail_{}",
292 std::time::SystemTime::now()
293 .duration_since(std::time::UNIX_EPOCH)
294 .expect("time went backwards")
295 .as_nanos()
296 ));
297 std::fs::create_dir_all(root.join("logs")).unwrap();
298 unsafe {
299 std::env::set_var("SAC_HOME", &root);
300 }
301
302 let path = crate::paths::sac_log_path_for_pid(std::process::id()).unwrap();
303 std::fs::write(root.join("logs/sac-old.log"), "old\n").unwrap();
304 std::fs::write(&path, "line-1\nline-2\nline-3\n").unwrap();
305
306 let logs = list_log_files().unwrap();
307 assert_eq!(logs.len(), 2);
308 let tail = tail_current_log_lines(2).unwrap();
309 assert_eq!(tail, vec!["line-2".to_string(), "line-3".to_string()]);
310
311 restore_env("SAC_HOME", original_sac_home);
312 let _ = std::fs::remove_dir_all(root);
313 }
314
315 #[test]
316 fn rotate_current_log_if_too_large_renames_file() {
317 let root = std::env::temp_dir().join(format!(
318 "sac_log_rotate_{}",
319 SystemTime::now()
320 .duration_since(SystemTime::UNIX_EPOCH)
321 .expect("time went backwards")
322 .as_nanos()
323 ));
324 std::fs::create_dir_all(&root).unwrap();
325 let current = root.join("sac-123.log");
326 std::fs::write(¤t, vec![b'x'; 32]).unwrap();
327
328 rotate_current_log_if_too_large(¤t, 8).unwrap();
329
330 assert!(!current.exists());
331 let rotated = std::fs::read_dir(&root)
332 .unwrap()
333 .filter_map(|entry| entry.ok())
334 .map(|entry| entry.file_name().to_string_lossy().into_owned())
335 .collect::<Vec<_>>();
336 assert_eq!(rotated.len(), 1);
337 assert!(rotated[0].starts_with("sac-123-"));
338
339 let _ = std::fs::remove_dir_all(root);
340 }
341
342 #[test]
343 fn prune_logs_older_than_removes_stale_logs_but_keeps_current() {
344 let root = std::env::temp_dir().join(format!(
345 "sac_log_age_{}",
346 SystemTime::now()
347 .duration_since(SystemTime::UNIX_EPOCH)
348 .expect("time went backwards")
349 .as_nanos()
350 ));
351 std::fs::create_dir_all(&root).unwrap();
352 let current = root.join("sac-100.log");
353 let stale = root.join("sac-101.log");
354 std::fs::write(¤t, "current").unwrap();
355 std::fs::write(&stale, "stale").unwrap();
356
357 let old_secs = SystemTime::now()
358 .checked_sub(Duration::from_secs(60 * 60 * 24 * 30))
359 .unwrap()
360 .duration_since(SystemTime::UNIX_EPOCH)
361 .unwrap()
362 .as_secs() as i64;
363 filetime::set_file_mtime(&stale, filetime::FileTime::from_unix_time(old_secs, 0)).unwrap();
364
365 prune_logs_older_than(¤t, Duration::from_secs(60 * 60 * 24 * 7)).unwrap();
366
367 assert!(current.exists());
368 assert!(!stale.exists());
369
370 let _ = std::fs::remove_dir_all(root);
371 }
372}