Skip to main content

sac/
logging.rs

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(&current, 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(&current, vec![b'x'; 32]).unwrap();
327
328        rotate_current_log_if_too_large(&current, 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(&current, "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(&current, 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}