Skip to main content

utils/
logs.rs

1//! On-disk log file management: per-session rotation, retention, crash
2//! reports, and the user-facing "export logs" bundle.
3//!
4//! Lives in `utils` (not `kopuz`) so the settings UI can trigger an export
5//! without depending on the binary crate. The tracing *subscriber* still
6//! lives in `kopuz::logging`; this module only owns the files it writes to.
7//!
8//! Layout under `<cache>/logs/`:
9//!   - `latest.log`        — the current session (the appender writes here).
10//!   - `kopuz-<ts>.log`    — previous sessions, archived on startup so a
11//!     restart never erases a crashing run. `<ts>` is UTC
12//!     `YYYY-MM-DD_HH-MM-SS`, which sorts alphabetically ==
13//!     chronologically.
14//!   - `crash-<ts>.txt`    — written only on a panic (message + backtrace +
15//!     recent log tail + version/OS).
16
17use std::io::{self, Write};
18use std::path::{Path, PathBuf};
19use std::sync::Mutex;
20use std::time::SystemTime;
21
22static LOG_DIR: Mutex<Option<PathBuf>> = Mutex::new(None);
23
24const LATEST: &str = "latest.log";
25const SESSION_PREFIX: &str = "kopuz-";
26const SESSION_SUFFIX: &str = ".log";
27const CRASH_PREFIX: &str = "crash-";
28/// How many archived session logs to keep before pruning the oldest.
29const KEEP_SESSIONS: usize = 10;
30/// Bytes of `latest.log` to include as context in a crash report / export.
31const TAIL_BYTES: u64 = 64 * 1024;
32
33/// Record the active log directory so [`export_logs`] (called from the UI)
34/// and [`write_crash_report`] (called from the panic hook) can find it.
35pub fn set_log_dir(dir: PathBuf) {
36    if let Ok(mut g) = LOG_DIR.lock() {
37        *g = Some(dir);
38    }
39}
40
41/// The active log directory, if logging has been initialized.
42pub fn log_dir() -> Option<PathBuf> {
43    LOG_DIR.lock().ok().and_then(|g| g.clone())
44}
45
46/// UTC `YYYY-MM-DD_HH-MM-SS` — filename-safe (no colons) and sorts
47/// lexicographically in chronological order.
48pub fn timestamp() -> String {
49    format_time(SystemTime::now())
50}
51
52fn format_time(t: SystemTime) -> String {
53    use time::OffsetDateTime;
54    use time::macros::format_description;
55    let fmt = format_description!("[year]-[month]-[day]_[hour]-[minute]-[second]");
56    OffsetDateTime::from(t)
57        .format(&fmt)
58        .unwrap_or_else(|_| "unknown".to_string())
59}
60
61/// Archive the previous session's `latest.log` to a timestamped file, then
62/// prune old archives. Call this BEFORE the appender opens a fresh
63/// `latest.log`, so the crashing session survives a restart instead of
64/// being overwritten.
65pub fn rotate_session_log(dir: &Path) {
66    let latest = dir.join(LATEST);
67    if latest.exists() {
68        // Name by the previous file's last-modified time (when that session
69        // ran) when available, falling back to now. Both sort chronologically.
70        let ts = std::fs::metadata(&latest)
71            .and_then(|m| m.modified())
72            .map(format_time)
73            .unwrap_or_else(|_| timestamp());
74        let archive = dir.join(format!("{SESSION_PREFIX}{ts}{SESSION_SUFFIX}"));
75        let _ = std::fs::rename(&latest, &archive);
76    }
77    prune_old_sessions(dir, KEEP_SESSIONS);
78}
79
80fn prune_old_sessions(dir: &Path, keep: usize) {
81    let mut sessions: Vec<PathBuf> = match std::fs::read_dir(dir) {
82        Ok(rd) => rd
83            .flatten()
84            .map(|e| e.path())
85            .filter(|p| {
86                p.file_name()
87                    .and_then(|n| n.to_str())
88                    .is_some_and(|n| n.starts_with(SESSION_PREFIX) && n.ends_with(SESSION_SUFFIX))
89            })
90            .collect(),
91        Err(_) => return,
92    };
93    if sessions.len() <= keep {
94        return;
95    }
96    sessions.sort(); // timestamped names sort oldest-first
97    for old in &sessions[..sessions.len() - keep] {
98        let _ = std::fs::remove_file(old);
99    }
100}
101
102/// Read up to the last `TAIL_BYTES` of a file as a UTF-8 (lossy) string,
103/// trimming a partial first line. Used so crash reports/exports carry recent
104/// context without copying a potentially large debug log in full.
105fn read_tail(path: &Path) -> Option<String> {
106    use std::io::{Read, Seek, SeekFrom};
107    let mut f = std::fs::File::open(path).ok()?;
108    let len = f.metadata().ok()?.len();
109    let start = len.saturating_sub(TAIL_BYTES);
110    f.seek(SeekFrom::Start(start)).ok()?;
111    // Read bytes and lossily decode: seeking to a fixed offset can land
112    // mid-UTF-8, which would make read_to_string fail and drop the tail
113    // entirely despite the "lossy" intent.
114    let mut bytes = Vec::new();
115    f.read_to_end(&mut bytes).ok()?;
116    let mut buf = String::from_utf8_lossy(&bytes).into_owned();
117    if start > 0 {
118        // Drop the (likely partial) first line.
119        if let Some(nl) = buf.find('\n') {
120            buf.drain(..=nl);
121        }
122    }
123    Some(buf)
124}
125
126/// Write a crash report from the panic hook. Returns the path written.
127/// `version` is passed in by the caller because `env!("CARGO_PKG_VERSION")`
128/// here would resolve to `utils`, not the app.
129pub fn write_crash_report(
130    dir: &Path,
131    version: &str,
132    message: &str,
133    location: &str,
134    backtrace: &str,
135) -> Option<PathBuf> {
136    let ts = timestamp();
137    let path = dir.join(format!("{CRASH_PREFIX}{ts}.txt"));
138    let mut f = std::fs::File::create(&path).ok()?;
139    let _ = writeln!(f, "kopuz crash report — {ts} UTC");
140    let _ = writeln!(f, "version: {version}");
141    let _ = writeln!(
142        f,
143        "os: {} / {}",
144        std::env::consts::OS,
145        std::env::consts::ARCH
146    );
147    let _ = writeln!(f, "\npanic: {message}");
148    let _ = writeln!(f, "location: {location}");
149    let _ = writeln!(f, "\n--- backtrace ---\n{backtrace}");
150    if let Some(tail) = read_tail(&dir.join(LATEST)) {
151        let _ = writeln!(f, "\n--- recent log ({LATEST} tail) ---\n{tail}");
152    }
153    Some(path)
154}
155
156fn newest_crash(dir: &Path) -> Option<PathBuf> {
157    let mut crashes: Vec<PathBuf> = std::fs::read_dir(dir)
158        .ok()?
159        .flatten()
160        .map(|e| e.path())
161        .filter(|p| {
162            p.file_name()
163                .and_then(|n| n.to_str())
164                .is_some_and(|n| n.starts_with(CRASH_PREFIX))
165        })
166        .collect();
167    crashes.sort();
168    crashes.pop()
169}
170
171/// Reveal the logs directory in the OS file manager so the user can grab
172/// `latest.log`, the archived sessions, and any crash reports directly.
173/// Fire-and-forget — we don't wait on the spawned file manager.
174pub fn open_log_dir() -> io::Result<()> {
175    let dir =
176        log_dir().ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "log dir not set"))?;
177    #[cfg(target_os = "macos")]
178    let program = "open";
179    #[cfg(target_os = "windows")]
180    let program = "explorer";
181    #[cfg(all(unix, not(target_os = "macos")))]
182    let program = "xdg-open";
183    std::process::Command::new(program).arg(&dir).spawn()?;
184    Ok(())
185}
186
187/// Bundle the current session log and the most recent crash report (plus a
188/// version/OS header) into a single file at `dest`, for the user to attach to
189/// a bug report. Triggered by the settings "Export logs" button.
190pub fn export_logs(dest: &Path) -> io::Result<()> {
191    let dir =
192        log_dir().ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "log dir not set"))?;
193
194    // Read sources BEFORE creating (truncating) dest — the user could pick an
195    // existing log file (even latest.log itself) as the destination, which
196    // File::create would erase before we read it.
197    let latest = std::fs::read_to_string(dir.join(LATEST));
198    let crash = newest_crash(&dir).map(|p| {
199        let name = p
200            .file_name()
201            .and_then(|n| n.to_str())
202            .unwrap_or("crash report")
203            .to_string();
204        (name, std::fs::read_to_string(&p))
205    });
206
207    let mut out = std::fs::File::create(dest)?;
208    writeln!(out, "=== kopuz log export — {} UTC ===", timestamp())?;
209    writeln!(
210        out,
211        "os: {} / {}",
212        std::env::consts::OS,
213        std::env::consts::ARCH
214    )?;
215
216    writeln!(out, "\n=== {LATEST} ===")?;
217    match latest {
218        Ok(s) => out.write_all(s.as_bytes())?,
219        Err(e) => writeln!(out, "(unavailable: {e})")?,
220    }
221
222    if let Some((name, body)) = crash {
223        writeln!(out, "\n=== {name} ===")?;
224        if let Ok(s) = body {
225            out.write_all(s.as_bytes())?;
226        }
227    }
228    Ok(())
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234
235    fn tmp(name: &str) -> PathBuf {
236        std::env::temp_dir().join(format!("kopuz-logtest-{}-{name}", std::process::id()))
237    }
238
239    #[test]
240    fn read_tail_small_file_returns_all() {
241        let p = tmp("small");
242        std::fs::write(&p, b"line1\nline2\nline3\n").unwrap();
243        assert_eq!(read_tail(&p).unwrap(), "line1\nline2\nline3\n");
244        let _ = std::fs::remove_file(&p);
245    }
246
247    #[test]
248    fn read_tail_caps_at_64k_and_trims_partial_first_line() {
249        let p = tmp("large");
250        // One giant line (> 64 KiB, no newline) then a complete trailing line.
251        // The 64 KiB window starts mid-giant-line, which must be dropped.
252        let mut content = "x".repeat(70_000);
253        content.push('\n');
254        content.push_str("TAIL LINE");
255        std::fs::write(&p, content.as_bytes()).unwrap();
256        let out = read_tail(&p).unwrap();
257        assert!(
258            out.len() <= TAIL_BYTES as usize,
259            "tail {} exceeds cap",
260            out.len()
261        );
262        assert_eq!(out, "TAIL LINE", "partial first line should be trimmed");
263        let _ = std::fs::remove_file(&p);
264    }
265
266    #[test]
267    fn read_tail_lossy_when_offset_splits_utf8() {
268        let p = tmp("utf8");
269        // 75_000 bytes of 3-byte chars: 64 KiB from the end lands mid-codepoint
270        // (75000-65536 = 9464, 9464 % 3 = 2). read_to_string would error here
271        // and drop the tail; the lossy read must still return it.
272        std::fs::write(&p, "€".repeat(25_000).as_bytes()).unwrap();
273        assert!(
274            read_tail(&p).is_some(),
275            "mid-UTF-8 window boundary should not drop the tail"
276        );
277        let _ = std::fs::remove_file(&p);
278    }
279}