1use std::collections::HashMap;
7
8use crate::config;
9use crate::data::MeasurementData;
10use crate::parsers::{BenchmarkMeasurement, ParsedMeasurement, TestMeasurement};
11
12#[derive(Debug, Clone)]
14pub struct ConversionOptions {
15 pub prefix: Option<String>,
17 pub extra_metadata: HashMap<String, String>,
19 pub epoch: u32,
21 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
36pub 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
73fn convert_test(test: TestMeasurement, options: &ConversionOptions) -> Vec<MeasurementData> {
88 let Some(duration) = test.duration else {
90 return vec![];
91 };
92
93 let name = format_measurement_name("test", &test.name, None, options);
94
95 let val = duration.as_nanos() as f64;
97
98 validate_unit(&name, "ns");
100
101 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 for (k, v) in test.metadata {
109 key_values.insert(k, v);
110 }
111
112 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
126fn 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()), "ms" => (value * 1_000_000.0, "ns".to_string()), "s" => (value * 1_000_000_000.0, "ns".to_string()), _ => {
139 log::warn!("Unknown benchmark unit '{}', storing value as-is", unit);
141 (value, unit.to_string())
142 }
143 }
144}
145
146fn 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
170fn convert_benchmark(
184 bench: BenchmarkMeasurement,
185 options: &ConversionOptions,
186) -> Vec<MeasurementData> {
187 let mut measurements = Vec::new();
188
189 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 ("unknown", bench.id.as_str(), None)
198 }
199 };
200
201 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 let (converted_value, normalized_unit) = convert_benchmark_unit(v, unit);
209
210 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 for (k, v) in &bench.metadata {
225 key_values.insert(k.clone(), v.clone());
226 }
227
228 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 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
261fn 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 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 assert_eq!(result.len(), 1);
355
356 let measurement = &result[0];
357 assert_eq!(measurement.name, "test::test_one");
358 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 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 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 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)), 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 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 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); 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 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); 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 let (val, unit) = convert_benchmark_unit(15.5, "us");
496 assert_eq!(val, 15500.0); assert_eq!(unit, "ns");
498
499 let (val, unit) = convert_benchmark_unit(2.5, "ms");
501 assert_eq!(val, 2_500_000.0); assert_eq!(unit, "ns");
503
504 let (val, unit) = convert_benchmark_unit(1.5, "s");
506 assert_eq!(val, 1_500_000_000.0); assert_eq!(unit, "ns");
508
509 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 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 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)), 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 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)), status: TestStatus::Passed,
619 metadata: HashMap::new(),
620 }),
621 ParsedMeasurement::Test(TestMeasurement {
622 name: "test_failed".to_string(),
623 duration: None, 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 assert_eq!(result.len(), 1);
634 assert_eq!(result[0].name, "test::test_passing");
635 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)), 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); }
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), median_ns: None,
664 slope_ns: None,
665 mad_ns: None,
666 unit: "us".to_string(), },
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 assert_eq!(result[0].val, 1_500_000.0);
677 assert_eq!(result[0].key_values.get("unit"), Some(&"ns".to_string()));
679 }
680}