#![allow(dead_code)] // Phase 1: consumed by LoreApp in bd-6pmy //! Ring buffer of recent app events for post-mortem crash diagnostics. //! //! The TUI pushes every key press, message dispatch, and state transition //! into [`CrashContext`]. On panic the installed hook dumps the last 2000 //! events to `~/.local/share/lore/crash-.json` as NDJSON. //! //! Retention: only the 5 most recent crash files are kept. use std::collections::VecDeque; use std::io::{self, BufWriter, Write}; use std::path::{Path, PathBuf}; use serde::Serialize; /// Maximum number of events retained in the ring buffer. const MAX_EVENTS: usize = 2000; /// Maximum number of crash files to keep on disk. const MAX_CRASH_FILES: usize = 5; // --------------------------------------------------------------------------- // CrashEvent // --------------------------------------------------------------------------- /// A single event recorded for crash diagnostics. #[derive(Debug, Clone, Serialize)] #[serde(tag = "type")] pub enum CrashEvent { /// A key was pressed. KeyPress { key: String, mode: String, screen: String, }, /// A message was dispatched through update(). MsgDispatched { msg_name: String, screen: String }, /// Navigation changed screens. StateTransition { from: String, to: String }, /// An error occurred. Error { message: String }, /// Catch-all for ad-hoc diagnostic breadcrumbs. Custom { tag: String, detail: String }, } // --------------------------------------------------------------------------- // CrashContext // --------------------------------------------------------------------------- /// Ring buffer of recent app events for panic diagnostics. /// /// Holds at most [`MAX_EVENTS`] entries. When full, the oldest event /// is evicted on each push. pub struct CrashContext { events: VecDeque, } impl CrashContext { /// Create an empty crash context with pre-allocated capacity. #[must_use] pub fn new() -> Self { Self { events: VecDeque::with_capacity(MAX_EVENTS), } } /// Record an event. Evicts the oldest when the buffer is full. pub fn push(&mut self, event: CrashEvent) { if self.events.len() == MAX_EVENTS { self.events.pop_front(); } self.events.push_back(event); } /// Number of events currently stored. #[must_use] pub fn len(&self) -> usize { self.events.len() } /// Whether the buffer is empty. #[must_use] pub fn is_empty(&self) -> bool { self.events.is_empty() } /// Iterate over stored events (oldest first). pub fn iter(&self) -> impl Iterator { self.events.iter() } /// Dump all events to a file as newline-delimited JSON. /// /// Creates parent directories if they don't exist. /// Returns `Ok(())` on success, `Err` on I/O failure. pub fn dump_to_file(&self, path: &Path) -> io::Result<()> { if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let file = std::fs::File::create(path)?; let mut writer = BufWriter::new(file); for event in &self.events { match serde_json::to_string(event) { Ok(json) => { writeln!(writer, "{json}")?; } Err(_) => { // Fallback to debug format if serialization fails. writeln!( writer, "{{\"type\":\"SerializationError\",\"debug\":\"{event:?}\"}}" )?; } } } writer.flush()?; Ok(()) } /// Default crash directory: `~/.local/share/lore/`. #[must_use] pub fn crash_dir() -> Option { dirs::data_local_dir().map(|d| d.join("lore")) } /// Generate a timestamped crash file path. #[must_use] pub fn crash_file_path() -> Option { let dir = Self::crash_dir()?; let timestamp = chrono::Utc::now().format("%Y%m%d-%H%M%S%.3f"); Some(dir.join(format!("crash-{timestamp}.json"))) } /// Remove old crash files, keeping only the most recent [`MAX_CRASH_FILES`]. /// /// Best-effort: silently ignores I/O errors on individual deletions. pub fn prune_crash_files() { let Some(dir) = Self::crash_dir() else { return; }; let Ok(entries) = std::fs::read_dir(&dir) else { return; }; let mut crash_files: Vec = entries .filter_map(Result::ok) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json")) }) .collect(); // Sort ascending by filename (timestamps sort lexicographically). crash_files.sort(); if crash_files.len() > MAX_CRASH_FILES { let to_remove = crash_files.len() - MAX_CRASH_FILES; for path in &crash_files[..to_remove] { let _ = std::fs::remove_file(path); } } } /// Install a panic hook that dumps the crash context to disk. /// /// Captures the current events via a snapshot. The hook chains with /// the default panic handler so backtraces are still printed. /// /// FIXME: This snapshots events at install time, which is typically /// during init() when the buffer is empty. The crash dump will only /// contain the panic itself, not the preceding key presses and state /// transitions. Fix requires CrashContext to use interior mutability /// (Arc>>) so the panic hook reads live /// state instead of a stale snapshot. pub fn install_panic_hook(ctx: &Self) { let snapshot: Vec = ctx.events.iter().cloned().collect(); let prev_hook = std::panic::take_hook(); std::panic::set_hook(Box::new(move |info| { // Best-effort dump — never panic inside the panic hook. if let Some(path) = Self::crash_file_path() { let mut dump = CrashContext::new(); for event in &snapshot { dump.push(event.clone()); } // Add the panic info itself as the final event. dump.push(CrashEvent::Error { message: format!("{info}"), }); let _ = dump.dump_to_file(&path); } // Chain to the previous hook (prints backtrace, etc.). prev_hook(info); })); } } impl Default for CrashContext { fn default() -> Self { Self::new() } } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[cfg(test)] mod tests { use super::*; use std::io::BufRead; /// Helper: create a numbered Custom event. fn event(n: usize) -> CrashEvent { CrashEvent::Custom { tag: "test".into(), detail: format!("event-{n}"), } } #[test] fn test_ring_buffer_evicts_oldest() { let mut ctx = CrashContext::new(); for i in 0..2500 { ctx.push(event(i)); } assert_eq!(ctx.len(), MAX_EVENTS); // First retained event should be #500 (0..499 evicted). let first = ctx.iter().next().unwrap(); match first { CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-500"), other => panic!("unexpected variant: {other:?}"), } // Last retained event should be #2499. let last = ctx.iter().last().unwrap(); match last { CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-2499"), other => panic!("unexpected variant: {other:?}"), } } #[test] fn test_new_is_empty() { let ctx = CrashContext::new(); assert!(ctx.is_empty()); assert_eq!(ctx.len(), 0); } #[test] fn test_push_increments_len() { let mut ctx = CrashContext::new(); ctx.push(event(1)); ctx.push(event(2)); assert_eq!(ctx.len(), 2); } #[test] fn test_push_does_not_evict_below_capacity() { let mut ctx = CrashContext::new(); for i in 0..MAX_EVENTS { ctx.push(event(i)); } assert_eq!(ctx.len(), MAX_EVENTS); // First should still be event-0. match ctx.iter().next().unwrap() { CrashEvent::Custom { detail, .. } => assert_eq!(detail, "event-0"), other => panic!("unexpected: {other:?}"), } } #[test] fn test_dump_to_file_writes_ndjson() { let mut ctx = CrashContext::new(); ctx.push(CrashEvent::KeyPress { key: "j".into(), mode: "Normal".into(), screen: "Dashboard".into(), }); ctx.push(CrashEvent::MsgDispatched { msg_name: "NavigateTo".into(), screen: "Dashboard".into(), }); ctx.push(CrashEvent::StateTransition { from: "Dashboard".into(), to: "IssueList".into(), }); ctx.push(CrashEvent::Error { message: "db busy".into(), }); ctx.push(CrashEvent::Custom { tag: "test".into(), detail: "hello".into(), }); let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("test-crash.json"); ctx.dump_to_file(&path).unwrap(); // Verify: each line is valid JSON, total lines == 5. let file = std::fs::File::open(&path).unwrap(); let reader = io::BufReader::new(file); let lines: Vec = reader.lines().map(Result::unwrap).collect(); assert_eq!(lines.len(), 5); // Each line must parse as JSON. for line in &lines { let val: serde_json::Value = serde_json::from_str(line).unwrap(); assert!(val.get("type").is_some(), "missing 'type' field: {line}"); } // Spot check first line: KeyPress with correct fields. let first: serde_json::Value = serde_json::from_str(&lines[0]).unwrap(); assert_eq!(first["type"], "KeyPress"); assert_eq!(first["key"], "j"); assert_eq!(first["mode"], "Normal"); assert_eq!(first["screen"], "Dashboard"); } #[test] fn test_dump_creates_parent_directories() { let dir = tempfile::tempdir().unwrap(); let nested = dir.path().join("a").join("b").join("c").join("crash.json"); let mut ctx = CrashContext::new(); ctx.push(event(1)); ctx.dump_to_file(&nested).unwrap(); assert!(nested.exists()); } #[test] fn test_dump_empty_context_creates_empty_file() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("empty.json"); let ctx = CrashContext::new(); ctx.dump_to_file(&path).unwrap(); let content = std::fs::read_to_string(&path).unwrap(); assert!(content.is_empty()); } #[test] fn test_prune_keeps_newest_files() { let dir = tempfile::tempdir().unwrap(); let crash_dir = dir.path(); // Create 8 crash files with ordered timestamps. let filenames: Vec = (0..8) .map(|i| format!("crash-2026010{i}-120000.000.json")) .collect(); for name in &filenames { std::fs::write(crash_dir.join(name), "{}").unwrap(); } // Prune, pointing at our temp dir. prune_crash_files_in(crash_dir); let remaining: Vec = std::fs::read_dir(crash_dir) .unwrap() .filter_map(Result::ok) .map(|e| e.file_name().to_string_lossy().into_owned()) .filter(|n| n.starts_with("crash-") && n.ends_with(".json")) .collect(); assert_eq!(remaining.len(), MAX_CRASH_FILES); // Oldest 3 should be gone. for name in filenames.iter().take(3) { assert!(!remaining.contains(name)); } // Newest 5 should remain. for name in filenames.iter().skip(3) { assert!(remaining.contains(name)); } } #[test] fn test_all_event_variants_serialize() { let events = vec![ CrashEvent::KeyPress { key: "q".into(), mode: "Normal".into(), screen: "Dashboard".into(), }, CrashEvent::MsgDispatched { msg_name: "Quit".into(), screen: "Dashboard".into(), }, CrashEvent::StateTransition { from: "Dashboard".into(), to: "IssueList".into(), }, CrashEvent::Error { message: "oops".into(), }, CrashEvent::Custom { tag: "debug".into(), detail: "trace".into(), }, ]; for event in events { let json = serde_json::to_string(&event).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); assert!(parsed.get("type").is_some()); } } #[test] fn test_default_is_new() { let ctx = CrashContext::default(); assert!(ctx.is_empty()); } // ----------------------------------------------------------------------- // Test helper: prune files in a specific directory (not the real path). // ----------------------------------------------------------------------- fn prune_crash_files_in(dir: &Path) { let Ok(entries) = std::fs::read_dir(dir) else { return; }; let mut crash_files: Vec = entries .filter_map(Result::ok) .map(|e| e.path()) .filter(|p| { p.file_name() .and_then(|n| n.to_str()) .is_some_and(|n| n.starts_with("crash-") && n.ends_with(".json")) }) .collect(); crash_files.sort(); if crash_files.len() > MAX_CRASH_FILES { let to_remove = crash_files.len() - MAX_CRASH_FILES; for path in &crash_files[..to_remove] { let _ = std::fs::remove_file(path); } } } }