git_perf/converters/
mod.rs

1//! Converters for transforming parsed measurements into MeasurementData
2//!
3//! This module provides functionality to convert parsed test and benchmark
4//! measurements into the `MeasurementData` format used by git-perf's storage layer.
5
6use std::collections::HashMap;
7
8use crate::config;
9use crate::data::MeasurementData;
10use crate::parsers::{BenchmarkMeasurement, ParsedMeasurement, TestMeasurement};
11
12/// Options for converting parsed measurements to MeasurementData
13#[derive(Debug, Clone)]
14pub struct ConversionOptions {
15    /// Optional prefix to prepend to measurement names
16    pub prefix: Option<String>,
17    /// Extra metadata to add to all measurements
18    pub extra_metadata: HashMap<String, String>,
19    /// Epoch value for measurements
20    pub epoch: u32,
21    /// Timestamp for measurements (seconds since UNIX epoch)
22    pub timestamp: f64,
23}
24
25impl Default for ConversionOptions {
26    fn default() -> Self {
27        Self {
28            prefix: None,
29            extra_metadata: HashMap::new(),
30            epoch: 0,
31            timestamp: 0.0,
32        }
33    }
34}
35
36/// Convert parsed measurements to MeasurementData
37///
38/// This function takes a list of parsed measurements and converts them to
39/// the MeasurementData format, applying the specified conversion options.
40///
41/// **Test Measurements:**
42/// - Only converted if duration is present (tests with performance data)
43/// - Tests WITHOUT duration are skipped (no performance to track)
44/// - Value stored in nanoseconds for consistency with benchmarks
45/// - Unit stored in metadata as "ns"
46///
47/// **Benchmark Measurements:**
48/// - Value stored in nanoseconds (converts us/ms/s → ns)
49/// - Creates one measurement per statistic (mean, median, slope, MAD)
50/// - Unit validation warnings logged for mismatches with config
51///
52/// # Arguments
53///
54/// * `parsed` - Vector of parsed measurements to convert
55/// * `options` - Conversion options (prefix, metadata, epoch, timestamp)
56///
57/// # Returns
58///
59/// A vector of MeasurementData ready for storage
60#[must_use]
61pub fn convert_to_measurements(
62    parsed: Vec<ParsedMeasurement>,
63    options: &ConversionOptions,
64) -> Vec<MeasurementData> {
65    parsed
66        .into_iter()
67        .flat_map(|p| match p {
68            ParsedMeasurement::Test(test) => convert_test(test, options),
69            ParsedMeasurement::Benchmark(bench) => convert_benchmark(bench, options),
70        })
71        .collect()
72}
73
74/// Convert a test measurement to MeasurementData
75///
76/// **IMPORTANT**: Only converts tests that HAVE a duration.
77/// Tests without durations are skipped entirely (returns empty vec).
78///
79/// This is because:
80/// - We want to track test performance over time
81/// - Tests without duration (failed/skipped before execution) have no performance data
82/// - Duration is stored in nanoseconds for consistency with benchmarks
83///
84/// Creates a single MeasurementData entry with:
85/// - Name: `[prefix::]test::<test_name>`
86/// - Value: duration in nanoseconds
87/// - Metadata: type=test, status, unit=ns, classname (if present), plus extra metadata
88fn convert_test(test: TestMeasurement, options: &ConversionOptions) -> Vec<MeasurementData> {
89    // Skip tests that don't have duration - no performance data to track
90    let Some(duration) = test.duration else {
91        return vec![];
92    };
93
94    let name = format_measurement_name("test", &test.name, None, options);
95
96    // Convert duration to nanoseconds for consistency with benchmarks
97    let val = duration.as_nanos() as f64;
98
99    // Validate unit consistency with config
100    validate_unit(&name, "ns");
101
102    // Build metadata
103    let mut key_values = HashMap::new();
104    key_values.insert("type".to_string(), "test".to_string());
105    key_values.insert("status".to_string(), test.status.as_str().to_string());
106    key_values.insert("unit".to_string(), "ns".to_string());
107
108    // Add test's own metadata (like classname)
109    for (k, v) in test.metadata {
110        key_values.insert(k, v);
111    }
112
113    // Add extra metadata from options
114    for (k, v) in &options.extra_metadata {
115        key_values.insert(k.clone(), v.clone());
116    }
117
118    vec![MeasurementData {
119        epoch: options.epoch,
120        name,
121        timestamp: options.timestamp,
122        val,
123        key_values,
124    }]
125}
126
127/// Convert benchmark unit to a standard representation and value
128///
129/// Criterion reports units as: "ns", "us", "ms", "s"
130/// We convert the value to the base unit and normalize the unit string.
131///
132/// Returns (value, normalized_unit_string)
133fn convert_benchmark_unit(value: f64, unit: &str) -> (f64, String) {
134    match unit.to_lowercase().as_str() {
135        "ns" => (value, "ns".to_string()),
136        "us" | "μs" => (value * 1_000.0, "ns".to_string()), // Convert to ns
137        "ms" => (value * 1_000_000.0, "ns".to_string()),    // Convert to ns
138        "s" => (value * 1_000_000_000.0, "ns".to_string()), // Convert to ns
139        _ => {
140            // Unknown unit - preserve as-is and warn
141            log::warn!("Unknown benchmark unit '{}', storing value as-is", unit);
142            (value, unit.to_string())
143        }
144    }
145}
146
147/// Validate unit consistency with config and log warnings if needed
148fn validate_unit(measurement_name: &str, unit: &str) {
149    if let Some(configured_unit) = config::measurement_unit(measurement_name) {
150        if configured_unit != unit {
151            log::warn!(
152                "Unit mismatch for '{}': importing '{}' but config specifies '{}'. \
153                 Consider updating .gitperfconfig to match.",
154                measurement_name,
155                unit,
156                configured_unit
157            );
158        }
159    } else {
160        log::info!(
161            "No unit configured for '{}'. Importing with unit '{}'. \
162             Consider adding to .gitperfconfig: [measurement.\"{}\"]\nunit = \"{}\"",
163            measurement_name,
164            unit,
165            measurement_name,
166            unit
167        );
168    }
169}
170
171/// Convert a benchmark measurement to MeasurementData
172///
173/// Creates multiple MeasurementData entries (one per statistic):
174/// - Name: `[prefix::]bench::<bench_id>::<statistic>`
175/// - Value: statistic value in ORIGINAL UNIT from criterion
176/// - Unit: Normalized to "ns" for time-based benchmarks
177/// - Metadata: type=bench, group, bench_name, input (if present), statistic, unit, plus extra metadata
178///
179/// **Unit Handling:**
180/// - Preserves the unit from criterion's output
181/// - Normalizes time units to "ns" (converts us/ms/s → ns)
182/// - Stores unit in metadata for validation and display
183/// - Validates against configured unit and logs warnings
184fn convert_benchmark(
185    bench: BenchmarkMeasurement,
186    options: &ConversionOptions,
187) -> Vec<MeasurementData> {
188    let mut measurements = Vec::new();
189
190    // Parse benchmark ID to extract group, name, and optional input
191    // Format: "group/name/input" or "group/name"
192    let parts: Vec<&str> = bench.id.split('/').collect();
193    let (group, bench_name, input) = match parts.len() {
194        2 => (parts[0], parts[1], None),
195        3 => (parts[0], parts[1], Some(parts[2])),
196        _ => {
197            // Fallback: use full ID as bench_name
198            ("unknown", bench.id.as_str(), None)
199        }
200    };
201
202    // Helper to create a measurement for a specific statistic
203    let create_measurement =
204        |stat_name: &str, value: Option<f64>, unit: &str| -> Option<MeasurementData> {
205            value.map(|v| {
206                let name = format_measurement_name("bench", &bench.id, Some(stat_name), options);
207
208                // Convert value and normalize unit
209                let (converted_value, normalized_unit) = convert_benchmark_unit(v, unit);
210
211                // Validate unit consistency with config
212                validate_unit(&name, &normalized_unit);
213
214                let mut key_values = HashMap::new();
215                key_values.insert("type".to_string(), "bench".to_string());
216                key_values.insert("group".to_string(), group.to_string());
217                key_values.insert("bench_name".to_string(), bench_name.to_string());
218                if let Some(input_val) = input {
219                    key_values.insert("input".to_string(), input_val.to_string());
220                }
221                key_values.insert("statistic".to_string(), stat_name.to_string());
222                key_values.insert("unit".to_string(), normalized_unit);
223
224                // Add benchmark's own metadata
225                for (k, v) in &bench.metadata {
226                    key_values.insert(k.clone(), v.clone());
227                }
228
229                // Add extra metadata from options
230                for (k, v) in &options.extra_metadata {
231                    key_values.insert(k.clone(), v.clone());
232                }
233
234                MeasurementData {
235                    epoch: options.epoch,
236                    name,
237                    timestamp: options.timestamp,
238                    val: converted_value,
239                    key_values,
240                }
241            })
242        };
243
244    // Create measurements for available statistics
245    let unit = &bench.statistics.unit;
246    if let Some(m) = create_measurement("mean", bench.statistics.mean_ns, unit) {
247        measurements.push(m);
248    }
249    if let Some(m) = create_measurement("median", bench.statistics.median_ns, unit) {
250        measurements.push(m);
251    }
252    if let Some(m) = create_measurement("slope", bench.statistics.slope_ns, unit) {
253        measurements.push(m);
254    }
255    if let Some(m) = create_measurement("mad", bench.statistics.mad_ns, unit) {
256        measurements.push(m);
257    }
258
259    measurements
260}
261
262/// Format a measurement name with optional prefix and suffix
263///
264/// # Arguments
265///
266/// * `type_prefix` - "test" or "bench"
267/// * `id` - The test/benchmark identifier
268/// * `suffix` - Optional suffix (e.g., statistic name for benchmarks)
269/// * `options` - Conversion options (for user-provided prefix)
270///
271/// # Returns
272///
273/// Formatted name like: `[prefix::]type_prefix::id[::suffix]`
274fn format_measurement_name(
275    type_prefix: &str,
276    id: &str,
277    suffix: Option<&str>,
278    options: &ConversionOptions,
279) -> String {
280    let mut parts = Vec::new();
281
282    if let Some(prefix) = &options.prefix {
283        parts.push(prefix.as_str());
284    }
285
286    parts.push(type_prefix);
287    parts.push(id);
288
289    if let Some(s) = suffix {
290        parts.push(s);
291    }
292
293    parts.join("::")
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299    use crate::parsers::{BenchStatistics, TestStatus};
300    use std::time::Duration;
301
302    #[test]
303    fn test_format_measurement_name_no_prefix_no_suffix() {
304        let options = ConversionOptions::default();
305        let name = format_measurement_name("test", "my_test", None, &options);
306        assert_eq!(name, "test::my_test");
307    }
308
309    #[test]
310    fn test_format_measurement_name_with_prefix() {
311        let options = ConversionOptions {
312            prefix: Some("custom".to_string()),
313            ..Default::default()
314        };
315        let name = format_measurement_name("test", "my_test", None, &options);
316        assert_eq!(name, "custom::test::my_test");
317    }
318
319    #[test]
320    fn test_format_measurement_name_with_suffix() {
321        let options = ConversionOptions::default();
322        let name = format_measurement_name("bench", "my_bench", Some("mean"), &options);
323        assert_eq!(name, "bench::my_bench::mean");
324    }
325
326    #[test]
327    fn test_format_measurement_name_with_prefix_and_suffix() {
328        let options = ConversionOptions {
329            prefix: Some("perf".to_string()),
330            ..Default::default()
331        };
332        let name = format_measurement_name("bench", "my_bench", Some("median"), &options);
333        assert_eq!(name, "perf::bench::my_bench::median");
334    }
335
336    #[test]
337    fn test_convert_test_with_duration() {
338        // Tests WITH duration should be converted
339        let test = TestMeasurement {
340            name: "test_one".to_string(),
341            duration: Some(Duration::from_secs_f64(1.5)),
342            status: TestStatus::Passed,
343            metadata: {
344                let mut map = HashMap::new();
345                map.insert("classname".to_string(), "module::tests".to_string());
346                map
347            },
348        };
349
350        let options = ConversionOptions {
351            epoch: 1,
352            timestamp: 1234567890.0,
353            prefix: None,
354            extra_metadata: HashMap::new(),
355        };
356
357        let result = convert_test(test, &options);
358        // Should convert test with duration
359        assert_eq!(result.len(), 1);
360
361        let measurement = &result[0];
362        assert_eq!(measurement.name, "test::test_one");
363        // 1.5 seconds = 1,500,000,000 nanoseconds
364        assert_eq!(measurement.val, 1_500_000_000.0);
365        assert_eq!(measurement.epoch, 1);
366        assert_eq!(measurement.timestamp, 1234567890.0);
367        assert_eq!(
368            measurement.key_values.get("type"),
369            Some(&"test".to_string())
370        );
371        assert_eq!(
372            measurement.key_values.get("status"),
373            Some(&"passed".to_string())
374        );
375        assert_eq!(measurement.key_values.get("unit"), Some(&"ns".to_string()));
376        assert_eq!(
377            measurement.key_values.get("classname"),
378            Some(&"module::tests".to_string())
379        );
380    }
381
382    #[test]
383    fn test_convert_test_without_duration_is_skipped() {
384        // Tests WITHOUT duration should be skipped
385        let test = TestMeasurement {
386            name: "test_skipped".to_string(),
387            duration: None,
388            status: TestStatus::Skipped,
389            metadata: HashMap::new(),
390        };
391
392        let options = ConversionOptions::default();
393        let result = convert_test(test, &options);
394
395        // Should return empty vec - test without duration is skipped
396        assert_eq!(result.len(), 0);
397    }
398
399    #[test]
400    fn test_convert_test_failed_without_duration_is_skipped() {
401        let test = TestMeasurement {
402            name: "test_failed".to_string(),
403            duration: None,
404            status: TestStatus::Failed,
405            metadata: HashMap::new(),
406        };
407
408        let options = ConversionOptions::default();
409        let result = convert_test(test, &options);
410
411        // Should be skipped - no duration available
412        assert_eq!(result.len(), 0);
413    }
414
415    #[test]
416    fn test_convert_test_with_extra_metadata() {
417        let test = TestMeasurement {
418            name: "test_ci".to_string(),
419            duration: Some(Duration::from_millis(250)), // Add duration so test is converted
420            status: TestStatus::Passed,
421            metadata: HashMap::new(),
422        };
423
424        let mut extra_metadata = HashMap::new();
425        extra_metadata.insert("ci".to_string(), "true".to_string());
426        extra_metadata.insert("branch".to_string(), "main".to_string());
427
428        let options = ConversionOptions {
429            extra_metadata,
430            ..Default::default()
431        };
432
433        let result = convert_test(test, &options);
434        assert_eq!(result.len(), 1);
435        assert_eq!(result[0].key_values.get("ci"), Some(&"true".to_string()));
436        assert_eq!(
437            result[0].key_values.get("branch"),
438            Some(&"main".to_string())
439        );
440        assert_eq!(result[0].key_values.get("unit"), Some(&"ns".to_string()));
441        // 250 ms = 250,000,000 ns
442        assert_eq!(result[0].val, 250_000_000.0);
443    }
444
445    #[test]
446    fn test_convert_benchmark_all_statistics_nanoseconds() {
447        let bench = BenchmarkMeasurement {
448            id: "group/bench_name/100".to_string(),
449            statistics: BenchStatistics {
450                mean_ns: Some(15000.0),
451                median_ns: Some(14500.0),
452                slope_ns: Some(15200.0),
453                mad_ns: Some(100.0),
454                unit: "ns".to_string(),
455            },
456            metadata: HashMap::new(),
457        };
458
459        let options = ConversionOptions {
460            epoch: 2,
461            timestamp: 9876543210.0,
462            ..Default::default()
463        };
464
465        let result = convert_benchmark(bench, &options);
466        assert_eq!(result.len(), 4);
467
468        // Check mean measurement - should be in nanoseconds
469        let mean = result
470            .iter()
471            .find(|m| m.name == "bench::group/bench_name/100::mean")
472            .unwrap();
473        assert_eq!(mean.val, 15000.0); // Stored in nanoseconds
474        assert_eq!(mean.key_values.get("type"), Some(&"bench".to_string()));
475        assert_eq!(mean.key_values.get("group"), Some(&"group".to_string()));
476        assert_eq!(
477            mean.key_values.get("bench_name"),
478            Some(&"bench_name".to_string())
479        );
480        assert_eq!(mean.key_values.get("input"), Some(&"100".to_string()));
481        assert_eq!(mean.key_values.get("statistic"), Some(&"mean".to_string()));
482        assert_eq!(mean.key_values.get("unit"), Some(&"ns".to_string()));
483
484        // Check median measurement
485        let median = result
486            .iter()
487            .find(|m| m.name == "bench::group/bench_name/100::median")
488            .unwrap();
489        assert_eq!(median.val, 14500.0); // Stored in nanoseconds
490        assert_eq!(
491            median.key_values.get("statistic"),
492            Some(&"median".to_string())
493        );
494        assert_eq!(median.key_values.get("unit"), Some(&"ns".to_string()));
495    }
496
497    #[test]
498    fn test_convert_benchmark_unit_conversion() {
499        // Test microseconds converted to nanoseconds
500        let (val, unit) = convert_benchmark_unit(15.5, "us");
501        assert_eq!(val, 15500.0); // 15.5 us = 15500 ns
502        assert_eq!(unit, "ns");
503
504        // Test milliseconds converted to nanoseconds
505        let (val, unit) = convert_benchmark_unit(2.5, "ms");
506        assert_eq!(val, 2_500_000.0); // 2.5 ms = 2,500,000 ns
507        assert_eq!(unit, "ns");
508
509        // Test seconds converted to nanoseconds
510        let (val, unit) = convert_benchmark_unit(1.5, "s");
511        assert_eq!(val, 1_500_000_000.0); // 1.5 s = 1,500,000,000 ns
512        assert_eq!(unit, "ns");
513
514        // Test nanoseconds preserved
515        let (val, unit) = convert_benchmark_unit(1000.0, "ns");
516        assert_eq!(val, 1000.0);
517        assert_eq!(unit, "ns");
518    }
519
520    #[test]
521    fn test_convert_benchmark_partial_statistics() {
522        let bench = BenchmarkMeasurement {
523            id: "group/bench_name".to_string(),
524            statistics: BenchStatistics {
525                mean_ns: Some(10000.0),
526                median_ns: None,
527                slope_ns: Some(10500.0),
528                mad_ns: None,
529                unit: "ns".to_string(),
530            },
531            metadata: HashMap::new(),
532        };
533
534        let options = ConversionOptions::default();
535        let result = convert_benchmark(bench, &options);
536
537        // Only mean and slope should be present
538        assert_eq!(result.len(), 2);
539        assert!(result
540            .iter()
541            .any(|m| m.name == "bench::group/bench_name::mean"));
542        assert!(result
543            .iter()
544            .any(|m| m.name == "bench::group/bench_name::slope"));
545
546        // Verify unit is stored
547        assert!(result
548            .iter()
549            .all(|m| m.key_values.get("unit") == Some(&"ns".to_string())));
550    }
551
552    #[test]
553    fn test_convert_benchmark_no_input() {
554        let bench = BenchmarkMeasurement {
555            id: "my_group/my_bench".to_string(),
556            statistics: BenchStatistics {
557                mean_ns: Some(5000.0),
558                median_ns: None,
559                slope_ns: None,
560                mad_ns: None,
561                unit: "ns".to_string(),
562            },
563            metadata: HashMap::new(),
564        };
565
566        let options = ConversionOptions::default();
567        let result = convert_benchmark(bench, &options);
568
569        assert_eq!(result.len(), 1);
570        let measurement = &result[0];
571        assert_eq!(
572            measurement.key_values.get("group"),
573            Some(&"my_group".to_string())
574        );
575        assert_eq!(
576            measurement.key_values.get("bench_name"),
577            Some(&"my_bench".to_string())
578        );
579        assert_eq!(measurement.key_values.get("input"), None);
580        assert_eq!(measurement.key_values.get("unit"), Some(&"ns".to_string()));
581    }
582
583    #[test]
584    fn test_convert_to_measurements_mixed() {
585        let parsed = vec![
586            ParsedMeasurement::Test(TestMeasurement {
587                name: "test_one".to_string(),
588                duration: Some(Duration::from_millis(100)), // Has duration so it gets converted
589                status: TestStatus::Passed,
590                metadata: HashMap::new(),
591            }),
592            ParsedMeasurement::Benchmark(BenchmarkMeasurement {
593                id: "group/bench".to_string(),
594                statistics: BenchStatistics {
595                    mean_ns: Some(1000.0),
596                    median_ns: Some(900.0),
597                    slope_ns: None,
598                    mad_ns: None,
599                    unit: "ns".to_string(),
600                },
601                metadata: HashMap::new(),
602            }),
603        ];
604
605        let options = ConversionOptions::default();
606        let result = convert_to_measurements(parsed, &options);
607
608        // 1 test (with duration) + 2 bench statistics = 3 measurements
609        assert_eq!(result.len(), 3);
610        assert!(result.iter().any(|m| m.name == "test::test_one"));
611        assert!(result.iter().any(|m| m.name == "bench::group/bench::mean"));
612        assert!(result
613            .iter()
614            .any(|m| m.name == "bench::group/bench::median"));
615    }
616
617    #[test]
618    fn test_convert_to_measurements_skips_tests_without_duration() {
619        let parsed = vec![
620            ParsedMeasurement::Test(TestMeasurement {
621                name: "test_passing".to_string(),
622                duration: Some(Duration::from_secs(1)), // Has duration - should be converted
623                status: TestStatus::Passed,
624                metadata: HashMap::new(),
625            }),
626            ParsedMeasurement::Test(TestMeasurement {
627                name: "test_failed".to_string(),
628                duration: None, // No duration - should be skipped
629                status: TestStatus::Failed,
630                metadata: HashMap::new(),
631            }),
632        ];
633
634        let options = ConversionOptions::default();
635        let result = convert_to_measurements(parsed, &options);
636
637        // Only the passing test (with duration) should be converted
638        assert_eq!(result.len(), 1);
639        assert_eq!(result[0].name, "test::test_passing");
640        // 1 second = 1,000,000,000 nanoseconds
641        assert_eq!(result[0].val, 1_000_000_000.0);
642        assert_eq!(result[0].key_values.get("unit"), Some(&"ns".to_string()));
643    }
644
645    #[test]
646    fn test_convert_with_prefix() {
647        let parsed = vec![ParsedMeasurement::Test(TestMeasurement {
648            name: "my_test".to_string(),
649            duration: Some(Duration::from_millis(50)), // Add duration so test is converted
650            status: TestStatus::Passed,
651            metadata: HashMap::new(),
652        })];
653
654        let options = ConversionOptions {
655            prefix: Some("ci".to_string()),
656            ..Default::default()
657        };
658
659        let result = convert_to_measurements(parsed, &options);
660        assert_eq!(result[0].name, "ci::test::my_test");
661        assert_eq!(result[0].val, 50_000_000.0); // 50 ms in nanoseconds
662    }
663
664    #[test]
665    fn test_benchmark_preserves_unit() {
666        let bench = BenchmarkMeasurement {
667            id: "group/bench".to_string(),
668            statistics: BenchStatistics {
669                mean_ns: Some(1500.0), // 1500 microseconds
670                median_ns: None,
671                slope_ns: None,
672                mad_ns: None,
673                unit: "us".to_string(), // Microseconds
674            },
675            metadata: HashMap::new(),
676        };
677
678        let options = ConversionOptions::default();
679        let result = convert_benchmark(bench, &options);
680
681        assert_eq!(result.len(), 1);
682        // Value should be converted to nanoseconds: 1500 us = 1,500,000 ns
683        assert_eq!(result[0].val, 1_500_000.0);
684        // Unit should be normalized to ns
685        assert_eq!(result[0].key_values.get("unit"), Some(&"ns".to_string()));
686    }
687}