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::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}