use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IpsReport {
pub path: PathBuf,
pub bundle: String,
pub process: String,
pub captured: String,
pub coalition: String,
pub exception: String,
pub termination: String,
pub frames: Vec<String>,
}
#[must_use]
pub fn reports_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| PathBuf::from(h).join("Library/Logs/DiagnosticReports"))
}
#[must_use]
pub fn read(path: &Path) -> Option<IpsReport> {
let text = std::fs::read_to_string(path).ok()?;
parse(path, &text)
}
fn parse(path: &Path, text: &str) -> Option<IpsReport> {
let (head, body) = text.split_once('\n')?;
let head: serde_json::Value = serde_json::from_str(head).ok()?;
let body: serde_json::Value = serde_json::from_str(body).ok()?;
let s = |v: &serde_json::Value| v.as_str().unwrap_or_default().to_string();
let thread = body["faultingThread"].as_u64().unwrap_or(0) as usize;
let frames = body["threads"][thread]["frames"]
.as_array()
.map(|fs| {
fs.iter()
.filter_map(|f| f["symbol"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let exception = match (
body["exception"]["type"].as_str(),
body["exception"]["signal"].as_str(),
) {
(Some(t), Some(sig)) => format!("{t} ({sig})"),
(Some(t), None) => t.to_string(),
(None, Some(sig)) => sig.to_string(),
(None, None) => String::new(),
};
let process = match body["procName"].as_str() {
Some(p) => p.to_string(),
None => s(&head["app_name"]),
};
Some(IpsReport {
path: path.to_path_buf(),
bundle: s(&head["bundleID"]),
process,
captured: s(&body["captureTime"]),
coalition: s(&body["coalitionName"]),
exception,
termination: s(&body["termination"]["indicator"]),
frames,
})
}
#[must_use]
pub fn coalition_of(udid: &str) -> String {
format!("com.apple.CoreSimulator.SimDevice.{udid}")
}
#[must_use]
pub fn for_simulator(dir: &Path, udid: &str) -> Vec<IpsReport> {
let coalition = coalition_of(udid);
let mut reports: Vec<IpsReport> = [dir.to_path_buf(), dir.join("Retired")]
.iter()
.filter_map(|d| std::fs::read_dir(d).ok())
.flatten()
.filter_map(Result::ok)
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|x| x == "ips"))
.filter_map(|p| read(&p))
.filter(|r| r.coalition == coalition)
.collect();
reports.sort_by(|a, b| a.captured.cmp(&b.captured));
reports
}
#[cfg(test)]
mod tests {
use super::*;
const UDID: &str = "A6E0D8D5-E9AA-43CD-BBFE-0F7CBC7F4150";
fn report(process: &str, udid: &str, captured: &str) -> String {
format!(
"{{\"app_name\":\"{process}\",\"bundleID\":\"com.apple.{process}\",\"bug_type\":\"309\"}}\n{}",
serde_json::json!({
"procName": process,
"captureTime": captured,
"coalitionName": coalition_of(udid),
"exception": {"type": "EXC_BREAKPOINT", "signal": "SIGTRAP"},
"termination": {"indicator": "Trace/BPT trap: 5"},
"faultingThread": 0,
"threads": [{"frames": [{"symbol": "-[PRPosterDescriptor _initWithPath:]"}]}],
})
)
}
fn write(dir: &Path, name: &str, text: &str) {
std::fs::create_dir_all(dir).expect("dir");
std::fs::write(dir.join(name), text).expect("write");
}
#[test]
fn a_report_says_which_simulator_and_what_happened() {
let r = parse(
Path::new("x.ips"),
&report("PosterBoard", UDID, "2026-10-14 06:42:45.8602 +0900"),
)
.expect("a report");
assert_eq!(r.process, "PosterBoard");
assert_eq!(
r.coalition,
format!("com.apple.CoreSimulator.SimDevice.{UDID}")
);
assert_eq!(r.exception, "EXC_BREAKPOINT (SIGTRAP)");
assert_eq!(r.termination, "Trace/BPT trap: 5");
assert_eq!(
r.frames,
vec!["-[PRPosterDescriptor _initWithPath:]".to_string()]
);
}
#[test]
fn only_this_simulators_reports_are_its_own_retired_ones_included() {
let dir = tempfile::tempdir().expect("tempdir");
let other = "49034EAE-EBE2-4A30-8D16-41CAC882058F";
write(
dir.path(),
"PosterBoard-2.ips",
&report("PosterBoard", UDID, "2026-10-14 06:24:31 +0900"),
);
write(
&dir.path().join("Retired"),
"PosterBoard-1.ips",
&report("PosterBoard", UDID, "2026-10-14 06:24:29 +0900"),
);
write(
dir.path(),
"Insight-1.ips",
&report("Insight", other, "2026-10-14 06:25:00 +0900"),
);
write(dir.path(), "notes.txt", "not a report");
write(dir.path(), "Broken-1.ips", "{not json");
let mine = for_simulator(dir.path(), UDID);
let when: Vec<_> = mine.iter().map(|r| r.captured.as_str()).collect();
assert_eq!(
when,
["2026-10-14 06:24:29 +0900", "2026-10-14 06:24:31 +0900"]
);
assert_eq!(for_simulator(dir.path(), other).len(), 1);
}
#[test]
fn a_report_without_a_body_is_not_one() {
assert!(parse(Path::new("x.ips"), "{\"app_name\":\"X\"}").is_none());
}
}