git_perf/parsers/
criterion_json.rs

1use crate::parsers::types::{BenchStatistics, BenchmarkMeasurement, ParsedMeasurement, Parser};
2use anyhow::{Context, Result};
3use serde::Deserialize;
4use std::collections::HashMap;
5
6/// Parser for cargo-criterion JSON format (line-delimited JSON)
7pub struct CriterionJsonParser;
8
9impl Parser for CriterionJsonParser {
10    fn parse(&self, input: &str) -> Result<Vec<ParsedMeasurement>> {
11        let mut measurements = Vec::new();
12
13        for (line_num, line) in input.lines().enumerate() {
14            let line = line.trim();
15            if line.is_empty() {
16                continue;
17            }
18
19            let message: CriterionMessage = serde_json::from_str(line)
20                .with_context(|| format!("Failed to parse JSON on line {}", line_num + 1))?;
21
22            // Only process benchmark-complete messages
23            if message.reason == "benchmark-complete" {
24                if let Some(measurement) = message.into_measurement()? {
25                    measurements.push(measurement);
26                }
27            }
28        }
29
30        Ok(measurements)
31    }
32}
33
34#[derive(Debug, Deserialize)]
35struct CriterionMessage {
36    reason: String,
37    #[serde(default)]
38    id: String,
39    group: Option<String>,
40    unit: Option<String>,
41    mean: Option<Estimate>,
42    median: Option<Estimate>,
43    slope: Option<Estimate>,
44    median_abs_dev: Option<Estimate>,
45}
46
47#[derive(Debug, Deserialize)]
48#[allow(dead_code)]
49struct Estimate {
50    estimate: f64,
51    lower_bound: Option<f64>,
52    upper_bound: Option<f64>,
53}
54
55impl CriterionMessage {
56    fn into_measurement(self) -> Result<Option<ParsedMeasurement>> {
57        if self.reason != "benchmark-complete" {
58            return Ok(None);
59        }
60
61        let unit = self.unit.unwrap_or_else(|| "ns".to_string());
62
63        // Extract group, name, and input from the benchmark ID
64        // Format: "group/name/input" or "group/name" or just "name"
65        // Prefer the explicit group field from criterion if available
66        let (parsed_group, bench_name, input) = parse_benchmark_id(&self.id);
67        let group = self.group.or(parsed_group);
68
69        let statistics = BenchStatistics {
70            mean_ns: self.mean.map(|e| convert_to_nanoseconds(e.estimate, &unit)),
71            median_ns: self
72                .median
73                .map(|e| convert_to_nanoseconds(e.estimate, &unit)),
74            slope_ns: self
75                .slope
76                .map(|e| convert_to_nanoseconds(e.estimate, &unit)),
77            mad_ns: self
78                .median_abs_dev
79                .map(|e| convert_to_nanoseconds(e.estimate, &unit)),
80            unit,
81        };
82
83        let mut metadata = HashMap::new();
84        metadata.insert("type".to_string(), "bench".to_string());
85        if let Some(g) = group {
86            metadata.insert("group".to_string(), g);
87        }
88        if let Some(n) = bench_name {
89            metadata.insert("bench_name".to_string(), n);
90        }
91        if let Some(i) = input {
92            metadata.insert("input".to_string(), i);
93        }
94
95        Ok(Some(ParsedMeasurement::Benchmark(BenchmarkMeasurement {
96            id: self.id,
97            statistics,
98            metadata,
99        })))
100    }
101}
102
103/// Parse benchmark ID into group, name, and input components
104/// Examples:
105///   "add_measurements/add_measurement/50" -> (Some("add_measurements"), Some("add_measurement"), Some("50"))
106///   "add_measurements/add_measurement" -> (Some("add_measurements"), Some("add_measurement"), None)
107///   "simple_bench" -> (None, Some("simple_bench"), None)
108fn parse_benchmark_id(id: &str) -> (Option<String>, Option<String>, Option<String>) {
109    let parts: Vec<&str> = id.split('/').collect();
110
111    match parts.len() {
112        0 => (None, None, None),
113        1 => (None, Some(parts[0].to_string()), None),
114        2 => (Some(parts[0].to_string()), Some(parts[1].to_string()), None),
115        _ => (
116            Some(parts[0].to_string()),
117            Some(parts[1].to_string()),
118            Some(parts[2..].join("/")),
119        ),
120    }
121}
122
123/// Convert criterion measurement to nanoseconds
124fn convert_to_nanoseconds(value: f64, unit: &str) -> f64 {
125    match unit {
126        "ns" => value,
127        "us" => value * 1_000.0,
128        "ms" => value * 1_000_000.0,
129        "s" => value * 1_000_000_000.0,
130        _ => value, // Assume nanoseconds if unknown
131    }
132}
133
134#[cfg(test)]
135mod tests {
136    use super::*;
137
138    #[test]
139    fn test_parse_benchmark_complete() {
140        let json = r#"{"reason":"benchmark-complete","id":"add_measurements/add_measurement/50","unit":"ns","mean":{"estimate":15456.78,"lower_bound":15234.0,"upper_bound":15678.5},"median":{"estimate":15400.0},"slope":{"estimate":15420.5},"median_abs_dev":{"estimate":123.45}}"#;
141
142        let parser = CriterionJsonParser;
143        let result = parser.parse(json).unwrap();
144
145        assert_eq!(result.len(), 1);
146
147        if let ParsedMeasurement::Benchmark(bench) = &result[0] {
148            assert_eq!(bench.id, "add_measurements/add_measurement/50");
149            assert_eq!(bench.statistics.unit, "ns");
150            assert_eq!(bench.statistics.mean_ns, Some(15456.78));
151            assert_eq!(bench.statistics.median_ns, Some(15400.0));
152            assert_eq!(bench.statistics.slope_ns, Some(15420.5));
153            assert_eq!(bench.statistics.mad_ns, Some(123.45));
154            assert_eq!(bench.metadata.get("group").unwrap(), "add_measurements");
155            assert_eq!(bench.metadata.get("bench_name").unwrap(), "add_measurement");
156            assert_eq!(bench.metadata.get("input").unwrap(), "50");
157        } else {
158            panic!("Expected Benchmark measurement");
159        }
160    }
161
162    #[test]
163    fn test_parse_multiple_lines() {
164        let json = r#"{"reason":"group-start","group":"fibonacci"}
165{"reason":"benchmark-complete","id":"fibonacci_10","unit":"us","mean":{"estimate":1.234}}
166{"reason":"benchmark-complete","id":"fibonacci_20","unit":"us","mean":{"estimate":56.789}}
167{"reason":"group-complete","group":"fibonacci"}"#;
168
169        let parser = CriterionJsonParser;
170        let result = parser.parse(json).unwrap();
171
172        assert_eq!(result.len(), 2);
173
174        if let ParsedMeasurement::Benchmark(bench) = &result[0] {
175            assert_eq!(bench.id, "fibonacci_10");
176            // us to ns conversion
177            assert_eq!(bench.statistics.mean_ns, Some(1234.0));
178        } else {
179            panic!("Expected Benchmark measurement");
180        }
181    }
182
183    #[test]
184    fn test_parse_benchmark_id_three_parts() {
185        let (group, name, input) = parse_benchmark_id("add_measurements/add_measurement/50");
186        assert_eq!(group, Some("add_measurements".to_string()));
187        assert_eq!(name, Some("add_measurement".to_string()));
188        assert_eq!(input, Some("50".to_string()));
189    }
190
191    #[test]
192    fn test_parse_benchmark_id_two_parts() {
193        let (group, name, input) = parse_benchmark_id("fibonacci/fib_10");
194        assert_eq!(group, Some("fibonacci".to_string()));
195        assert_eq!(name, Some("fib_10".to_string()));
196        assert_eq!(input, None);
197    }
198
199    #[test]
200    fn test_parse_benchmark_id_one_part() {
201        let (group, name, input) = parse_benchmark_id("simple_bench");
202        assert_eq!(group, None);
203        assert_eq!(name, Some("simple_bench".to_string()));
204        assert_eq!(input, None);
205    }
206
207    #[test]
208    fn test_convert_units() {
209        assert_eq!(convert_to_nanoseconds(1.0, "ns"), 1.0);
210        assert_eq!(convert_to_nanoseconds(1.0, "us"), 1000.0);
211        assert_eq!(convert_to_nanoseconds(1.0, "ms"), 1_000_000.0);
212        assert_eq!(convert_to_nanoseconds(1.0, "s"), 1_000_000_000.0);
213    }
214
215    #[test]
216    fn test_parse_empty_lines() {
217        let json = r#"
218{"reason":"benchmark-complete","id":"test","unit":"ns","mean":{"estimate":100.0}}
219
220{"reason":"benchmark-complete","id":"test2","unit":"ns","mean":{"estimate":200.0}}
221"#;
222
223        let parser = CriterionJsonParser;
224        let result = parser.parse(json).unwrap();
225        assert_eq!(result.len(), 2);
226    }
227
228    #[test]
229    fn test_parse_invalid_json() {
230        let json = "not valid json";
231        let parser = CriterionJsonParser;
232        assert!(parser.parse(json).is_err());
233    }
234
235    #[test]
236    fn test_parse_missing_unit_defaults_to_ns() {
237        let json = r#"{"reason":"benchmark-complete","id":"test","mean":{"estimate":15456.78}}"#;
238
239        let parser = CriterionJsonParser;
240        let result = parser.parse(json).unwrap();
241
242        if let ParsedMeasurement::Benchmark(bench) = &result[0] {
243            assert_eq!(bench.statistics.unit, "ns");
244            assert_eq!(bench.statistics.mean_ns, Some(15456.78));
245        } else {
246            panic!("Expected Benchmark measurement");
247        }
248    }
249}