Skip to main content

socorro_cli/models/
correlations.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};
6use std::collections::HashMap;
7
8#[derive(Debug, Deserialize, Serialize)]
9pub struct CorrelationsTotals {
10    pub date: String,
11    pub release: u64,
12    pub beta: u64,
13    pub nightly: u64,
14    pub esr: u64,
15}
16
17impl CorrelationsTotals {
18    pub fn total_for_channel(&self, channel: &str) -> Option<u64> {
19        match channel {
20            "release" => Some(self.release),
21            "beta" => Some(self.beta),
22            "nightly" => Some(self.nightly),
23            "esr" => Some(self.esr),
24            _ => None,
25        }
26    }
27}
28
29#[derive(Debug, Deserialize, Serialize)]
30pub struct CorrelationsResponse {
31    pub total: f64,
32    pub results: Vec<CorrelationResult>,
33}
34
35#[derive(Debug, Deserialize, Serialize)]
36pub struct CorrelationResult {
37    pub item: HashMap<String, serde_json::Value>,
38    pub count_reference: f64,
39    pub count_group: f64,
40    pub prior: Option<CorrelationPrior>,
41}
42
43#[derive(Debug, Deserialize, Serialize)]
44pub struct CorrelationPrior {
45    pub item: HashMap<String, serde_json::Value>,
46    pub count_reference: f64,
47    pub count_group: f64,
48    pub total_reference: f64,
49    pub total_group: f64,
50}
51
52#[derive(Debug)]
53pub struct CorrelationsSummary {
54    pub signature: String,
55    pub channel: String,
56    pub date: String,
57    pub sig_count: f64,
58    pub ref_count: u64,
59    pub items: Vec<CorrelationItem>,
60}
61
62#[derive(Debug)]
63pub struct CorrelationItem {
64    pub label: String,
65    pub sig_pct: f64,
66    pub ref_pct: f64,
67    pub prior: Option<CorrelationItemPrior>,
68}
69
70#[derive(Debug)]
71pub struct CorrelationItemPrior {
72    pub label: String,
73    pub sig_pct: f64,
74    pub ref_pct: f64,
75}
76
77pub fn format_item_map(item: &HashMap<String, serde_json::Value>) -> String {
78    let mut keys: Vec<&String> = item.keys().collect();
79    keys.sort();
80    let parts: Vec<String> = keys
81        .iter()
82        .map(|k| {
83            let v = &item[*k];
84            let val_str = match v {
85                serde_json::Value::Null => "null".to_string(),
86                serde_json::Value::Bool(b) => b.to_string(),
87                serde_json::Value::String(s) => s.clone(),
88                serde_json::Value::Number(n) => n.to_string(),
89                other => other.to_string(),
90            };
91            format!("{} = {}", k, val_str)
92        })
93        .collect();
94    parts.join(" \u{2227} ")
95}
96
97impl CorrelationsResponse {
98    pub fn to_summary(
99        &self,
100        signature: &str,
101        channel: &str,
102        totals: &CorrelationsTotals,
103    ) -> CorrelationsSummary {
104        let ref_count = totals.total_for_channel(channel).unwrap_or(0);
105        let items = self
106            .results
107            .iter()
108            .map(|r| {
109                let sig_pct = if self.total > 0.0 {
110                    r.count_group / self.total * 100.0
111                } else {
112                    0.0
113                };
114                let ref_pct = if ref_count > 0 {
115                    r.count_reference / ref_count as f64 * 100.0
116                } else {
117                    0.0
118                };
119                let prior = r.prior.as_ref().map(|p| {
120                    let prior_sig_pct = if p.total_group > 0.0 {
121                        p.count_group / p.total_group * 100.0
122                    } else {
123                        0.0
124                    };
125                    let prior_ref_pct = if p.total_reference > 0.0 {
126                        p.count_reference / p.total_reference * 100.0
127                    } else {
128                        0.0
129                    };
130                    CorrelationItemPrior {
131                        label: format_item_map(&p.item),
132                        sig_pct: prior_sig_pct,
133                        ref_pct: prior_ref_pct,
134                    }
135                });
136                CorrelationItem {
137                    label: format_item_map(&r.item),
138                    sig_pct,
139                    ref_pct,
140                    prior,
141                }
142            })
143            .collect();
144
145        CorrelationsSummary {
146            signature: signature.to_string(),
147            channel: channel.to_string(),
148            date: totals.date.clone(),
149            sig_count: self.total,
150            ref_count,
151            items,
152        }
153    }
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159    use serde_json::json;
160
161    #[test]
162    fn test_deserialize_totals() {
163        let data = r#"{"date":"2026-02-13","release":79268,"beta":4996,"nightly":4876,"esr":792}"#;
164        let totals: CorrelationsTotals = serde_json::from_str(data).unwrap();
165        assert_eq!(totals.date, "2026-02-13");
166        assert_eq!(totals.release, 79268);
167        assert_eq!(totals.beta, 4996);
168        assert_eq!(totals.nightly, 4876);
169        assert_eq!(totals.esr, 792);
170    }
171
172    #[test]
173    fn test_total_for_channel_valid() {
174        let totals = CorrelationsTotals {
175            date: "2026-02-13".to_string(),
176            release: 79268,
177            beta: 4996,
178            nightly: 4876,
179            esr: 792,
180        };
181        assert_eq!(totals.total_for_channel("release"), Some(79268));
182        assert_eq!(totals.total_for_channel("beta"), Some(4996));
183        assert_eq!(totals.total_for_channel("nightly"), Some(4876));
184        assert_eq!(totals.total_for_channel("esr"), Some(792));
185    }
186
187    #[test]
188    fn test_total_for_channel_invalid() {
189        let totals = CorrelationsTotals {
190            date: "2026-02-13".to_string(),
191            release: 79268,
192            beta: 4996,
193            nightly: 4876,
194            esr: 792,
195        };
196        assert_eq!(totals.total_for_channel("aurora"), None);
197        assert_eq!(totals.total_for_channel("unknown"), None);
198    }
199
200    #[test]
201    fn test_deserialize_correlations_response() {
202        let data = r#"{
203            "total": 220.0,
204            "results": [
205                {
206                    "item": {"Module \"cscapi.dll\"": true},
207                    "count_reference": 19432.0,
208                    "count_group": 220.0,
209                    "prior": null
210                },
211                {
212                    "item": {"startup_crash": null},
213                    "count_reference": 920.0,
214                    "count_group": 65.0,
215                    "prior": {
216                        "item": {"process_type": "parent"},
217                        "count_reference": 3630.0,
218                        "count_group": 112.0,
219                        "total_reference": 79268.0,
220                        "total_group": 220.0
221                    }
222                }
223            ]
224        }"#;
225        let resp: CorrelationsResponse = serde_json::from_str(data).unwrap();
226        assert_eq!(resp.total, 220.0);
227        assert_eq!(resp.results.len(), 2);
228        assert_eq!(resp.results[0].count_group, 220.0);
229        assert!(resp.results[0].prior.is_none());
230        assert!(resp.results[1].prior.is_some());
231    }
232
233    #[test]
234    fn test_to_summary_percentages() {
235        let totals = CorrelationsTotals {
236            date: "2026-02-13".to_string(),
237            release: 79268,
238            beta: 4996,
239            nightly: 4876,
240            esr: 792,
241        };
242        let mut item = HashMap::new();
243        item.insert("Module \"cscapi.dll\"".to_string(), json!(true));
244        let resp = CorrelationsResponse {
245            total: 220.0,
246            results: vec![CorrelationResult {
247                item,
248                count_reference: 19432.0,
249                count_group: 220.0,
250                prior: None,
251            }],
252        };
253        let summary = resp.to_summary("TestSig", "release", &totals);
254        assert_eq!(summary.sig_count, 220.0);
255        assert_eq!(summary.ref_count, 79268);
256        assert!((summary.items[0].sig_pct - 100.0).abs() < 0.01);
257        assert!((summary.items[0].ref_pct - 24.51).abs() < 0.01);
258    }
259
260    #[test]
261    fn test_to_summary_with_prior() {
262        let totals = CorrelationsTotals {
263            date: "2026-02-13".to_string(),
264            release: 79268,
265            beta: 4996,
266            nightly: 4876,
267            esr: 792,
268        };
269        let mut item = HashMap::new();
270        item.insert("startup_crash".to_string(), serde_json::Value::Null);
271        let mut prior_item = HashMap::new();
272        prior_item.insert("process_type".to_string(), json!("parent"));
273        let resp = CorrelationsResponse {
274            total: 220.0,
275            results: vec![CorrelationResult {
276                item,
277                count_reference: 920.0,
278                count_group: 65.0,
279                prior: Some(CorrelationPrior {
280                    item: prior_item,
281                    count_reference: 3630.0,
282                    count_group: 112.0,
283                    total_reference: 79268.0,
284                    total_group: 220.0,
285                }),
286            }],
287        };
288        let summary = resp.to_summary("TestSig", "release", &totals);
289        let item = &summary.items[0];
290        assert!((item.sig_pct - 29.545).abs() < 0.01);
291        let prior = item.prior.as_ref().unwrap();
292        assert!((prior.sig_pct - 50.909).abs() < 0.01);
293        assert!((prior.ref_pct - 4.578).abs() < 0.01);
294    }
295
296    #[test]
297    fn test_format_item_map_single_key_true() {
298        let mut item = HashMap::new();
299        item.insert("Module \"cscapi.dll\"".to_string(), json!(true));
300        assert_eq!(format_item_map(&item), "Module \"cscapi.dll\" = true");
301    }
302
303    #[test]
304    fn test_format_item_map_single_key_null() {
305        let mut item = HashMap::new();
306        item.insert("startup_crash".to_string(), serde_json::Value::Null);
307        assert_eq!(format_item_map(&item), "startup_crash = null");
308    }
309
310    #[test]
311    fn test_format_item_map_single_key_string() {
312        let mut item = HashMap::new();
313        item.insert("process_type".to_string(), json!("parent"));
314        assert_eq!(format_item_map(&item), "process_type = parent");
315    }
316
317    #[test]
318    fn test_format_item_map_multi_key_sorted() {
319        let mut item = HashMap::new();
320        item.insert("z_field".to_string(), json!(true));
321        item.insert("a_field".to_string(), json!("value"));
322        let result = format_item_map(&item);
323        assert_eq!(result, "a_field = value \u{2227} z_field = true");
324    }
325}