use serde::{Deserialize, Serialize};
use super::actions::ActionRing;
use super::rescue::RescueOutcome;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CrashReport {
pub version: String,
pub generated_at: String,
pub panic: PanicContext,
pub project: ProjectContext,
pub rescued_buffers: Vec<RescueOutcome>,
pub recent_actions: ActionRing,
pub environment: Environment,
pub process: ProcessContext,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PanicContext {
pub message: String,
pub location: Option<String>,
pub thread: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProjectContext {
pub path: Option<String>,
pub open_book: Option<String>,
pub open_paragraph: Option<String>,
pub open_paragraph_rel_path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Environment {
pub os_family: String,
pub os_arch: String,
pub term: Option<String>,
pub lang: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ProcessContext {
pub pid: u32,
pub started_at: String,
}
impl CrashReport {
pub fn capture(
info: &std::panic::PanicHookInfo<'_>,
state: &super::CrashState,
rescue_outcomes: &[RescueOutcome],
) -> Self {
let message = panic_message(info);
let location = info
.location()
.map(|loc| format!("{}:{}:{}", loc.file(), loc.line(), loc.column()));
let thread = std::thread::current()
.name()
.unwrap_or("<unnamed>")
.to_string();
let panic = PanicContext {
message,
location,
thread,
};
let project = ProjectContext {
path: state.project_path.as_ref().map(|p| p.display().to_string()),
open_book: state.open_book.clone(),
open_paragraph: state.open_paragraph.clone(),
open_paragraph_rel_path: state.open_paragraph_rel_path.clone(),
};
let environment = Environment {
os_family: std::env::consts::FAMILY.to_string(),
os_arch: format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH),
term: std::env::var("TERM").ok(),
lang: std::env::var("LANG").ok(),
};
let process = ProcessContext {
pid: std::process::id(),
started_at: chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
};
Self {
version: env!("CARGO_PKG_VERSION").to_string(),
generated_at: chrono::Utc::now()
.format("%Y-%m-%dT%H:%M:%SZ")
.to_string(),
panic,
project,
rescued_buffers: rescue_outcomes.to_vec(),
recent_actions: state.actions.clone(),
environment,
process,
}
}
pub fn write_atomic(&self, target: &std::path::Path) -> std::io::Result<()> {
let body = serde_hjson::to_string(self).map_err(|e| {
std::io::Error::other(format!("serialise CrashReport as HJSON: {e}"))
})?;
super::write_atomic(target, body.as_bytes())
}
}
fn panic_message(info: &std::panic::PanicHookInfo<'_>) -> String {
let payload = info.payload();
let raw = if let Some(s) = payload.downcast_ref::<&'static str>() {
s.to_string()
} else if let Some(s) = payload.downcast_ref::<String>() {
s.clone()
} else {
format!("non-string panic payload: {info}")
};
if raw.len() > 4096 {
let mut truncated = raw.chars().take(4000).collect::<String>();
truncated.push_str("…[truncated]");
truncated
} else {
raw
}
}
#[cfg(test)]
mod tests {
use super::*;
use super::super::{ActionRecord, CrashState};
#[test]
fn report_roundtrips_through_hjson() {
let state = CrashState {
project_path: Some(std::path::PathBuf::from("/proj")),
open_book: Some("manuscript".into()),
open_paragraph: Some("ch1/opening".into()),
open_paragraph_rel_path: Some("manuscript/ch1/opening.typ".into()),
actions: {
let mut r = super::super::actions::ActionRing::default();
r.push(ActionRecord::new("view.add_comment"));
r.push(ActionRecord::with_detail("ai.continuation_draft", "anchors=3"));
r
},
dirty_buffers: Default::default(),
};
let report = CrashReport {
version: "1.2.15".into(),
generated_at: "2026-05-31T14:23:00Z".into(),
panic: PanicContext {
message: "called Option::unwrap() on None".into(),
location: Some("src/foo.rs:42:7".into()),
thread: "main".into(),
},
project: ProjectContext {
path: state.project_path.as_ref().map(|p| p.display().to_string()),
open_book: state.open_book.clone(),
open_paragraph: state.open_paragraph.clone(),
open_paragraph_rel_path: state.open_paragraph_rel_path.clone(),
},
rescued_buffers: vec![],
recent_actions: state.actions.clone(),
environment: Environment::default(),
process: ProcessContext::default(),
};
let body = serde_hjson::to_string(&report).expect("serialize");
let parsed: CrashReport = serde_hjson::from_str(&body).expect("parse");
assert_eq!(parsed.version, "1.2.15");
assert_eq!(parsed.panic.message, "called Option::unwrap() on None");
assert_eq!(parsed.project.open_book.as_deref(), Some("manuscript"));
assert_eq!(parsed.recent_actions.entries.len(), 2);
assert_eq!(
parsed.recent_actions.entries[1].action,
"ai.continuation_draft"
);
}
#[test]
fn atomic_write_creates_target_file() {
let dir = std::env::temp_dir().join(format!(
"inkhaven-crash-report-test-{}",
std::process::id()
));
std::fs::create_dir_all(&dir).unwrap();
let target = dir.join("report.hjson");
let report = CrashReport {
version: "1.2.15".into(),
generated_at: "2026-05-31T14:23:00Z".into(),
panic: PanicContext::default(),
project: ProjectContext::default(),
rescued_buffers: vec![],
recent_actions: super::super::actions::ActionRing::default(),
environment: Environment::default(),
process: ProcessContext::default(),
};
report.write_atomic(&target).expect("write succeeds");
assert!(target.exists());
let body = std::fs::read_to_string(&target).unwrap();
assert!(
body.contains("1.2.15"),
"body should contain version string: {body}",
);
let _ = std::fs::remove_dir_all(&dir);
}
}