1use crate::commands::crash_pings::format_frame_location;
6use crate::models::bugs::BugsSummary;
7use crate::models::crash_pings::{CrashPingStackSummary, CrashPingsSummary};
8use crate::models::{CorrelationsSummary, CrashSummary, ModulesMode, SearchResponse, StackFrame};
9use std::collections::HashSet;
10
11fn format_function(frame: &StackFrame) -> String {
12 if let Some(func) = &frame.function {
13 func.clone()
14 } else {
15 let mut parts = Vec::new();
16 if let Some(offset) = &frame.offset {
17 parts.push(offset.clone());
18 }
19 if let Some(module) = &frame.module {
20 parts.push(format!("({})", module));
21 }
22 if parts.is_empty() {
23 "???".to_string()
24 } else {
25 parts.join(" ")
26 }
27 }
28}
29
30pub fn format_crash(summary: &CrashSummary, modules_mode: ModulesMode) -> String {
31 let mut output = String::new();
32
33 output.push_str(&format!("CRASH {}\n", summary.crash_id));
34 output.push_str(&format!("sig: {}\n", summary.signature));
35
36 if let Some(reason) = &summary.reason {
37 let addr_str = summary.address.as_deref().unwrap_or("");
38 let addr_desc = if addr_str == "0x0" || addr_str == "0" {
39 " (null ptr)"
40 } else {
41 ""
42 };
43
44 if !addr_str.is_empty() {
45 output.push_str(&format!("reason: {} @ {}{}\n", reason, addr_str, addr_desc));
46 } else {
47 output.push_str(&format!("reason: {}\n", reason));
48 }
49 }
50
51 if let Some(moz_reason) = &summary.moz_crash_reason {
52 output.push_str(&format!("moz_reason: {}\n", moz_reason));
53 }
54
55 if let Some(abort) = &summary.abort_message {
56 output.push_str(&format!("abort: {}\n", abort));
57 }
58
59 let device_info = match (&summary.android_model, &summary.android_version) {
60 (Some(model), Some(version)) => format!(", {} {}", model, version),
61 (Some(model), None) => format!(", {}", model),
62 _ => String::new(),
63 };
64
65 output.push_str(&format!(
66 "product: {} {} ({}{})\n",
67 summary.product, summary.version, summary.platform, device_info
68 ));
69
70 if let Some(build_id) = &summary.build_id {
71 output.push_str(&format!("build: {}\n", build_id));
72 }
73
74 if let Some(channel) = &summary.release_channel {
75 output.push_str(&format!("channel: {}\n", channel));
76 }
77
78 if !summary.all_threads.is_empty() {
79 output.push('\n');
80 for thread in &summary.all_threads {
81 let thread_name = thread.thread_name.as_deref().unwrap_or("unknown");
82 let crash_marker = if thread.is_crashing {
83 " [CRASHING]"
84 } else {
85 ""
86 };
87 output.push_str(&format!(
88 "stack[thread {}:{}{}]:\n",
89 thread.thread_index, thread_name, crash_marker
90 ));
91
92 for frame in &thread.frames {
93 let func = format_function(frame);
94 let location = match (&frame.file, frame.line) {
95 (Some(file), Some(line)) => format!(" @ {}:{}", file, line),
96 (Some(file), None) => format!(" @ {}", file),
97 _ => String::new(),
98 };
99 output.push_str(&format!(" #{} {}{}\n", frame.frame, func, location));
100 }
101 output.push('\n');
102 }
103 } else if !summary.frames.is_empty() {
104 output.push('\n');
105 let thread_name = summary.crashing_thread_name.as_deref().unwrap_or("unknown");
106 output.push_str(&format!("stack[{}]:\n", thread_name));
107
108 for frame in &summary.frames {
109 let func = format_function(frame);
110 let location = match (&frame.file, frame.line) {
111 (Some(file), Some(line)) => format!(" @ {}:{}", file, line),
112 (Some(file), None) => format!(" @ {}", file),
113 _ => String::new(),
114 };
115 output.push_str(&format!(" #{} {}{}\n", frame.frame, func, location));
116 }
117 }
118
119 output.push_str(&format_modules(summary, modules_mode));
120
121 output
122}
123
124fn format_modules(summary: &CrashSummary, mode: ModulesMode) -> String {
125 if mode == ModulesMode::None || summary.modules.is_empty() {
126 return String::new();
127 }
128
129 let modules: Vec<_> = match mode {
130 ModulesMode::Stack => {
131 let mut module_names: HashSet<&str> = HashSet::new();
132 if !summary.all_threads.is_empty() {
133 for thread in &summary.all_threads {
134 for frame in &thread.frames {
135 if let Some(m) = &frame.module {
136 module_names.insert(m);
137 }
138 }
139 }
140 } else {
141 for frame in &summary.frames {
142 if let Some(m) = &frame.module {
143 module_names.insert(m);
144 }
145 }
146 }
147 summary
148 .modules
149 .iter()
150 .filter(|m| module_names.contains(m.filename.as_str()))
151 .collect()
152 }
153 ModulesMode::Full => summary.modules.iter().collect(),
154 ModulesMode::None => unreachable!(),
155 };
156
157 if modules.is_empty() {
158 return String::new();
159 }
160
161 let mut out = String::new();
162 out.push_str("\nmodules:\n");
163 for m in &modules {
164 let version = m.version.as_deref().unwrap_or("?");
165 let debug_file = m.debug_file.as_deref().unwrap_or("?");
166 let debug_id = m.debug_id.as_deref().unwrap_or("?");
167 let code_id = m.code_id.as_deref().unwrap_or("?");
168 out.push_str(&format!(
169 " {} {} | {} | {} | {}\n",
170 m.filename, version, debug_file, debug_id, code_id
171 ));
172 }
173 out
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::models::{
180 CrashHit, CrashSummary, FacetBucket, ModuleInfo, ModulesMode, ThreadSummary,
181 };
182 use std::collections::HashMap;
183
184 fn sample_crash_summary() -> CrashSummary {
185 CrashSummary {
186 crash_id: "247653e8-7a18-4836-97d1-42a720260120".to_string(),
187 signature: "mozilla::AudioDecoderInputTrack::EnsureTimeStretcher".to_string(),
188 reason: Some("SIGSEGV".to_string()),
189 address: Some("0x0".to_string()),
190 moz_crash_reason: Some("MOZ_RELEASE_ASSERT(mTimeStretcher->Init())".to_string()),
191 abort_message: None,
192 product: "Fenix".to_string(),
193 version: "147.0.1".to_string(),
194 build_id: Some("20240115103000".to_string()),
195 release_channel: Some("release".to_string()),
196 platform: "Android 36".to_string(),
197 android_version: Some("36".to_string()),
198 android_model: Some("SM-S918B".to_string()),
199 crashing_thread_name: Some("GraphRunner".to_string()),
200 frames: vec![StackFrame {
201 frame: 0,
202 function: Some("EnsureTimeStretcher".to_string()),
203 file: Some("AudioDecoderInputTrack.cpp".to_string()),
204 line: Some(624),
205 module: None,
206 offset: None,
207 }],
208 all_threads: vec![],
209 modules: vec![],
210 }
211 }
212
213 fn sample_crash_summary_with_modules() -> CrashSummary {
214 CrashSummary {
215 crash_id: "test-modules".to_string(),
216 signature: "TestSig".to_string(),
217 reason: None,
218 address: None,
219 moz_crash_reason: None,
220 abort_message: None,
221 product: "Firefox".to_string(),
222 version: "148.0".to_string(),
223 build_id: None,
224 release_channel: None,
225 platform: "Windows".to_string(),
226 android_version: None,
227 android_model: None,
228 crashing_thread_name: Some("main".to_string()),
229 frames: vec![
230 StackFrame {
231 frame: 0,
232 function: Some("func_a".to_string()),
233 file: None,
234 line: None,
235 module: Some("xul.dll".to_string()),
236 offset: None,
237 },
238 StackFrame {
239 frame: 1,
240 function: Some("func_b".to_string()),
241 file: None,
242 line: None,
243 module: Some("ntdll.dll".to_string()),
244 offset: None,
245 },
246 ],
247 all_threads: vec![],
248 modules: vec![
249 ModuleInfo {
250 filename: "xul.dll".to_string(),
251 debug_file: Some("xul.pdb".to_string()),
252 debug_id: Some("F51BCD2A".to_string()),
253 code_id: Some("69934c4b".to_string()),
254 version: Some("148.0.0.3".to_string()),
255 },
256 ModuleInfo {
257 filename: "ntdll.dll".to_string(),
258 debug_file: Some("ntdll.pdb".to_string()),
259 debug_id: Some("180BF1B9".to_string()),
260 code_id: Some("7ec9c15d".to_string()),
261 version: Some("6.2.19041.6456".to_string()),
262 },
263 ModuleInfo {
264 filename: "mozglue.dll".to_string(),
265 debug_file: Some("mozglue.pdb".to_string()),
266 debug_id: Some("AABBCCDD".to_string()),
267 code_id: Some("abc123".to_string()),
268 version: Some("148.0".to_string()),
269 },
270 ],
271 }
272 }
273
274 #[test]
275 fn test_format_crash_header() {
276 let summary = sample_crash_summary();
277 let output = format_crash(&summary, ModulesMode::None);
278
279 assert!(output.contains("CRASH 247653e8-7a18-4836-97d1-42a720260120"));
280 assert!(output.contains("sig: mozilla::AudioDecoderInputTrack::EnsureTimeStretcher"));
281 }
282
283 #[test]
284 fn test_format_crash_reason_with_null_ptr() {
285 let summary = sample_crash_summary();
286 let output = format_crash(&summary, ModulesMode::None);
287
288 assert!(output.contains("reason: SIGSEGV @ 0x0 (null ptr)"));
289 }
290
291 #[test]
292 fn test_format_crash_moz_reason() {
293 let summary = sample_crash_summary();
294 let output = format_crash(&summary, ModulesMode::None);
295
296 assert!(output.contains("moz_reason: MOZ_RELEASE_ASSERT(mTimeStretcher->Init())"));
297 }
298
299 #[test]
300 fn test_format_crash_product_with_device() {
301 let summary = sample_crash_summary();
302 let output = format_crash(&summary, ModulesMode::None);
303
304 assert!(output.contains("product: Fenix 147.0.1 (Android 36, SM-S918B 36)"));
305 }
306
307 #[test]
308 fn test_format_crash_stack_trace() {
309 let summary = sample_crash_summary();
310 let output = format_crash(&summary, ModulesMode::None);
311
312 assert!(output.contains("stack[GraphRunner]:"));
313 assert!(output.contains("#0 EnsureTimeStretcher @ AudioDecoderInputTrack.cpp:624"));
314 }
315
316 #[test]
317 fn test_format_crash_with_all_threads() {
318 let mut summary = sample_crash_summary();
319 summary.all_threads = vec![
320 ThreadSummary {
321 thread_index: 0,
322 thread_name: Some("MainThread".to_string()),
323 frames: vec![],
324 is_crashing: false,
325 },
326 ThreadSummary {
327 thread_index: 1,
328 thread_name: Some("GraphRunner".to_string()),
329 frames: vec![],
330 is_crashing: true,
331 },
332 ];
333 let output = format_crash(&summary, ModulesMode::None);
334
335 assert!(output.contains("stack[thread 0:MainThread]:"));
336 assert!(output.contains("stack[thread 1:GraphRunner [CRASHING]]:"));
337 }
338
339 #[test]
340 fn test_format_crash_modules_none() {
341 let summary = sample_crash_summary_with_modules();
342 let output = format_crash(&summary, ModulesMode::None);
343
344 assert!(!output.contains("modules:"));
345 assert!(!output.contains("xul.dll"));
346 }
347
348 #[test]
349 fn test_format_crash_modules_stack() {
350 let summary = sample_crash_summary_with_modules();
351 let output = format_crash(&summary, ModulesMode::Stack);
352
353 assert!(output.contains("modules:"));
354 assert!(output.contains("xul.dll 148.0.0.3 | xul.pdb | F51BCD2A | 69934c4b"));
355 assert!(output.contains("ntdll.dll 6.2.19041.6456 | ntdll.pdb | 180BF1B9 | 7ec9c15d"));
356 assert!(!output.contains("mozglue.dll"));
358 }
359
360 #[test]
361 fn test_format_crash_modules_full() {
362 let summary = sample_crash_summary_with_modules();
363 let output = format_crash(&summary, ModulesMode::Full);
364
365 assert!(output.contains("modules:"));
366 assert!(output.contains("xul.dll 148.0.0.3 | xul.pdb | F51BCD2A | 69934c4b"));
367 assert!(output.contains("ntdll.dll 6.2.19041.6456 | ntdll.pdb | 180BF1B9 | 7ec9c15d"));
368 assert!(output.contains("mozglue.dll 148.0 | mozglue.pdb | AABBCCDD | abc123"));
370 }
371
372 #[test]
373 fn test_format_crash_modules_stack_with_all_threads() {
374 let mut summary = sample_crash_summary_with_modules();
375 summary.frames = vec![];
376 summary.all_threads = vec![
377 ThreadSummary {
378 thread_index: 0,
379 thread_name: Some("Main".to_string()),
380 frames: vec![StackFrame {
381 frame: 0,
382 function: Some("main".to_string()),
383 file: None,
384 line: None,
385 module: Some("mozglue.dll".to_string()),
386 offset: None,
387 }],
388 is_crashing: false,
389 },
390 ThreadSummary {
391 thread_index: 1,
392 thread_name: Some("Worker".to_string()),
393 frames: vec![StackFrame {
394 frame: 0,
395 function: Some("work".to_string()),
396 file: None,
397 line: None,
398 module: Some("xul.dll".to_string()),
399 offset: None,
400 }],
401 is_crashing: true,
402 },
403 ];
404 let output = format_crash(&summary, ModulesMode::Stack);
405
406 assert!(output.contains("mozglue.dll"));
408 assert!(output.contains("xul.dll"));
409 assert!(!output.contains("ntdll.dll"));
411 }
412
413 #[test]
414 fn test_format_crash_modules_empty_modules_list() {
415 let summary = sample_crash_summary();
416 let output = format_crash(&summary, ModulesMode::Full);
417
418 assert!(!output.contains("modules:"));
420 }
421
422 #[test]
423 fn test_format_search_basic() {
424 let response = SearchResponse {
425 total: 42,
426 hits: vec![CrashHit {
427 uuid: "247653e8-7a18-4836-97d1-42a720260120".to_string(),
428 date: "2024-01-15".to_string(),
429 signature: "mozilla::SomeFunction".to_string(),
430 product: "Firefox".to_string(),
431 version: "120.0".to_string(),
432 platform: Some("Windows".to_string()),
433 build_id: Some("20240115103000".to_string()),
434 release_channel: Some("release".to_string()),
435 platform_version: Some("10.0.19045".to_string()),
436 }],
437 facets: HashMap::new(),
438 };
439 let output = format_search(&response);
440
441 assert!(output.contains("FOUND 42 crashes"));
442 assert!(output.contains("247653e8"));
443 assert!(output.contains("2024-01-15"));
444 assert!(output.contains("Firefox 120.0"));
445 assert!(output.contains("Windows 10.0.19045"));
446 assert!(output.contains("mozilla::SomeFunction"));
447 }
448
449 #[test]
450 fn test_format_search_with_facets() {
451 let mut facets = HashMap::new();
452 facets.insert(
453 "version".to_string(),
454 vec![
455 FacetBucket {
456 term: "120.0".to_string(),
457 count: 50,
458 },
459 FacetBucket {
460 term: "119.0".to_string(),
461 count: 30,
462 },
463 ],
464 );
465 let response = SearchResponse {
466 total: 80,
467 hits: vec![],
468 facets,
469 };
470 let output = format_search(&response);
471
472 assert!(output.contains("AGGREGATIONS:"));
473 assert!(output.contains("version:"));
474 assert!(output.contains("120.0 (50)"));
475 assert!(output.contains("119.0 (30)"));
476 }
477
478 #[test]
479 fn test_format_function_with_function_name() {
480 let frame = StackFrame {
481 frame: 0,
482 function: Some("my_function".to_string()),
483 file: None,
484 line: None,
485 module: None,
486 offset: None,
487 };
488 assert_eq!(format_function(&frame), "my_function");
489 }
490
491 #[test]
492 fn test_format_function_without_function_name() {
493 let frame = StackFrame {
494 frame: 0,
495 function: None,
496 file: None,
497 line: None,
498 module: Some("libfoo.so".to_string()),
499 offset: Some("0x1234".to_string()),
500 };
501 assert_eq!(format_function(&frame), "0x1234 (libfoo.so)");
502 }
503
504 #[test]
505 fn test_format_function_unknown() {
506 let frame = StackFrame {
507 frame: 0,
508 function: None,
509 file: None,
510 line: None,
511 module: None,
512 offset: None,
513 };
514 assert_eq!(format_function(&frame), "???");
515 }
516
517 use crate::models::bugs::{BugGroup, BugsSummary};
518 use crate::models::{CorrelationItem, CorrelationItemPrior, CorrelationsSummary};
519
520 #[test]
521 fn test_format_bugs_with_results() {
522 let summary = BugsSummary {
523 bugs: vec![
524 BugGroup {
525 bug_id: 888888,
526 signatures: vec!["OOM | small".to_string()],
527 },
528 BugGroup {
529 bug_id: 999999,
530 signatures: vec!["OOM | large".to_string(), "OOM | small".to_string()],
531 },
532 ],
533 };
534 let output = format_bugs(&summary);
535 assert!(output.contains("bug 888888\n"));
536 assert!(output.contains(" OOM | small\n"));
537 assert!(output.contains("bug 999999\n"));
538 assert!(output.contains(" OOM | large\n"));
539 }
540
541 #[test]
542 fn test_format_bugs_empty() {
543 let summary = BugsSummary { bugs: vec![] };
544 let output = format_bugs(&summary);
545 assert!(output.contains("No bugs found."));
546 }
547
548 fn sample_correlations_summary() -> CorrelationsSummary {
549 CorrelationsSummary {
550 signature: "TestSig".to_string(),
551 channel: "release".to_string(),
552 date: "2026-02-13".to_string(),
553 sig_count: 220.0,
554 ref_count: 79268,
555 items: vec![
556 CorrelationItem {
557 label: "Module \"cscapi.dll\" = true".to_string(),
558 sig_pct: 100.0,
559 ref_pct: 24.51,
560 prior: None,
561 },
562 CorrelationItem {
563 label: "startup_crash = null".to_string(),
564 sig_pct: 29.55,
565 ref_pct: 1.16,
566 prior: Some(CorrelationItemPrior {
567 label: "process_type = parent".to_string(),
568 sig_pct: 50.91,
569 ref_pct: 4.58,
570 }),
571 },
572 ],
573 }
574 }
575
576 #[test]
577 fn test_format_correlations_header() {
578 let summary = sample_correlations_summary();
579 let output = format_correlations(&summary);
580 assert!(output.contains("CORRELATIONS for \"TestSig\" (release, data from 2026-02-13)"));
581 assert!(output.contains("sig_count: 220, ref_count: 79268"));
582 }
583
584 #[test]
585 fn test_format_correlations_items() {
586 let summary = sample_correlations_summary();
587 let output = format_correlations(&summary);
588 assert!(output.contains("(100.00% vs 24.51% overall) Module \"cscapi.dll\" = true"));
589 }
590
591 #[test]
592 fn test_format_correlations_with_prior() {
593 let summary = sample_correlations_summary();
594 let output = format_correlations(&summary);
595 assert!(output.contains("(029.55% vs 01.16% overall) startup_crash = null [50.91% vs 04.58% if process_type = parent]"));
596 }
597
598 #[test]
599 fn test_format_correlations_empty() {
600 let summary = CorrelationsSummary {
601 signature: "EmptySig".to_string(),
602 channel: "release".to_string(),
603 date: "2026-02-13".to_string(),
604 sig_count: 0.0,
605 ref_count: 79268,
606 items: vec![],
607 };
608 let output = format_correlations(&summary);
609 assert!(output.contains("No correlations found."));
610 }
611}
612
613pub fn format_correlations(summary: &CorrelationsSummary) -> String {
614 let mut output = String::new();
615
616 output.push_str(&format!(
617 "CORRELATIONS for \"{}\" ({}, data from {})\n",
618 summary.signature, summary.channel, summary.date
619 ));
620 output.push_str(&format!(
621 "sig_count: {}, ref_count: {}\n\n",
622 summary.sig_count as u64, summary.ref_count
623 ));
624
625 if summary.items.is_empty() {
626 output.push_str("No correlations found.\n");
627 } else {
628 for item in &summary.items {
629 let prior_str = if let Some(prior) = &item.prior {
630 format!(
631 " [{:05.2}% vs {:05.2}% if {}]",
632 prior.sig_pct, prior.ref_pct, prior.label
633 )
634 } else {
635 String::new()
636 };
637 output.push_str(&format!(
638 "({:06.2}% vs {:05.2}% overall) {}{}\n",
639 item.sig_pct, item.ref_pct, item.label, prior_str
640 ));
641 }
642 }
643
644 output
645}
646
647pub fn format_crash_pings(summary: &CrashPingsSummary) -> String {
648 let mut output = String::new();
649
650 let date_str = if summary.date_from == summary.date_to {
651 summary.date_from.clone()
652 } else {
653 format!("{}..{}", summary.date_from, summary.date_to)
654 };
655 let filter_str = if let Some(ref sig) = summary.signature_filter {
656 format!(": \"{}\" ({} pings)", sig, summary.filtered_total)
657 } else {
658 format!(" ({} pings, sampled)", summary.total)
659 };
660 output.push_str(&format!("CRASH PINGS {}{}\n\n", date_str, filter_str));
661
662 if summary.facet_name != "signature" || summary.signature_filter.is_some() {
663 output.push_str(&format!("{}:\n", summary.facet_name));
664 }
665
666 if summary.items.is_empty() {
667 output.push_str(" (no matching pings)\n");
668 } else {
669 for item in &summary.items {
670 output.push_str(&format!(
671 " {} ({}, {:.2}%)\n",
672 item.label, item.count, item.percentage
673 ));
674 if !item.example_ids.is_empty() {
675 output.push_str(&format!(" e.g. {}\n", item.example_ids.join(", ")));
676 }
677 }
678 }
679
680 output
681}
682
683pub fn format_crash_ping_stack(summary: &CrashPingStackSummary) -> String {
684 let mut output = String::new();
685
686 output.push_str(&format!(
687 "CRASH PING {} ({})\n",
688 summary.crash_id, summary.date
689 ));
690
691 if summary.frames.is_empty() {
692 if summary.java_exception.is_some() {
693 output.push_str("\njava_exception:\n");
694 if let Some(ref exc) = summary.java_exception {
695 output.push_str(&format!(" {}\n", exc));
696 }
697 } else {
698 output.push_str("\nNo stack trace available.\n");
699 }
700 } else {
701 output.push_str("\nstack:\n");
702 for (i, frame) in summary.frames.iter().enumerate() {
703 output.push_str(&format!(" #{} {}\n", i, format_frame_location(frame)));
704 }
705 }
706
707 output
708}
709
710pub fn format_bugs(summary: &BugsSummary) -> String {
711 let mut output = String::new();
712
713 if summary.bugs.is_empty() {
714 output.push_str("No bugs found.\n");
715 } else {
716 for group in &summary.bugs {
717 output.push_str(&format!("bug {}\n", group.bug_id));
718 for sig in &group.signatures {
719 output.push_str(&format!(" {}\n", sig));
720 }
721 }
722 }
723
724 output
725}
726
727pub fn format_search(response: &SearchResponse) -> String {
728 let mut output = String::new();
729
730 output.push_str(&format!("FOUND {} crashes\n\n", response.total));
731
732 for hit in &response.hits {
733 let platform = match (&hit.platform, &hit.platform_version) {
734 (Some(p), Some(v)) => format!("{} {}", p, v),
735 (Some(p), None) => p.clone(),
736 (None, Some(v)) => v.clone(),
737 (None, None) => "?".to_string(),
738 };
739 let channel = hit.release_channel.as_deref().unwrap_or("?");
740 let build = hit.build_id.as_deref().unwrap_or("?");
741 output.push_str(&format!(
742 "{} | {} | {} {} | {} | {} | {} | {}\n",
743 hit.uuid, hit.date, hit.product, hit.version, platform, channel, build, hit.signature
744 ));
745 }
746
747 if !response.facets.is_empty() {
748 output.push_str("\nAGGREGATIONS:\n");
749 for (field, buckets) in &response.facets {
750 output.push_str(&format!("\n{}:\n", field));
751 for bucket in buckets {
752 output.push_str(&format!(" {} ({})\n", bucket.term, bucket.count));
753 }
754 }
755 }
756
757 output
758}