1use 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-";
28const KEEP_SESSIONS: usize = 10;
30const TAIL_BYTES: u64 = 64 * 1024;
32
33pub fn set_log_dir(dir: PathBuf) {
36 if let Ok(mut g) = LOG_DIR.lock() {
37 *g = Some(dir);
38 }
39}
40
41pub fn log_dir() -> Option<PathBuf> {
43 LOG_DIR.lock().ok().and_then(|g| g.clone())
44}
45
46pub 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
61pub fn rotate_session_log(dir: &Path) {
66 let latest = dir.join(LATEST);
67 if latest.exists() {
68 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(); for old in &sessions[..sessions.len() - keep] {
98 let _ = std::fs::remove_file(old);
99 }
100}
101
102fn 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 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 if let Some(nl) = buf.find('\n') {
120 buf.drain(..=nl);
121 }
122 }
123 Some(buf)
124}
125
126pub 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
171pub 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
187pub 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 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 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 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}