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
36#[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
74fn convert_test(test: TestMeasurement, options: &ConversionOptions) -> Vec<MeasurementData> {
89 let Some(duration) = test.duration else {
91 return vec![];
92 };
93
94 let name = format_measurement_name("test", &test.name, None, options);
95
96 let val = duration.as_nanos() as f64;
98
99 validate_unit(&name, "ns");
101
102 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 for (k, v) in test.metadata {
110 key_values.insert(k, v);
111 }
112
113 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
127fn 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()), "ms" => (value * 1_000_000.0, "ns".to_string()), "s" => (value * 1_000_000_000.0, "ns".to_string()), _ => {
140 log::warn!("Unknown benchmark unit '{}', storing value as-is", unit);
142 (value, unit.to_string())
143 }
144 }
145}
146
147fn 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
171fn convert_benchmark(
185 bench: BenchmarkMeasurement,
186 options: &ConversionOptions,
187) -> Vec<MeasurementData> {
188 let mut measurements = Vec::new();
189
190 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 ("unknown", bench.id.as_str(), None)
199 }
200 };
201
202 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 let (converted_value, normalized_unit) = convert_benchmark_unit(v, unit);
210
211 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 for (k, v) in &bench.metadata {
226 key_values.insert(k.clone(), v.clone());
227 }
228
229 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 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
262fn 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 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 assert_eq!(result.len(), 1);
360
361 let measurement = &result[0];
362 assert_eq!(measurement.name, "test::test_one");
363 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 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 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 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)), 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 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 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); 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 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); 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 let (val, unit) = convert_benchmark_unit(15.5, "us");
501 assert_eq!(val, 15500.0); assert_eq!(unit, "ns");
503
504 let (val, unit) = convert_benchmark_unit(2.5, "ms");
506 assert_eq!(val, 2_500_000.0); assert_eq!(unit, "ns");
508
509 let (val, unit) = convert_benchmark_unit(1.5, "s");
511 assert_eq!(val, 1_500_000_000.0); assert_eq!(unit, "ns");
513
514 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 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 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)), 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 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)), status: TestStatus::Passed,
624 metadata: HashMap::new(),
625 }),
626 ParsedMeasurement::Test(TestMeasurement {
627 name: "test_failed".to_string(),
628 duration: None, 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 assert_eq!(result.len(), 1);
639 assert_eq!(result[0].name, "test::test_passing");
640 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)), 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); }
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), median_ns: None,
671 slope_ns: None,
672 mad_ns: None,
673 unit: "us".to_string(), },
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 assert_eq!(result[0].val, 1_500_000.0);
684 assert_eq!(result[0].key_values.get("unit"), Some(&"ns".to_string()));
686 }
687}