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