1use crate::commands::crash_pings::format_frame_location;
6use crate::models::crash_pings::{CrashPingStackSummary, CrashPingsSummary};
7use crate::models::{CorrelationsSummary, CrashSummary, SearchResponse, StackFrame};
8
9fn format_function(frame: &StackFrame) -> String {
10 if let Some(func) = &frame.function {
11 func.clone()
12 } else {
13 let mut parts = Vec::new();
14 if let Some(offset) = &frame.offset {
15 parts.push(offset.clone());
16 }
17 if let Some(module) = &frame.module {
18 parts.push(format!("({})", module));
19 }
20 if parts.is_empty() {
21 "???".to_string()
22 } else {
23 parts.join(" ")
24 }
25 }
26}
27
28pub fn format_crash(summary: &CrashSummary) -> String {
29 let mut output = String::new();
30
31 output.push_str(&format!("CRASH {}\n", summary.crash_id));
32 output.push_str(&format!("sig: {}\n", summary.signature));
33
34 if let Some(reason) = &summary.reason {
35 let addr_str = summary.address.as_deref().unwrap_or("");
36 let addr_desc = if addr_str == "0x0" || addr_str == "0" {
37 " (null ptr)"
38 } else {
39 ""
40 };
41
42 if !addr_str.is_empty() {
43 output.push_str(&format!("reason: {} @ {}{}\n", reason, addr_str, addr_desc));
44 } else {
45 output.push_str(&format!("reason: {}\n", reason));
46 }
47 }
48
49 if let Some(moz_reason) = &summary.moz_crash_reason {
50 output.push_str(&format!("moz_reason: {}\n", moz_reason));
51 }
52
53 if let Some(abort) = &summary.abort_message {
54 output.push_str(&format!("abort: {}\n", abort));
55 }
56
57 let device_info = match (&summary.android_model, &summary.android_version) {
58 (Some(model), Some(version)) => format!(", {} {}", model, version),
59 (Some(model), None) => format!(", {}", model),
60 _ => String::new(),
61 };
62
63 output.push_str(&format!(
64 "product: {} {} ({}{})\n",
65 summary.product, summary.version, summary.platform, device_info
66 ));
67
68 if let Some(build_id) = &summary.build_id {
69 output.push_str(&format!("build: {}\n", build_id));
70 }
71
72 if let Some(channel) = &summary.release_channel {
73 output.push_str(&format!("channel: {}\n", channel));
74 }
75
76 if !summary.all_threads.is_empty() {
77 output.push('\n');
78 for thread in &summary.all_threads {
79 let thread_name = thread.thread_name.as_deref().unwrap_or("unknown");
80 let crash_marker = if thread.is_crashing {
81 " [CRASHING]"
82 } else {
83 ""
84 };
85 output.push_str(&format!(
86 "stack[thread {}:{}{}]:\n",
87 thread.thread_index, thread_name, crash_marker
88 ));
89
90 for frame in &thread.frames {
91 let func = format_function(frame);
92 let location = match (&frame.file, frame.line) {
93 (Some(file), Some(line)) => format!(" @ {}:{}", file, line),
94 (Some(file), None) => format!(" @ {}", file),
95 _ => String::new(),
96 };
97 output.push_str(&format!(" #{} {}{}\n", frame.frame, func, location));
98 }
99 output.push('\n');
100 }
101 } else if !summary.frames.is_empty() {
102 output.push('\n');
103 let thread_name = summary.crashing_thread_name.as_deref().unwrap_or("unknown");
104 output.push_str(&format!("stack[{}]:\n", thread_name));
105
106 for frame in &summary.frames {
107 let func = format_function(frame);
108 let location = match (&frame.file, frame.line) {
109 (Some(file), Some(line)) => format!(" @ {}:{}", file, line),
110 (Some(file), None) => format!(" @ {}", file),
111 _ => String::new(),
112 };
113 output.push_str(&format!(" #{} {}{}\n", frame.frame, func, location));
114 }
115 }
116
117 output
118}
119
120#[cfg(test)]
121mod tests {
122 use super::*;
123 use crate::models::{CrashHit, CrashSummary, FacetBucket, ThreadSummary};
124 use std::collections::HashMap;
125
126 fn sample_crash_summary() -> CrashSummary {
127 CrashSummary {
128 crash_id: "247653e8-7a18-4836-97d1-42a720260120".to_string(),
129 signature: "mozilla::AudioDecoderInputTrack::EnsureTimeStretcher".to_string(),
130 reason: Some("SIGSEGV".to_string()),
131 address: Some("0x0".to_string()),
132 moz_crash_reason: Some("MOZ_RELEASE_ASSERT(mTimeStretcher->Init())".to_string()),
133 abort_message: None,
134 product: "Fenix".to_string(),
135 version: "147.0.1".to_string(),
136 build_id: Some("20240115103000".to_string()),
137 release_channel: Some("release".to_string()),
138 platform: "Android 36".to_string(),
139 android_version: Some("36".to_string()),
140 android_model: Some("SM-S918B".to_string()),
141 crashing_thread_name: Some("GraphRunner".to_string()),
142 frames: vec![StackFrame {
143 frame: 0,
144 function: Some("EnsureTimeStretcher".to_string()),
145 file: Some("AudioDecoderInputTrack.cpp".to_string()),
146 line: Some(624),
147 module: None,
148 offset: None,
149 }],
150 all_threads: vec![],
151 }
152 }
153
154 #[test]
155 fn test_format_crash_header() {
156 let summary = sample_crash_summary();
157 let output = format_crash(&summary);
158
159 assert!(output.contains("CRASH 247653e8-7a18-4836-97d1-42a720260120"));
160 assert!(output.contains("sig: mozilla::AudioDecoderInputTrack::EnsureTimeStretcher"));
161 }
162
163 #[test]
164 fn test_format_crash_reason_with_null_ptr() {
165 let summary = sample_crash_summary();
166 let output = format_crash(&summary);
167
168 assert!(output.contains("reason: SIGSEGV @ 0x0 (null ptr)"));
169 }
170
171 #[test]
172 fn test_format_crash_moz_reason() {
173 let summary = sample_crash_summary();
174 let output = format_crash(&summary);
175
176 assert!(output.contains("moz_reason: MOZ_RELEASE_ASSERT(mTimeStretcher->Init())"));
177 }
178
179 #[test]
180 fn test_format_crash_product_with_device() {
181 let summary = sample_crash_summary();
182 let output = format_crash(&summary);
183
184 assert!(output.contains("product: Fenix 147.0.1 (Android 36, SM-S918B 36)"));
185 }
186
187 #[test]
188 fn test_format_crash_stack_trace() {
189 let summary = sample_crash_summary();
190 let output = format_crash(&summary);
191
192 assert!(output.contains("stack[GraphRunner]:"));
193 assert!(output.contains("#0 EnsureTimeStretcher @ AudioDecoderInputTrack.cpp:624"));
194 }
195
196 #[test]
197 fn test_format_crash_with_all_threads() {
198 let mut summary = sample_crash_summary();
199 summary.all_threads = vec![
200 ThreadSummary {
201 thread_index: 0,
202 thread_name: Some("MainThread".to_string()),
203 frames: vec![],
204 is_crashing: false,
205 },
206 ThreadSummary {
207 thread_index: 1,
208 thread_name: Some("GraphRunner".to_string()),
209 frames: vec![],
210 is_crashing: true,
211 },
212 ];
213 let output = format_crash(&summary);
214
215 assert!(output.contains("stack[thread 0:MainThread]:"));
216 assert!(output.contains("stack[thread 1:GraphRunner [CRASHING]]:"));
217 }
218
219 #[test]
220 fn test_format_search_basic() {
221 let response = SearchResponse {
222 total: 42,
223 hits: vec![CrashHit {
224 uuid: "247653e8-7a18-4836-97d1-42a720260120".to_string(),
225 date: "2024-01-15".to_string(),
226 signature: "mozilla::SomeFunction".to_string(),
227 product: "Firefox".to_string(),
228 version: "120.0".to_string(),
229 platform: Some("Windows".to_string()),
230 build_id: Some("20240115103000".to_string()),
231 release_channel: Some("release".to_string()),
232 platform_version: Some("10.0.19045".to_string()),
233 }],
234 facets: HashMap::new(),
235 };
236 let output = format_search(&response);
237
238 assert!(output.contains("FOUND 42 crashes"));
239 assert!(output.contains("247653e8"));
240 assert!(output.contains("Firefox 120.0"));
241 assert!(output.contains("Windows 10.0.19045"));
242 assert!(output.contains("mozilla::SomeFunction"));
243 }
244
245 #[test]
246 fn test_format_search_with_facets() {
247 let mut facets = HashMap::new();
248 facets.insert(
249 "version".to_string(),
250 vec![
251 FacetBucket {
252 term: "120.0".to_string(),
253 count: 50,
254 },
255 FacetBucket {
256 term: "119.0".to_string(),
257 count: 30,
258 },
259 ],
260 );
261 let response = SearchResponse {
262 total: 80,
263 hits: vec![],
264 facets,
265 };
266 let output = format_search(&response);
267
268 assert!(output.contains("AGGREGATIONS:"));
269 assert!(output.contains("version:"));
270 assert!(output.contains("120.0 (50)"));
271 assert!(output.contains("119.0 (30)"));
272 }
273
274 #[test]
275 fn test_format_function_with_function_name() {
276 let frame = StackFrame {
277 frame: 0,
278 function: Some("my_function".to_string()),
279 file: None,
280 line: None,
281 module: None,
282 offset: None,
283 };
284 assert_eq!(format_function(&frame), "my_function");
285 }
286
287 #[test]
288 fn test_format_function_without_function_name() {
289 let frame = StackFrame {
290 frame: 0,
291 function: None,
292 file: None,
293 line: None,
294 module: Some("libfoo.so".to_string()),
295 offset: Some("0x1234".to_string()),
296 };
297 assert_eq!(format_function(&frame), "0x1234 (libfoo.so)");
298 }
299
300 #[test]
301 fn test_format_function_unknown() {
302 let frame = StackFrame {
303 frame: 0,
304 function: None,
305 file: None,
306 line: None,
307 module: None,
308 offset: None,
309 };
310 assert_eq!(format_function(&frame), "???");
311 }
312
313 use crate::models::{CorrelationItem, CorrelationItemPrior, CorrelationsSummary};
314
315 fn sample_correlations_summary() -> CorrelationsSummary {
316 CorrelationsSummary {
317 signature: "TestSig".to_string(),
318 channel: "release".to_string(),
319 date: "2026-02-13".to_string(),
320 sig_count: 220.0,
321 ref_count: 79268,
322 items: vec![
323 CorrelationItem {
324 label: "Module \"cscapi.dll\" = true".to_string(),
325 sig_pct: 100.0,
326 ref_pct: 24.51,
327 prior: None,
328 },
329 CorrelationItem {
330 label: "startup_crash = null".to_string(),
331 sig_pct: 29.55,
332 ref_pct: 1.16,
333 prior: Some(CorrelationItemPrior {
334 label: "process_type = parent".to_string(),
335 sig_pct: 50.91,
336 ref_pct: 4.58,
337 }),
338 },
339 ],
340 }
341 }
342
343 #[test]
344 fn test_format_correlations_header() {
345 let summary = sample_correlations_summary();
346 let output = format_correlations(&summary);
347 assert!(output.contains("CORRELATIONS for \"TestSig\" (release, data from 2026-02-13)"));
348 assert!(output.contains("sig_count: 220, ref_count: 79268"));
349 }
350
351 #[test]
352 fn test_format_correlations_items() {
353 let summary = sample_correlations_summary();
354 let output = format_correlations(&summary);
355 assert!(output.contains("(100.00% vs 24.51% overall) Module \"cscapi.dll\" = true"));
356 }
357
358 #[test]
359 fn test_format_correlations_with_prior() {
360 let summary = sample_correlations_summary();
361 let output = format_correlations(&summary);
362 assert!(output.contains("(029.55% vs 01.16% overall) startup_crash = null [50.91% vs 04.58% if process_type = parent]"));
363 }
364
365 #[test]
366 fn test_format_correlations_empty() {
367 let summary = CorrelationsSummary {
368 signature: "EmptySig".to_string(),
369 channel: "release".to_string(),
370 date: "2026-02-13".to_string(),
371 sig_count: 0.0,
372 ref_count: 79268,
373 items: vec![],
374 };
375 let output = format_correlations(&summary);
376 assert!(output.contains("No correlations found."));
377 }
378}
379
380pub fn format_correlations(summary: &CorrelationsSummary) -> String {
381 let mut output = String::new();
382
383 output.push_str(&format!(
384 "CORRELATIONS for \"{}\" ({}, data from {})\n",
385 summary.signature, summary.channel, summary.date
386 ));
387 output.push_str(&format!(
388 "sig_count: {}, ref_count: {}\n\n",
389 summary.sig_count as u64, summary.ref_count
390 ));
391
392 if summary.items.is_empty() {
393 output.push_str("No correlations found.\n");
394 } else {
395 for item in &summary.items {
396 let prior_str = if let Some(prior) = &item.prior {
397 format!(
398 " [{:05.2}% vs {:05.2}% if {}]",
399 prior.sig_pct, prior.ref_pct, prior.label
400 )
401 } else {
402 String::new()
403 };
404 output.push_str(&format!(
405 "({:06.2}% vs {:05.2}% overall) {}{}\n",
406 item.sig_pct, item.ref_pct, item.label, prior_str
407 ));
408 }
409 }
410
411 output
412}
413
414pub fn format_crash_pings(summary: &CrashPingsSummary) -> String {
415 let mut output = String::new();
416
417 let date_str = if summary.date_from == summary.date_to {
418 summary.date_from.clone()
419 } else {
420 format!("{}..{}", summary.date_from, summary.date_to)
421 };
422 let filter_str = if let Some(ref sig) = summary.signature_filter {
423 format!(": \"{}\" ({} pings)", sig, summary.filtered_total)
424 } else {
425 format!(" ({} pings, sampled)", summary.total)
426 };
427 output.push_str(&format!("CRASH PINGS {}{}\n\n", date_str, filter_str));
428
429 if summary.facet_name != "signature" || summary.signature_filter.is_some() {
430 output.push_str(&format!("{}:\n", summary.facet_name));
431 }
432
433 if summary.items.is_empty() {
434 output.push_str(" (no matching pings)\n");
435 } else {
436 for item in &summary.items {
437 output.push_str(&format!(
438 " {} ({}, {:.2}%)\n",
439 item.label, item.count, item.percentage
440 ));
441 }
442 }
443
444 output
445}
446
447pub fn format_crash_ping_stack(summary: &CrashPingStackSummary) -> String {
448 let mut output = String::new();
449
450 output.push_str(&format!(
451 "CRASH PING {} ({})\n",
452 summary.crash_id, summary.date
453 ));
454
455 if summary.frames.is_empty() {
456 if summary.java_exception.is_some() {
457 output.push_str("\njava_exception:\n");
458 if let Some(ref exc) = summary.java_exception {
459 output.push_str(&format!(" {}\n", exc));
460 }
461 } else {
462 output.push_str("\nNo stack trace available.\n");
463 }
464 } else {
465 output.push_str("\nstack:\n");
466 for (i, frame) in summary.frames.iter().enumerate() {
467 output.push_str(&format!(" #{} {}\n", i, format_frame_location(frame)));
468 }
469 }
470
471 output
472}
473
474pub fn format_search(response: &SearchResponse) -> String {
475 let mut output = String::new();
476
477 output.push_str(&format!("FOUND {} crashes\n\n", response.total));
478
479 for hit in &response.hits {
480 let platform = match (&hit.platform, &hit.platform_version) {
481 (Some(p), Some(v)) => format!("{} {}", p, v),
482 (Some(p), None) => p.clone(),
483 (None, Some(v)) => v.clone(),
484 (None, None) => "?".to_string(),
485 };
486 let channel = hit.release_channel.as_deref().unwrap_or("?");
487 let build = hit.build_id.as_deref().unwrap_or("?");
488 output.push_str(&format!(
489 "{} | {} {} | {} | {} | {} | {}\n",
490 hit.uuid, hit.product, hit.version, platform, channel, build, hit.signature
491 ));
492 }
493
494 if !response.facets.is_empty() {
495 output.push_str("\nAGGREGATIONS:\n");
496 for (field, buckets) in &response.facets {
497 output.push_str(&format!("\n{}:\n", field));
498 for bucket in buckets {
499 output.push_str(&format!(" {} ({})\n", bucket.term, bucket.count));
500 }
501 }
502 }
503
504 output
505}