use std::backtrace::Backtrace;
use std::fmt::{self, Write as _};
use std::io;
use std::panic::PanicHookInfo;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use crate::paths;
const KEEP: usize = 20;
static LAST_ACTION: Mutex<String> = Mutex::new(String::new());
pub fn install(dir: PathBuf, process: &'static str) {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let id = format!(
"{}-{process}",
chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f")
);
if let Err(e) = write(&dir, &id, &report(info, process)) {
log::warn!("could not write a crash report to {}: {e}", dir.display());
}
previous(info);
}));
}
pub fn note(action: &impl fmt::Debug) {
let mut kind = Word(String::new());
let _ = write!(kind, "{action:?}");
if let Ok(mut last) = LAST_ACTION.lock() {
*last = kind.0;
}
}
pub fn list(dir: &Path) -> io::Result<Vec<String>> {
let entries = match std::fs::read_dir(dir) {
Ok(entries) => entries,
Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(e) => return Err(e),
};
let mut ids = Vec::new();
for entry in entries {
let name = entry?.file_name();
if let Some(id) = name.to_str().and_then(|name| name.strip_suffix(".txt")) {
ids.push(id.to_string());
}
}
ids.sort_unstable_by(|a, b| b.cmp(a));
Ok(ids)
}
pub fn show(dir: &Path, id: &str) -> io::Result<String> {
if !list(dir)?.iter().any(|known| known == id) {
return Err(io::Error::new(
io::ErrorKind::NotFound,
format!("no crash report {id}; `postvak crashes` lists them"),
));
}
std::fs::read_to_string(dir.join(format!("{id}.txt")))
}
fn report(info: &PanicHookInfo, process: &str) -> String {
let thread = std::thread::current();
let thread = first_word(thread.name().unwrap_or("unnamed"));
let last = LAST_ACTION
.try_lock()
.map(|last| last.clone())
.unwrap_or_default();
let location = info
.location()
.map(|at| format!("{}:{}:{}", at.file(), at.line(), at.column()))
.unwrap_or_default();
let message = info
.payload()
.downcast_ref::<&'static str>()
.copied()
.unwrap_or("(withheld: a formatted message can quote mail)");
format!(
"Postvak crash report. It is never sent anywhere; attach it to a bug report if you like.\n\
version: {}\nos: {} {}\nprocess: {process}\nthread: {thread}\nlast action: {last}\n\
location: {location}\nmessage: {message}\n\nbacktrace:\n{}\n",
env!("CARGO_PKG_VERSION"),
std::env::consts::OS,
std::env::consts::ARCH,
Backtrace::force_capture(),
)
}
fn write(dir: &Path, id: &str, report: &str) -> io::Result<()> {
paths::create_private_dir(dir)?;
paths::write_atomic(&dir.join(format!("{id}.txt")), report.as_bytes())?;
prune(dir, KEEP)
}
fn prune(dir: &Path, keep: usize) -> io::Result<()> {
for id in list(dir)?.iter().skip(keep) {
std::fs::remove_file(dir.join(format!("{id}.txt")))?;
}
Ok(())
}
fn first_word(text: &str) -> &str {
let end = text.find(|c: char| !is_word(c)).unwrap_or(text.len());
&text[..end]
}
fn is_word(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_'
}
struct Word(String);
impl fmt::Write for Word {
fn write_str(&mut self, text: &str) -> fmt::Result {
let word = first_word(text);
self.0.push_str(word);
if word.len() < text.len() {
return Err(fmt::Error);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn only_the_newest_reports_are_kept() {
let dir = tempfile::tempdir().unwrap();
for n in 0..5 {
std::fs::write(dir.path().join(format!("2026010{n}-gui.txt")), "").unwrap();
}
prune(dir.path(), 2).unwrap();
assert_eq!(list(dir.path()).unwrap(), ["20260104-gui", "20260103-gui"]);
}
#[test]
fn a_noted_action_keeps_its_variant_name_only() {
#[derive(Debug)]
#[allow(dead_code)]
enum Action {
Move(String),
}
note(&Action::Move("jane@example.org".into()));
assert_eq!(*LAST_ACTION.lock().unwrap(), "Move");
}
}