use std::fs::{self, OpenOptions};
use std::io::Write;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
pub(crate) const MAX_PANIC_LOG_BYTES: u64 = 5 * 1024 * 1024;
pub(crate) const ROTATION_GENERATIONS: usize = 3;
fn panic_log_path() -> Option<PathBuf> {
let dir = koda_core::db::config_dir().ok()?;
Some(dir.join("logs").join("panic.log"))
}
pub fn write_panic_log(info: &PanicHookInfo<'_>) {
let Some(path) = panic_log_path() else {
return;
};
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = fs::set_permissions(parent, fs::Permissions::from_mode(0o700));
}
}
let _ = rotate_if_needed(&path, MAX_PANIC_LOG_BYTES, ROTATION_GENERATIONS);
#[cfg(unix)]
let open_result = {
use std::os::unix::fs::OpenOptionsExt;
OpenOptions::new()
.append(true)
.create(true)
.mode(0o600)
.open(&path)
};
#[cfg(not(unix))]
let open_result = OpenOptions::new().append(true).create(true).open(&path);
let Ok(mut file) = open_result else {
return;
};
let mut buf = Vec::with_capacity(1024);
write_panic_record(&mut buf, info, &iso8601_now(), env!("CARGO_PKG_VERSION"));
let _ = file.write_all(&buf);
let _ = file.flush();
}
pub(crate) fn write_panic_record<W: Write>(
writer: &mut W,
info: &PanicHookInfo<'_>,
timestamp: &str,
version: &str,
) {
let location = info
.location()
.map(|l| format!("{}:{}:{}", l.file(), l.line(), l.column()))
.unwrap_or_else(|| "(unknown)".to_string());
let message = info
.payload()
.downcast_ref::<&str>()
.map(|s| (*s).to_string())
.or_else(|| info.payload().downcast_ref::<String>().cloned())
.unwrap_or_else(|| "(non-string panic payload)".to_string());
let thread = std::thread::current()
.name()
.unwrap_or("<unnamed>")
.to_string();
let backtrace = std::backtrace::Backtrace::capture();
let backtrace_str = match backtrace.status() {
std::backtrace::BacktraceStatus::Captured => format!("{backtrace}"),
std::backtrace::BacktraceStatus::Disabled => {
"(disabled — re-run with RUST_BACKTRACE=1 to capture)".to_string()
}
_ => "(unsupported on this platform)".to_string(),
};
let _ = writeln!(
writer,
"======================================================================"
);
let _ = writeln!(writer, "[{timestamp}] PANIC");
let _ = writeln!(writer, "version: koda {version}");
let _ = writeln!(writer, "location: {location}");
let _ = writeln!(writer, "thread: {thread}");
let _ = writeln!(writer, "message: {message}");
let _ = writeln!(writer, "backtrace:");
for line in backtrace_str.lines() {
let _ = writeln!(writer, " {line}");
}
let _ = writeln!(
writer,
"======================================================================\n"
);
}
pub(crate) fn rotate_if_needed(
path: &Path,
max_bytes: u64,
generations: usize,
) -> std::io::Result<()> {
let metadata = match fs::metadata(path) {
Ok(m) => m,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
Err(e) => return Err(e),
};
if metadata.len() <= max_bytes {
return Ok(());
}
let oldest = numbered_path(path, generations);
let _ = fs::remove_file(&oldest);
for n in (1..generations).rev() {
let src = numbered_path(path, n);
let dst = numbered_path(path, n + 1);
if src.exists() {
let _ = fs::rename(&src, &dst);
}
}
let _ = fs::rename(path, numbered_path(path, 1));
Ok(())
}
fn numbered_path(base: &Path, n: usize) -> PathBuf {
let mut s = base.as_os_str().to_owned();
s.push(format!(".{n}"));
PathBuf::from(s)
}
fn iso8601_now() -> String {
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;
OffsetDateTime::now_utc()
.format(&Rfc3339)
.unwrap_or_else(|_| "(timestamp unavailable)".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::Mutex;
static CAPTURED_RECORD: Mutex<Option<String>> = Mutex::new(None);
#[test]
#[serial]
fn write_panic_record_includes_message_location_version_timestamp() {
*CAPTURED_RECORD.lock().unwrap() = None;
let saved = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let mut buf: Vec<u8> = Vec::new();
write_panic_record(&mut buf, info, "2026-01-01T00:00:00Z", "9.9.9");
*CAPTURED_RECORD.lock().unwrap() = Some(String::from_utf8_lossy(&buf).into_owned());
}));
let _ = std::panic::catch_unwind(|| {
panic!("intentional test panic for #1122");
});
std::panic::set_hook(saved);
let formatted = CAPTURED_RECORD.lock().unwrap().clone().expect("hook ran");
assert!(formatted.contains("PANIC"), "header present");
assert!(
formatted.contains("intentional test panic for #1122"),
"message"
);
assert!(formatted.contains("2026-01-01T00:00:00Z"), "timestamp");
assert!(formatted.contains("koda 9.9.9"), "version");
assert!(formatted.contains("location:"), "location field");
assert!(formatted.contains("backtrace:"), "backtrace field");
assert!(formatted.trim_end().ends_with('='), "trailing delimiter");
}
#[test]
fn rotate_does_nothing_when_under_threshold() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("panic.log");
std::fs::write(&path, b"small content").unwrap();
rotate_if_needed(&path, 1024, 3).unwrap();
assert!(path.exists());
assert!(!numbered_path(&path, 1).exists());
}
#[test]
fn rotate_shifts_generations_when_over_threshold() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("panic.log");
std::fs::write(&path, vec![b'x'; 200]).unwrap();
std::fs::write(numbered_path(&path, 1), b"gen1").unwrap();
std::fs::write(numbered_path(&path, 2), b"gen2").unwrap();
rotate_if_needed(&path, 100, 3).unwrap();
assert!(!path.exists(), "current rotated away");
assert_eq!(std::fs::read(numbered_path(&path, 1)).unwrap().len(), 200);
assert_eq!(std::fs::read(numbered_path(&path, 2)).unwrap(), b"gen1");
assert_eq!(std::fs::read(numbered_path(&path, 3)).unwrap(), b"gen2");
}
#[test]
fn rotate_drops_oldest_generation() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("panic.log");
std::fs::write(&path, vec![b'x'; 200]).unwrap();
std::fs::write(numbered_path(&path, 1), b"gen1").unwrap();
std::fs::write(numbered_path(&path, 2), b"gen2").unwrap();
std::fs::write(numbered_path(&path, 3), b"oldest_to_drop").unwrap();
rotate_if_needed(&path, 100, 3).unwrap();
assert_eq!(std::fs::read(numbered_path(&path, 3)).unwrap(), b"gen2");
assert!(
!numbered_path(&path, 4).exists(),
"rotation must not create a 4th generation"
);
}
#[test]
fn rotate_no_op_when_file_missing() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("does_not_exist.log");
rotate_if_needed(&path, 100, 3).unwrap();
assert!(!path.exists());
}
}