use std::collections::HashMap;
use std::collections::VecDeque;
use std::io::Write as _;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use crate::error_capture::types::CapturedError;
pub const DEFAULT_CAPTURE_CAPACITY: usize = 500;
const ERRORS_FILENAME: &str = "errors.jsonl";
#[derive(Clone)]
pub struct ErrorStore {
inner: Arc<Mutex<Inner>>,
capacity: usize,
}
struct Inner {
ring: VecDeque<CapturedError>,
file_path: Option<PathBuf>,
}
impl ErrorStore {
#[must_use]
pub fn open(app_name: &str, capacity: usize) -> Self {
let capacity = capacity.max(1);
let file_path = match crate::resolve_data_dir(app_name) {
Ok(dir) => Some(dir.join(ERRORS_FILENAME)),
Err(e) => {
eprintln!("[bug-capture] cannot resolve data dir for {app_name}: {e}");
None
}
};
let ring = if let Some(ref path) = file_path {
load_ring_from_disk(path, capacity)
} else {
VecDeque::with_capacity(capacity)
};
Self {
inner: Arc::new(Mutex::new(Inner { ring, file_path })),
capacity,
}
}
#[must_use]
pub fn with_path(file_path: Option<PathBuf>, capacity: usize) -> Self {
let capacity = capacity.max(1);
let ring = if let Some(ref path) = file_path {
load_ring_from_disk(path, capacity)
} else {
VecDeque::with_capacity(capacity)
};
Self {
inner: Arc::new(Mutex::new(Inner { ring, file_path })),
capacity,
}
}
pub fn append(&self, record: CapturedError) {
let mut guard = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
if let Some(ref path) = guard.file_path {
match serialise_and_append(path, &record) {
Ok(()) => {}
Err(e) => {
eprintln!("[bug-capture] write to {}: {e}", path.display());
}
}
}
guard.ring.push_back(record);
while guard.ring.len() > self.capacity {
guard.ring.pop_front();
}
}
#[must_use]
pub fn recent_errors(&self, n: usize) -> Vec<CapturedError> {
let guard = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let skip = guard.ring.len().saturating_sub(n);
guard.ring.iter().skip(skip).cloned().collect()
}
#[must_use]
pub fn errors_by_fingerprint(&self) -> Vec<(CapturedError, usize)> {
let guard = match self.inner.lock() {
Ok(g) => g,
Err(poisoned) => poisoned.into_inner(),
};
let mut counts: HashMap<String, usize> = HashMap::new();
let mut latest: HashMap<String, CapturedError> = HashMap::new();
for rec in &guard.ring {
*counts.entry(rec.fingerprint.clone()).or_insert(0) += 1;
latest.insert(rec.fingerprint.clone(), rec.clone());
}
let mut result: Vec<(CapturedError, usize)> = latest
.into_iter()
.map(|(fp, rec)| (rec, *counts.get(&fp).unwrap_or(&1)))
.collect();
result.sort_by_key(|item| std::cmp::Reverse(item.1));
result
}
#[must_use]
pub fn len(&self) -> usize {
match self.inner.lock() {
Ok(g) => g.ring.len(),
Err(p) => p.into_inner().ring.len(),
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
#[must_use]
pub fn read_records(path: &std::path::Path, limit: usize) -> Vec<CapturedError> {
load_ring_from_disk(&path.to_path_buf(), limit)
.into_iter()
.collect()
}
}
fn load_ring_from_disk(path: &PathBuf, capacity: usize) -> VecDeque<CapturedError> {
let content = match std::fs::read_to_string(path) {
Ok(s) => s,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
return VecDeque::with_capacity(capacity);
}
Err(e) => {
eprintln!("[bug-capture] cannot read {}: {e}", path.display());
return VecDeque::with_capacity(capacity);
}
};
let mut ring: VecDeque<CapturedError> = VecDeque::with_capacity(capacity);
for line in content.lines() {
let line = line.trim();
if line.is_empty() {
continue;
}
match serde_json::from_str::<CapturedError>(line) {
Ok(rec) => {
ring.push_back(rec);
while ring.len() > capacity {
ring.pop_front();
}
}
Err(_) => {
eprintln!(
"[bug-capture] skipping corrupt record in {}",
path.display()
);
}
}
}
ring
}
fn serialise_and_append(path: &PathBuf, record: &CapturedError) -> std::io::Result<()> {
let json = serde_json::to_vec(record)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
let mut file = std::fs::OpenOptions::new()
.append(true)
.create(true)
.open(path)?;
file.write_all(&json)?;
file.write_all(b"\n")?;
file.flush()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::error_capture::types::CapturedError;
fn make_record(msg: &str, fp: &str) -> CapturedError {
CapturedError {
timestamp_secs: 1_000_000,
crate_target: "test_crate".to_string(),
crate_version: "0.1.0".to_string(),
message: msg.to_string(),
fields: String::new(),
file: Some("src/lib.rs".to_string()),
line: Some(10),
os: "linux".to_string(),
arch: "x86_64".to_string(),
fingerprint: fp.to_string(),
}
}
#[test]
fn store_ring_bounded() {
let store = ErrorStore::with_path(None, 3);
assert!(store.is_empty());
for i in 0..5u32 {
store.append(make_record(&format!("err {i}"), &format!("fp{i}")));
}
assert_eq!(store.len(), 3);
let recent = store.recent_errors(10);
assert_eq!(recent.len(), 3);
assert_eq!(recent[0].message, "err 2");
assert_eq!(recent[2].message, "err 4");
}
#[test]
fn store_round_trip_write_read() {
let tmp_dir = {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("bugcap-test-{pid}-{nanos}"))
};
std::fs::create_dir_all(&tmp_dir).unwrap();
let file_path = tmp_dir.join(ERRORS_FILENAME);
{
let store = ErrorStore::with_path(Some(file_path.clone()), 10);
store.append(make_record("first error", "fp1"));
store.append(make_record("second error", "fp2"));
assert_eq!(store.len(), 2);
}
let store2 = ErrorStore::with_path(Some(file_path), 10);
let records = store2.recent_errors(10);
assert_eq!(records.len(), 2, "expected 2 records after reload");
assert_eq!(records[0].message, "first error");
assert_eq!(records[1].message, "second error");
}
#[test]
fn store_handles_missing_file_gracefully() {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
let nonexistent = std::env::temp_dir().join(format!("bugcap-missing-{pid}-{nanos}.jsonl"));
let store = ErrorStore::with_path(Some(nonexistent), 10);
assert!(store.is_empty());
store.append(make_record("hello", "fp1"));
assert_eq!(store.len(), 1);
}
#[test]
fn store_corrupt_line_skipped() {
let tmp_dir = {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("bugcap-corrupt-{pid}-{nanos}"))
};
std::fs::create_dir_all(&tmp_dir).unwrap();
let file_path = tmp_dir.join(ERRORS_FILENAME);
{
let valid = serde_json::to_string(&make_record("valid first", "fp1")).unwrap();
let valid2 = serde_json::to_string(&make_record("valid second", "fp2")).unwrap();
let content = format!("{valid}\nnot-json-at-all\n{valid2}\n");
std::fs::write(&file_path, content).unwrap();
}
let store = ErrorStore::with_path(Some(file_path), 10);
assert_eq!(store.len(), 2, "corrupt line should be skipped");
let records = store.recent_errors(10);
assert_eq!(records[0].message, "valid first");
assert_eq!(records[1].message, "valid second");
}
#[test]
fn store_errors_by_fingerprint() {
let store = ErrorStore::with_path(None, 20);
store.append(make_record("err a", "fp1"));
store.append(make_record("err b", "fp2"));
store.append(make_record("err c", "fp1"));
let by_fp = store.errors_by_fingerprint();
assert_eq!(by_fp.len(), 2, "expected 2 unique fingerprints");
assert_eq!(by_fp[0].1, 2);
assert_eq!(by_fp[0].0.fingerprint, "fp1");
assert_eq!(by_fp[0].0.message, "err c");
assert_eq!(by_fp[1].1, 1);
}
#[test]
fn read_records_loads_file() {
let tmp_dir = {
let pid = std::process::id();
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("bugcap-readrec-{pid}-{nanos}"))
};
std::fs::create_dir_all(&tmp_dir).unwrap();
let file_path = tmp_dir.join(ERRORS_FILENAME);
let store = ErrorStore::with_path(Some(file_path.clone()), 10);
store.append(make_record("alpha error", "fp-a"));
store.append(make_record("beta error", "fp-b"));
let records = ErrorStore::read_records(&file_path, 10);
assert_eq!(records.len(), 2, "expected 2 records");
assert_eq!(records[0].message, "alpha error");
assert_eq!(records[1].message, "beta error");
}
#[test]
fn read_records_missing_file_is_empty() {
let nonexistent = std::env::temp_dir().join("bugcap-no-such-file-x99.jsonl");
let records = ErrorStore::read_records(&nonexistent, 50);
assert!(records.is_empty(), "missing file must yield empty vec");
}
}