Skip to main content

socorro_cli/models/
crash_pings.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 serde::{Deserialize, Serialize};
6
7// --- API response types (struct-of-arrays with string deduplication) ---
8
9#[derive(Debug, Deserialize, Serialize)]
10pub struct IndexedStrings {
11    pub strings: Vec<String>,
12    pub values: Vec<u32>,
13}
14
15#[derive(Debug, Deserialize, Serialize)]
16pub struct NullableIndexedStrings {
17    pub strings: Vec<Option<String>>,
18    pub values: Vec<u32>,
19}
20
21impl IndexedStrings {
22    pub fn get(&self, i: usize) -> &str {
23        &self.strings[self.values[i] as usize]
24    }
25}
26
27impl NullableIndexedStrings {
28    pub fn get(&self, i: usize) -> Option<&str> {
29        self.strings[self.values[i] as usize].as_deref()
30    }
31}
32
33#[derive(Debug, Serialize, Deserialize)]
34pub struct CrashPingsResponse {
35    pub channel: IndexedStrings,
36    pub process: IndexedStrings,
37    pub ipc_actor: NullableIndexedStrings,
38    pub clientid: IndexedStrings,
39    pub crashid: Vec<String>,
40    pub version: IndexedStrings,
41    pub os: IndexedStrings,
42    pub osversion: IndexedStrings,
43    pub arch: IndexedStrings,
44    pub date: IndexedStrings,
45    pub reason: NullableIndexedStrings,
46    #[serde(rename = "type")]
47    pub crash_type: NullableIndexedStrings,
48    pub minidump_sha256_hash: Vec<Option<String>>,
49    pub startup_crash: Vec<Option<bool>>,
50    pub build_id: IndexedStrings,
51    pub signature: IndexedStrings,
52}
53
54impl CrashPingsResponse {
55    pub fn len(&self) -> usize {
56        self.crashid.len()
57    }
58
59    pub fn is_empty(&self) -> bool {
60        self.crashid.is_empty()
61    }
62
63    pub fn signature(&self, i: usize) -> &str {
64        self.signature.get(i)
65    }
66
67    pub fn channel(&self, i: usize) -> &str {
68        self.channel.get(i)
69    }
70
71    pub fn os(&self, i: usize) -> &str {
72        self.os.get(i)
73    }
74
75    pub fn process(&self, i: usize) -> &str {
76        self.process.get(i)
77    }
78
79    pub fn version(&self, i: usize) -> &str {
80        self.version.get(i)
81    }
82
83    pub fn arch(&self, i: usize) -> &str {
84        self.arch.get(i)
85    }
86
87    pub fn matches_filters(&self, i: usize, filters: &CrashPingFilters) -> bool {
88        if let Some(ref ch) = filters.channel
89            && !self.channel(i).eq_ignore_ascii_case(ch)
90        {
91            return false;
92        }
93        if let Some(ref os) = filters.os
94            && !self.os(i).eq_ignore_ascii_case(os)
95        {
96            return false;
97        }
98        if let Some(ref proc) = filters.process
99            && !self.process(i).eq_ignore_ascii_case(proc)
100        {
101            return false;
102        }
103        if let Some(ref ver) = filters.version
104            && self.version(i) != ver
105        {
106            return false;
107        }
108        if let Some(ref sig) = filters.signature {
109            let ping_sig = self.signature(i);
110            if let Some(pattern) = sig.strip_prefix('~') {
111                if !ping_sig.to_lowercase().contains(&pattern.to_lowercase()) {
112                    return false;
113                }
114            } else if ping_sig != sig {
115                return false;
116            }
117        }
118        if let Some(ref arch) = filters.arch
119            && !self.arch(i).eq_ignore_ascii_case(arch)
120        {
121            return false;
122        }
123        true
124    }
125
126    pub fn facet_value(&self, i: usize, facet: &str) -> String {
127        match facet {
128            "signature" => self.signature(i).to_string(),
129            "channel" => self.channel(i).to_string(),
130            "os" => self.os(i).to_string(),
131            "process" => self.process(i).to_string(),
132            "version" => self.version(i).to_string(),
133            "arch" => self.arch(i).to_string(),
134            "osversion" => self.osversion.get(i).to_string(),
135            "build_id" => self.build_id.get(i).to_string(),
136            "ipc_actor" => self.ipc_actor.get(i).unwrap_or("(none)").to_string(),
137            "reason" => self.reason.get(i).unwrap_or("(none)").to_string(),
138            "type" => self.crash_type.get(i).unwrap_or("(none)").to_string(),
139            _ => "(unknown facet)".to_string(),
140        }
141    }
142}
143
144// --- Stack trace types ---
145
146#[derive(Debug, Serialize, Deserialize)]
147pub struct CrashPingStackResponse {
148    pub stack: Option<Vec<CrashPingFrame>>,
149    pub java_exception: Option<serde_json::Value>,
150}
151
152#[derive(Debug, Serialize, Deserialize)]
153pub struct CrashPingFrame {
154    pub function: Option<String>,
155    pub function_offset: Option<String>,
156    pub file: Option<String>,
157    pub line: Option<u32>,
158    pub module: Option<String>,
159    pub module_offset: Option<String>,
160    pub offset: Option<String>,
161    #[serde(default)]
162    pub omitted: Option<serde_json::Value>,
163    #[serde(default)]
164    pub error: Option<String>,
165}
166
167// --- Filter parameters ---
168
169#[derive(Debug, Default)]
170pub struct CrashPingFilters {
171    pub channel: Option<String>,
172    pub os: Option<String>,
173    pub process: Option<String>,
174    pub version: Option<String>,
175    pub signature: Option<String>,
176    pub arch: Option<String>,
177}
178
179// --- Summary types for display ---
180
181#[derive(Debug, Serialize)]
182pub struct CrashPingsSummary {
183    pub date_from: String,
184    pub date_to: String,
185    pub total: usize,
186    pub filtered_total: usize,
187    pub signature_filter: Option<String>,
188    pub facet_name: String,
189    pub items: Vec<CrashPingsItem>,
190}
191
192#[derive(Debug, Serialize)]
193pub struct CrashPingsItem {
194    pub label: String,
195    pub count: usize,
196    pub percentage: f64,
197    pub example_ids: Vec<String>,
198}
199
200#[derive(Debug, Serialize)]
201pub struct CrashPingStackSummary {
202    pub crash_id: String,
203    pub date: String,
204    pub frames: Vec<CrashPingFrame>,
205    pub java_exception: Option<serde_json::Value>,
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use serde_json::json;
212
213    fn sample_response_json() -> serde_json::Value {
214        json!({
215            "channel": {
216                "strings": ["release", "beta", "nightly"],
217                "values": [0, 0, 1, 2]
218            },
219            "process": {
220                "strings": ["main", "content", "gpu"],
221                "values": [0, 1, 0, 2]
222            },
223            "ipc_actor": {
224                "strings": [null, "windows-file-dialog"],
225                "values": [0, 1, 0, 0]
226            },
227            "clientid": {
228                "strings": ["client1", "client2", "client3", "client4"],
229                "values": [0, 1, 2, 3]
230            },
231            "crashid": ["crash-1", "crash-2", "crash-3", "crash-4"],
232            "version": {
233                "strings": ["147.0", "148.0"],
234                "values": [0, 0, 1, 1]
235            },
236            "os": {
237                "strings": ["Windows", "Linux", "Mac"],
238                "values": [0, 0, 1, 2]
239            },
240            "osversion": {
241                "strings": ["10.0.19045", "6.1", "15.0"],
242                "values": [0, 0, 1, 2]
243            },
244            "arch": {
245                "strings": ["x86_64", "aarch64"],
246                "values": [0, 0, 0, 1]
247            },
248            "date": {
249                "strings": ["2026-02-12"],
250                "values": [0, 0, 0, 0]
251            },
252            "reason": {
253                "strings": [null, "OOM"],
254                "values": [0, 1, 0, 1]
255            },
256            "type": {
257                "strings": [null, "SIGSEGV"],
258                "values": [0, 1, 0, 0]
259            },
260            "minidump_sha256_hash": ["hash1", null, "hash3", null],
261            "startup_crash": [false, true, false, false],
262            "build_id": {
263                "strings": ["20260210103000", "20260211103000"],
264                "values": [0, 0, 1, 1]
265            },
266            "signature": {
267                "strings": ["OOM | small", "setup_stack_prot", "js::gc::SomeFunc"],
268                "values": [0, 0, 1, 2]
269            }
270        })
271    }
272
273    #[test]
274    fn test_deserialize_response() {
275        let data = sample_response_json();
276        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
277        assert_eq!(resp.len(), 4);
278        assert_eq!(resp.crashid.len(), 4);
279    }
280
281    #[test]
282    fn test_indexed_strings_get() {
283        let data = sample_response_json();
284        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
285        assert_eq!(resp.signature(0), "OOM | small");
286        assert_eq!(resp.signature(1), "OOM | small");
287        assert_eq!(resp.signature(2), "setup_stack_prot");
288        assert_eq!(resp.signature(3), "js::gc::SomeFunc");
289    }
290
291    #[test]
292    fn test_nullable_indexed_strings() {
293        let data = sample_response_json();
294        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
295        assert_eq!(resp.ipc_actor.get(0), None);
296        assert_eq!(resp.ipc_actor.get(1), Some("windows-file-dialog"));
297    }
298
299    #[test]
300    fn test_channel_accessor() {
301        let data = sample_response_json();
302        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
303        assert_eq!(resp.channel(0), "release");
304        assert_eq!(resp.channel(2), "beta");
305        assert_eq!(resp.channel(3), "nightly");
306    }
307
308    #[test]
309    fn test_os_accessor() {
310        let data = sample_response_json();
311        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
312        assert_eq!(resp.os(0), "Windows");
313        assert_eq!(resp.os(2), "Linux");
314        assert_eq!(resp.os(3), "Mac");
315    }
316
317    #[test]
318    fn test_filter_no_filters() {
319        let data = sample_response_json();
320        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
321        let filters = CrashPingFilters::default();
322        for i in 0..resp.len() {
323            assert!(resp.matches_filters(i, &filters));
324        }
325    }
326
327    #[test]
328    fn test_filter_by_channel() {
329        let data = sample_response_json();
330        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
331        let filters = CrashPingFilters {
332            channel: Some("release".to_string()),
333            ..Default::default()
334        };
335        assert!(resp.matches_filters(0, &filters));
336        assert!(resp.matches_filters(1, &filters));
337        assert!(!resp.matches_filters(2, &filters));
338        assert!(!resp.matches_filters(3, &filters));
339    }
340
341    #[test]
342    fn test_filter_by_os() {
343        let data = sample_response_json();
344        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
345        let filters = CrashPingFilters {
346            os: Some("Linux".to_string()),
347            ..Default::default()
348        };
349        assert!(!resp.matches_filters(0, &filters));
350        assert!(resp.matches_filters(2, &filters));
351    }
352
353    #[test]
354    fn test_filter_by_signature_exact() {
355        let data = sample_response_json();
356        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
357        let filters = CrashPingFilters {
358            signature: Some("OOM | small".to_string()),
359            ..Default::default()
360        };
361        assert!(resp.matches_filters(0, &filters));
362        assert!(resp.matches_filters(1, &filters));
363        assert!(!resp.matches_filters(2, &filters));
364    }
365
366    #[test]
367    fn test_filter_by_signature_contains() {
368        let data = sample_response_json();
369        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
370        let filters = CrashPingFilters {
371            signature: Some("~oom".to_string()),
372            ..Default::default()
373        };
374        assert!(resp.matches_filters(0, &filters));
375        assert!(!resp.matches_filters(2, &filters));
376    }
377
378    #[test]
379    fn test_filter_combined() {
380        let data = sample_response_json();
381        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
382        let filters = CrashPingFilters {
383            channel: Some("release".to_string()),
384            os: Some("Windows".to_string()),
385            ..Default::default()
386        };
387        assert!(resp.matches_filters(0, &filters));
388        assert!(resp.matches_filters(1, &filters));
389        assert!(!resp.matches_filters(2, &filters)); // beta
390        assert!(!resp.matches_filters(3, &filters)); // nightly + Mac
391    }
392
393    #[test]
394    fn test_filter_case_insensitive() {
395        let data = sample_response_json();
396        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
397        let filters = CrashPingFilters {
398            os: Some("windows".to_string()),
399            ..Default::default()
400        };
401        assert!(resp.matches_filters(0, &filters));
402    }
403
404    #[test]
405    fn test_facet_value() {
406        let data = sample_response_json();
407        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
408        assert_eq!(resp.facet_value(0, "signature"), "OOM | small");
409        assert_eq!(resp.facet_value(0, "os"), "Windows");
410        assert_eq!(resp.facet_value(0, "channel"), "release");
411        assert_eq!(resp.facet_value(1, "ipc_actor"), "windows-file-dialog");
412        assert_eq!(resp.facet_value(0, "ipc_actor"), "(none)");
413    }
414
415    #[test]
416    fn test_deserialize_stack_response() {
417        let data = json!({
418            "stack": [
419                {
420                    "function": "KiRaiseUserExceptionDispatcher",
421                    "function_offset": "0x000000000000003a",
422                    "file": null,
423                    "line": null,
424                    "module": "ntdll.dll",
425                    "module_offset": "0x00000000000a14fa",
426                    "omitted": null,
427                    "error": null,
428                    "offset": "0x00007ffbeeef14fa"
429                },
430                {
431                    "function": "mozilla::SomeFunc",
432                    "function_offset": null,
433                    "file": "SomeFile.cpp",
434                    "line": 42,
435                    "module": "xul.dll",
436                    "module_offset": "0x1234",
437                    "omitted": null,
438                    "error": null,
439                    "offset": "0x1234"
440                }
441            ],
442            "java_exception": null
443        });
444        let resp: CrashPingStackResponse = serde_json::from_value(data).unwrap();
445        let stack = resp.stack.unwrap();
446        assert_eq!(stack.len(), 2);
447        assert_eq!(
448            stack[0].function.as_deref(),
449            Some("KiRaiseUserExceptionDispatcher")
450        );
451        assert_eq!(stack[0].module.as_deref(), Some("ntdll.dll"));
452        assert!(stack[0].file.is_none());
453        assert_eq!(stack[1].file.as_deref(), Some("SomeFile.cpp"));
454        assert_eq!(stack[1].line, Some(42));
455    }
456
457    #[test]
458    fn test_deserialize_stack_response_null_stack() {
459        let data = json!({
460            "stack": null,
461            "java_exception": null
462        });
463        let resp: CrashPingStackResponse = serde_json::from_value(data).unwrap();
464        assert!(resp.stack.is_none());
465    }
466
467    #[test]
468    fn test_deserialize_stack_response_with_java_exception() {
469        let data = json!({
470            "stack": null,
471            "java_exception": {"message": "OutOfMemoryError", "frames": []}
472        });
473        let resp: CrashPingStackResponse = serde_json::from_value(data).unwrap();
474        assert!(resp.java_exception.is_some());
475    }
476
477    #[test]
478    fn test_crash_pings_summary() {
479        let summary = CrashPingsSummary {
480            date_from: "2026-02-12".to_string(),
481            date_to: "2026-02-12".to_string(),
482            total: 88808,
483            filtered_total: 4523,
484            signature_filter: Some("OOM | small".to_string()),
485            facet_name: "os".to_string(),
486            items: vec![
487                CrashPingsItem {
488                    label: "Windows".to_string(),
489                    count: 3900,
490                    percentage: 86.24,
491                    example_ids: vec!["id1".to_string(), "id2".to_string()],
492                },
493                CrashPingsItem {
494                    label: "Linux".to_string(),
495                    count: 400,
496                    percentage: 8.85,
497                    example_ids: vec!["id3".to_string()],
498                },
499            ],
500        };
501        assert_eq!(summary.items.len(), 2);
502        assert_eq!(summary.items[0].label, "Windows");
503    }
504
505    #[test]
506    fn test_filter_by_version() {
507        let data = sample_response_json();
508        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
509        let filters = CrashPingFilters {
510            version: Some("148.0".to_string()),
511            ..Default::default()
512        };
513        assert!(!resp.matches_filters(0, &filters));
514        assert!(resp.matches_filters(2, &filters));
515        assert!(resp.matches_filters(3, &filters));
516    }
517
518    #[test]
519    fn test_filter_by_arch() {
520        let data = sample_response_json();
521        let resp: CrashPingsResponse = serde_json::from_value(data).unwrap();
522        let filters = CrashPingFilters {
523            arch: Some("aarch64".to_string()),
524            ..Default::default()
525        };
526        assert!(!resp.matches_filters(0, &filters));
527        assert!(resp.matches_filters(3, &filters));
528    }
529}