Skip to main content

socorro_cli/output/
compact.rs

1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at http://mozilla.org/MPL/2.0/.
4
5use 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        // mozglue.dll is NOT in any stack frame, so should be excluded
357        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        // mozglue.dll IS included in full mode
369        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        // Both mozglue.dll and xul.dll are in threads, so both should appear
407        assert!(output.contains("mozglue.dll"));
408        assert!(output.contains("xul.dll"));
409        // ntdll.dll is NOT in any thread frame
410        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        // No modules section when modules list is empty
419        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}