1use std::{
2 collections::HashMap,
3 fs::File,
4 io::{self, ErrorKind, Write},
5 path::{Path, PathBuf},
6};
7
8use anyhow::anyhow;
9use anyhow::{bail, Result};
10use itertools::Itertools;
11use plotly::{
12 common::{Font, LegendGroupTitle, Title},
13 layout::{Axis, Legend},
14 Configuration, Layout, Plot,
15};
16
17use crate::{
18 config,
19 data::{Commit, MeasurementData, MeasurementSummary},
20 measurement_retrieval::{self, MeasurementReducer},
21 stats::ReductionFunc,
22};
23
24fn format_measurement_with_unit(measurement_name: &str) -> String {
27 match config::measurement_unit(measurement_name) {
28 Some(unit) => format!("{} ({})", measurement_name, unit),
29 None => measurement_name.to_string(),
30 }
31}
32
33struct CsvMeasurementRow {
36 commit: String,
37 epoch: u32,
38 measurement: String,
39 timestamp: f64,
40 value: f64,
41 unit: String,
42 metadata: HashMap<String, String>,
43}
44
45impl CsvMeasurementRow {
46 fn from_measurement(commit: &str, measurement: &MeasurementData) -> Self {
48 let unit = config::measurement_unit(&measurement.name).unwrap_or_default();
49 CsvMeasurementRow {
50 commit: commit.to_string(),
51 epoch: measurement.epoch,
52 measurement: measurement.name.clone(),
53 timestamp: measurement.timestamp,
54 value: measurement.val,
55 unit,
56 metadata: measurement.key_values.clone(),
57 }
58 }
59
60 fn from_summary(
62 commit: &str,
63 measurement_name: &str,
64 summary: &MeasurementSummary,
65 group_value: Option<&String>,
66 ) -> Self {
67 let unit = config::measurement_unit(measurement_name).unwrap_or_default();
68 let mut metadata = HashMap::new();
69 if let Some(gv) = group_value {
70 metadata.insert("group".to_string(), gv.clone());
71 }
72 CsvMeasurementRow {
73 commit: commit.to_string(),
74 epoch: summary.epoch,
75 measurement: measurement_name.to_string(),
76 timestamp: 0.0,
77 value: summary.val,
78 unit,
79 metadata,
80 }
81 }
82
83 fn to_csv_line(&self) -> String {
86 let value_str = if self.value.fract() == 0.0 && self.value.is_finite() {
89 format!("{:.1}", self.value)
90 } else {
91 self.value.to_string()
92 };
93
94 let timestamp_str = if self.timestamp.fract() == 0.0 && self.timestamp.is_finite() {
95 format!("{:.1}", self.timestamp)
96 } else {
97 self.timestamp.to_string()
98 };
99
100 let mut line = format!(
101 "{}\t{}\t{}\t{}\t{}\t{}",
102 self.commit, self.epoch, self.measurement, timestamp_str, value_str, self.unit
103 );
104
105 for (k, v) in &self.metadata {
107 line.push('\t');
108 line.push_str(k);
109 line.push('=');
110 line.push_str(v);
111 }
112
113 line
114 }
115}
116
117trait Reporter<'a> {
118 fn add_commits(&mut self, hashes: &'a [Commit]);
119 fn add_trace(
120 &mut self,
121 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
122 measurement_name: &str,
123 group_values: &[String],
124 );
125 fn add_summarized_trace(
126 &mut self,
127 indexed_measurements: Vec<(usize, MeasurementSummary)>,
128 measurement_name: &str,
129 group_values: &[String],
130 );
131 fn as_bytes(&self) -> Vec<u8>;
132}
133
134struct PlotlyReporter {
135 plot: Plot,
136 size: usize,
141 measurement_units: Vec<Option<String>>,
143}
144
145impl PlotlyReporter {
146 fn new() -> PlotlyReporter {
147 let config = Configuration::default().responsive(true).fill_frame(true);
148 let mut plot = Plot::new();
149 plot.set_configuration(config);
150 PlotlyReporter {
151 plot,
152 size: 0,
153 measurement_units: Vec::new(),
154 }
155 }
156
157 fn convert_to_x_y(&self, indexed_measurements: Vec<(usize, f64)>) -> (Vec<usize>, Vec<f64>) {
158 indexed_measurements
159 .iter()
160 .map(|(i, m)| (self.size - i - 1, *m))
161 .unzip()
162 }
163
164 fn compute_y_axis(&self) -> Option<Axis> {
166 if self.measurement_units.is_empty() {
168 return None;
169 }
170
171 let first_unit = self.measurement_units.first();
172 let all_same_unit = self
173 .measurement_units
174 .iter()
175 .all(|u| u == first_unit.unwrap());
176
177 if all_same_unit {
178 if let Some(Some(unit)) = first_unit {
179 return Some(Axis::new().title(Title::from(format!("Value ({})", unit))));
181 }
182 }
183 None
184 }
185}
186
187impl<'a> Reporter<'a> for PlotlyReporter {
188 fn add_commits(&mut self, commits: &'a [Commit]) {
189 let enumerated_commits = commits.iter().rev().enumerate();
190 self.size = commits.len();
191
192 let (commit_nrs, short_hashes): (Vec<_>, Vec<_>) = enumerated_commits
193 .map(|(n, c)| (n as f64, c.commit[..6].to_owned()))
194 .unzip();
195 let x_axis = Axis::new()
196 .tick_values(commit_nrs)
197 .tick_text(short_hashes)
198 .tick_angle(45.0)
199 .tick_font(Font::new().family("monospace"));
200 let layout = Layout::new()
201 .title(Title::from("Performance Measurements"))
202 .x_axis(x_axis)
203 .legend(
204 Legend::new()
205 .group_click(plotly::layout::GroupClick::ToggleItem)
206 .orientation(plotly::common::Orientation::Horizontal),
207 );
208
209 self.plot.set_layout(layout);
210 }
211
212 fn add_trace(
213 &mut self,
214 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
215 measurement_name: &str,
216 group_values: &[String],
217 ) {
218 let (x, y) = self.convert_to_x_y(
219 indexed_measurements
220 .into_iter()
221 .map(|(i, m)| (i, m.val))
222 .collect_vec(),
223 );
224
225 self.measurement_units
227 .push(config::measurement_unit(measurement_name));
228
229 let measurement_display = format_measurement_with_unit(measurement_name);
230
231 let trace = plotly::BoxPlot::new_xy(x, y);
232
233 let trace = if !group_values.is_empty() {
234 let group_label = group_values.join("/");
236 trace
237 .name(&group_label)
238 .legend_group(measurement_name)
239 .legend_group_title(LegendGroupTitle::from(measurement_display))
240 } else {
241 trace.name(&measurement_display)
242 };
243
244 self.plot.add_trace(trace);
245 }
246
247 fn add_summarized_trace(
248 &mut self,
249 indexed_measurements: Vec<(usize, MeasurementSummary)>,
250 measurement_name: &str,
251 group_values: &[String],
252 ) {
253 let (x, y) = self.convert_to_x_y(
254 indexed_measurements
255 .into_iter()
256 .map(|(i, m)| (i, m.val))
257 .collect_vec(),
258 );
259
260 self.measurement_units
262 .push(config::measurement_unit(measurement_name));
263
264 let measurement_display = format_measurement_with_unit(measurement_name);
265
266 let trace = plotly::Scatter::new(x, y).name(&measurement_display);
267
268 let trace = if !group_values.is_empty() {
269 let group_label = group_values.join("/");
271 trace
272 .name(&group_label)
273 .legend_group(measurement_name)
274 .legend_group_title(LegendGroupTitle::from(measurement_display))
275 } else {
276 trace.name(&measurement_display)
277 };
278
279 self.plot.add_trace(trace);
280 }
281
282 fn as_bytes(&self) -> Vec<u8> {
283 if let Some(y_axis) = self.compute_y_axis() {
285 let mut plot_with_y_axis = self.plot.clone();
286 let mut layout = plot_with_y_axis.layout().clone();
287 layout = layout.y_axis(y_axis);
288 plot_with_y_axis.set_layout(layout);
289 plot_with_y_axis.to_html().as_bytes().to_vec()
290 } else {
291 self.plot.to_html().as_bytes().to_vec()
292 }
293 }
294}
295
296struct CsvReporter<'a> {
297 hashes: Vec<String>,
298 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
299 summarized_measurements: Vec<(usize, String, Option<String>, MeasurementSummary)>,
300}
301
302impl CsvReporter<'_> {
303 fn new() -> Self {
304 CsvReporter {
305 hashes: Vec::new(),
306 indexed_measurements: Vec::new(),
307 summarized_measurements: Vec::new(),
308 }
309 }
310}
311
312impl<'a> Reporter<'a> for CsvReporter<'a> {
313 fn add_commits(&mut self, hashes: &'a [Commit]) {
314 self.hashes = hashes.iter().map(|c| c.commit.to_owned()).collect();
315 }
316
317 fn add_trace(
318 &mut self,
319 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
320 _measurement_name: &str,
321 _group_values: &[String],
322 ) {
323 self.indexed_measurements
324 .extend_from_slice(indexed_measurements.as_slice());
325 }
326
327 fn as_bytes(&self) -> Vec<u8> {
328 if self.indexed_measurements.is_empty() && self.summarized_measurements.is_empty() {
329 return Vec::new();
330 }
331
332 let mut lines = Vec::new();
333
334 lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
336
337 for (index, measurement_data) in &self.indexed_measurements {
339 let commit = &self.hashes[*index];
340 let row = CsvMeasurementRow::from_measurement(commit, measurement_data);
341 lines.push(row.to_csv_line());
342 }
343
344 for (index, measurement_name, group_value, summary) in &self.summarized_measurements {
346 let commit = &self.hashes[*index];
347 let row = CsvMeasurementRow::from_summary(
348 commit,
349 measurement_name,
350 summary,
351 group_value.as_ref(),
352 );
353 lines.push(row.to_csv_line());
354 }
355
356 let mut output = lines.join("\n");
357 output.push('\n');
358 output.into_bytes()
359 }
360
361 fn add_summarized_trace(
362 &mut self,
363 _indexed_measurements: Vec<(usize, MeasurementSummary)>,
364 _measurement_name: &str,
365 _group_values: &[String],
366 ) {
367 let group_label = if !_group_values.is_empty() {
370 Some(_group_values.join("/"))
371 } else {
372 None
373 };
374
375 for (index, summary) in _indexed_measurements.into_iter() {
376 self.summarized_measurements.push((
377 index,
378 _measurement_name.to_string(),
379 group_label.clone(),
380 summary,
381 ));
382 }
383 }
384}
385
386struct ReporterFactory {}
387
388impl ReporterFactory {
389 fn from_file_name(path: &Path) -> Option<Box<dyn Reporter<'_> + '_>> {
390 if path == Path::new("-") {
391 return Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>);
392 }
393 let mut res = None;
394 if let Some(ext) = path.extension() {
395 let extension = ext.to_ascii_lowercase().into_string().unwrap();
396 res = match extension.as_str() {
397 "html" => Some(Box::new(PlotlyReporter::new()) as Box<dyn Reporter<'_>>),
398 "csv" => Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>),
399 _ => None,
400 }
401 }
402 res
403 }
404}
405
406pub fn report(
407 output: PathBuf,
408 separate_by: Vec<String>,
409 num_commits: usize,
410 key_values: &[(String, String)],
411 aggregate_by: Option<ReductionFunc>,
412 combined_patterns: &[String],
413) -> Result<()> {
414 let filters = crate::filter::compile_filters(combined_patterns)?;
417
418 let commits: Vec<Commit> = measurement_retrieval::walk_commits(num_commits)?.try_collect()?;
419
420 if commits.is_empty() {
421 bail!(
422 "No commits found in repository. Ensure commits exist and were pushed to the remote."
423 );
424 }
425
426 let mut plot =
427 ReporterFactory::from_file_name(&output).ok_or(anyhow!("Could not infer output format"))?;
428
429 plot.add_commits(&commits);
430
431 let relevant = |m: &MeasurementData| {
432 if !crate::filter::matches_any_filter(&m.name, &filters) {
434 return false;
435 }
436
437 m.key_values_is_superset_of(key_values)
439 };
440
441 let relevant_measurements = commits
442 .iter()
443 .map(|commit| commit.measurements.iter().filter(|m| relevant(m)));
444
445 let unique_measurement_names: Vec<_> = relevant_measurements
446 .clone()
447 .flat_map(|m| m.map(|m| &m.name))
448 .unique()
449 .collect();
450
451 if unique_measurement_names.is_empty() {
452 bail!("No performance measurements found.")
453 }
454
455 for measurement_name in unique_measurement_names {
456 let filtered_measurements = relevant_measurements
457 .clone()
458 .map(|ms| ms.filter(|m| m.name == *measurement_name));
459
460 let group_values: Vec<Vec<String>> = if !separate_by.is_empty() {
461 filtered_measurements
463 .clone()
464 .flatten()
465 .filter_map(|m| {
466 let values: Vec<String> = separate_by
468 .iter()
469 .filter_map(|key| m.key_values.get(key).cloned())
470 .collect();
471
472 if values.len() == separate_by.len() {
474 Some(values)
475 } else {
476 None
477 }
478 })
479 .unique()
480 .collect_vec()
481 } else {
482 vec![]
483 };
484
485 let group_values_to_process: Vec<Vec<String>> = if group_values.is_empty() {
487 if !separate_by.is_empty() {
488 bail!(
489 "Invalid separator supplied, no measurements have all required keys: {:?}",
490 separate_by
491 );
492 }
493 vec![vec![]]
494 } else {
495 group_values
496 };
497
498 for group_value in group_values_to_process {
499 let group_measurements = filtered_measurements.clone().map(|ms| {
500 ms.filter(|m| {
501 if !group_value.is_empty() {
502 separate_by
504 .iter()
505 .zip(group_value.iter())
506 .all(|(key, expected_val)| {
507 m.key_values
508 .get(key)
509 .map(|v| v == expected_val)
510 .unwrap_or(false)
511 })
512 } else {
513 true
514 }
515 })
516 });
517
518 if let Some(reduction_func) = aggregate_by {
519 let trace_measurements = group_measurements
520 .clone()
521 .enumerate()
522 .flat_map(move |(i, ms)| {
523 ms.reduce_by(reduction_func)
524 .into_iter()
525 .map(move |m| (i, m))
526 })
527 .collect_vec();
528 plot.add_summarized_trace(trace_measurements, measurement_name, &group_value);
529 } else {
530 let trace_measurements: Vec<_> = group_measurements
531 .clone()
532 .enumerate()
533 .flat_map(|(i, ms)| ms.map(move |m| (i, m)))
534 .collect();
535 plot.add_trace(trace_measurements, measurement_name, &group_value);
536 }
537 }
538 }
539
540 if output == Path::new("-") {
541 match io::stdout().write_all(&plot.as_bytes()) {
542 Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()),
543 res => res,
544 }?;
545 } else {
546 File::create(&output)?.write_all(&plot.as_bytes())?;
547 }
548
549 Ok(())
550}
551
552#[cfg(test)]
553mod tests {
554 use super::*;
555
556 #[test]
557 fn test_convert_to_x_y_empty() {
558 let reporter = PlotlyReporter::new();
559 let (x, y) = reporter.convert_to_x_y(vec![]);
560 assert!(x.is_empty());
561 assert!(y.is_empty());
562 }
563
564 #[test]
565 fn test_convert_to_x_y_single_value() {
566 let mut reporter = PlotlyReporter::new();
567 reporter.size = 3;
568 let (x, y) = reporter.convert_to_x_y(vec![(0, 1.5)]);
569 assert_eq!(x, vec![2]);
570 assert_eq!(y, vec![1.5]);
571 }
572
573 #[test]
574 fn test_convert_to_x_y_multiple_values() {
575 let mut reporter = PlotlyReporter::new();
576 reporter.size = 5;
577 let (x, y) = reporter.convert_to_x_y(vec![(0, 10.0), (2, 20.0), (4, 30.0)]);
578 assert_eq!(x, vec![4, 2, 0]);
579 assert_eq!(y, vec![10.0, 20.0, 30.0]);
580 }
581
582 #[test]
583 fn test_convert_to_x_y_negative_values() {
584 let mut reporter = PlotlyReporter::new();
585 reporter.size = 2;
586 let (x, y) = reporter.convert_to_x_y(vec![(0, -5.5), (1, -10.2)]);
587 assert_eq!(x, vec![1, 0]);
588 assert_eq!(y, vec![-5.5, -10.2]);
589 }
590
591 #[test]
592 fn test_plotly_reporter_as_bytes_not_empty() {
593 let reporter = PlotlyReporter::new();
594 let bytes = reporter.as_bytes();
595 assert!(!bytes.is_empty());
596 let html = String::from_utf8_lossy(&bytes);
598 assert!(html.contains("plotly") || html.contains("Plotly"));
599 }
600
601 #[test]
602 fn test_reporter_factory_html_extension() {
603 let path = Path::new("output.html");
604 let reporter = ReporterFactory::from_file_name(path);
605 assert!(reporter.is_some());
606 }
607
608 #[test]
609 fn test_reporter_factory_csv_extension() {
610 let path = Path::new("output.csv");
611 let reporter = ReporterFactory::from_file_name(path);
612 assert!(reporter.is_some());
613 }
614
615 #[test]
616 fn test_reporter_factory_stdout() {
617 let path = Path::new("-");
618 let reporter = ReporterFactory::from_file_name(path);
619 assert!(reporter.is_some());
620 }
621
622 #[test]
623 fn test_reporter_factory_unsupported_extension() {
624 let path = Path::new("output.txt");
625 let reporter = ReporterFactory::from_file_name(path);
626 assert!(reporter.is_none());
627 }
628
629 #[test]
630 fn test_reporter_factory_no_extension() {
631 let path = Path::new("output");
632 let reporter = ReporterFactory::from_file_name(path);
633 assert!(reporter.is_none());
634 }
635
636 #[test]
637 fn test_reporter_factory_uppercase_extension() {
638 let path = Path::new("output.HTML");
639 let reporter = ReporterFactory::from_file_name(path);
640 assert!(reporter.is_some());
641 }
642
643 #[test]
644 fn test_format_measurement_with_unit_no_unit() {
645 let result = format_measurement_with_unit("unknown_measurement");
647 assert_eq!(result, "unknown_measurement");
648 }
649
650 #[test]
651 fn test_compute_y_axis_empty_measurements() {
652 let reporter = PlotlyReporter::new();
653 let y_axis = reporter.compute_y_axis();
654 assert!(y_axis.is_none());
655 }
656
657 #[test]
658 fn test_compute_y_axis_single_unit() {
659 let mut reporter = PlotlyReporter::new();
660 reporter.measurement_units.push(Some("ms".to_string()));
661 reporter.measurement_units.push(Some("ms".to_string()));
662 reporter.measurement_units.push(Some("ms".to_string()));
663
664 let y_axis = reporter.compute_y_axis();
665 assert!(y_axis.is_some());
666 }
667
668 #[test]
669 fn test_compute_y_axis_mixed_units() {
670 let mut reporter = PlotlyReporter::new();
671 reporter.measurement_units.push(Some("ms".to_string()));
672 reporter.measurement_units.push(Some("bytes".to_string()));
673
674 let y_axis = reporter.compute_y_axis();
675 assert!(y_axis.is_none());
676 }
677
678 #[test]
679 fn test_compute_y_axis_no_units() {
680 let mut reporter = PlotlyReporter::new();
681 reporter.measurement_units.push(None);
682 reporter.measurement_units.push(None);
683
684 let y_axis = reporter.compute_y_axis();
685 assert!(y_axis.is_none());
686 }
687
688 #[test]
689 fn test_compute_y_axis_some_with_unit_some_without() {
690 let mut reporter = PlotlyReporter::new();
691 reporter.measurement_units.push(Some("ms".to_string()));
692 reporter.measurement_units.push(None);
693
694 let y_axis = reporter.compute_y_axis();
695 assert!(y_axis.is_none());
696 }
697
698 #[test]
699 fn test_plotly_reporter_adds_units_to_legend() {
700 use crate::data::Commit;
701
702 let mut reporter = PlotlyReporter::new();
703
704 let commits = vec![
706 Commit {
707 commit: "abc123".to_string(),
708 measurements: vec![],
709 },
710 Commit {
711 commit: "def456".to_string(),
712 measurements: vec![],
713 },
714 ];
715 reporter.add_commits(&commits);
716
717 reporter.measurement_units.push(Some("ms".to_string()));
719
720 let bytes = reporter.as_bytes();
722 let html = String::from_utf8_lossy(&bytes);
723
724 assert!(!html.is_empty());
726 assert!(html.contains("plotly") || html.contains("Plotly"));
727 }
728
729 #[test]
730 fn test_plotly_reporter_y_axis_with_same_units() {
731 let mut reporter = PlotlyReporter::new();
732
733 reporter.measurement_units.push(Some("ms".to_string()));
735 reporter.measurement_units.push(Some("ms".to_string()));
736
737 let bytes = reporter.as_bytes();
739 let html = String::from_utf8_lossy(&bytes);
740
741 assert!(html.contains("Value (ms)"));
743 }
744
745 #[test]
746 fn test_plotly_reporter_no_y_axis_with_mixed_units() {
747 let mut reporter = PlotlyReporter::new();
748
749 reporter.measurement_units.push(Some("ms".to_string()));
751 reporter.measurement_units.push(Some("bytes".to_string()));
752
753 let bytes = reporter.as_bytes();
755 let html = String::from_utf8_lossy(&bytes);
756
757 assert!(!html.contains("Value (ms)"));
759 assert!(!html.contains("Value (bytes)"));
760 }
761
762 #[test]
763 fn test_csv_reporter_as_bytes_empty_on_init() {
764 let reporter = CsvReporter::new();
765 let bytes = reporter.as_bytes();
766 assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
768 }
769
770 #[test]
771 fn test_csv_reporter_includes_header() {
772 use crate::data::{Commit, MeasurementData};
773 use std::collections::HashMap;
774
775 let mut reporter = CsvReporter::new();
776
777 let commits = vec![Commit {
779 commit: "abc123".to_string(),
780 measurements: vec![],
781 }];
782 reporter.add_commits(&commits);
783
784 let measurement = MeasurementData {
786 epoch: 0,
787 name: "test_measurement".to_string(),
788 timestamp: 1234.0,
789 val: 42.5,
790 key_values: HashMap::new(),
791 };
792 reporter.add_trace(vec![(0, &measurement)], "test_measurement", &[]);
793
794 let bytes = reporter.as_bytes();
796 let csv = String::from_utf8_lossy(&bytes);
797
798 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
800
801 assert!(csv.contains("abc123"));
803 assert!(csv.contains("test_measurement"));
804 assert!(csv.contains("42.5"));
805 }
806
807 #[test]
808 fn test_csv_exact_output_single_measurement() {
809 use crate::data::{Commit, MeasurementData};
810 use std::collections::HashMap;
811
812 let mut reporter = CsvReporter::new();
813
814 let commits = vec![Commit {
815 commit: "abc123def456".to_string(),
816 measurements: vec![],
817 }];
818 reporter.add_commits(&commits);
819
820 let measurement = MeasurementData {
821 epoch: 0,
822 name: "build_time".to_string(),
823 timestamp: 1234567890.5,
824 val: 42.0,
825 key_values: HashMap::new(),
826 };
827 reporter.add_trace(vec![(0, &measurement)], "build_time", &[]);
828
829 let bytes = reporter.as_bytes();
830 let csv = String::from_utf8_lossy(&bytes);
831
832 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc123def456\t0\tbuild_time\t1234567890.5\t42.0\t\n";
833 assert_eq!(csv, expected);
834 }
835
836 #[test]
837 fn test_csv_exact_output_with_metadata() {
838 use crate::data::{Commit, MeasurementData};
839 use std::collections::HashMap;
840
841 let mut reporter = CsvReporter::new();
842
843 let commits = vec![Commit {
844 commit: "commit123".to_string(),
845 measurements: vec![],
846 }];
847 reporter.add_commits(&commits);
848
849 let mut metadata = HashMap::new();
850 metadata.insert("os".to_string(), "linux".to_string());
851 metadata.insert("arch".to_string(), "x64".to_string());
852
853 let measurement = MeasurementData {
854 epoch: 1,
855 name: "test".to_string(),
856 timestamp: 1000.0,
857 val: 3.5,
858 key_values: metadata,
859 };
860 reporter.add_trace(vec![(0, &measurement)], "test", &[]);
861
862 let bytes = reporter.as_bytes();
863 let csv = String::from_utf8_lossy(&bytes);
864
865 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
867 assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
868 assert!(csv.contains("os=linux"));
870 assert!(csv.contains("arch=x64"));
871 assert!(csv.ends_with('\n'));
873 }
874
875 #[test]
876 fn test_csv_exact_output_multiple_measurements() {
877 use crate::data::{Commit, MeasurementData};
878 use std::collections::HashMap;
879
880 let mut reporter = CsvReporter::new();
881
882 let commits = vec![
883 Commit {
884 commit: "commit1".to_string(),
885 measurements: vec![],
886 },
887 Commit {
888 commit: "commit2".to_string(),
889 measurements: vec![],
890 },
891 ];
892 reporter.add_commits(&commits);
893
894 let m1 = MeasurementData {
895 epoch: 0,
896 name: "timer".to_string(),
897 timestamp: 100.0,
898 val: 1.5,
899 key_values: HashMap::new(),
900 };
901
902 let m2 = MeasurementData {
903 epoch: 0,
904 name: "timer".to_string(),
905 timestamp: 200.0,
906 val: 2.0,
907 key_values: HashMap::new(),
908 };
909
910 reporter.add_trace(vec![(0, &m1), (1, &m2)], "timer", &[]);
911
912 let bytes = reporter.as_bytes();
913 let csv = String::from_utf8_lossy(&bytes);
914
915 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n\
916 commit1\t0\ttimer\t100.0\t1.5\t\n\
917 commit2\t0\ttimer\t200.0\t2.0\t\n";
918 assert_eq!(csv, expected);
919 }
920
921 #[test]
922 fn test_csv_exact_output_whole_number_formatting() {
923 use crate::data::{Commit, MeasurementData};
924 use std::collections::HashMap;
925
926 let mut reporter = CsvReporter::new();
927
928 let commits = vec![Commit {
929 commit: "hash1".to_string(),
930 measurements: vec![],
931 }];
932 reporter.add_commits(&commits);
933
934 let measurement = MeasurementData {
935 epoch: 0,
936 name: "count".to_string(),
937 timestamp: 500.0,
938 val: 10.0,
939 key_values: HashMap::new(),
940 };
941 reporter.add_trace(vec![(0, &measurement)], "count", &[]);
942
943 let bytes = reporter.as_bytes();
944 let csv = String::from_utf8_lossy(&bytes);
945
946 let expected =
948 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nhash1\t0\tcount\t500.0\t10.0\t\n";
949 assert_eq!(csv, expected);
950 }
951
952 #[test]
953 fn test_csv_exact_output_summarized_measurement() {
954 use crate::data::{Commit, MeasurementSummary};
955
956 let mut reporter = CsvReporter::new();
957
958 let commits = vec![Commit {
959 commit: "abc".to_string(),
960 measurements: vec![],
961 }];
962 reporter.add_commits(&commits);
963
964 let summary = MeasurementSummary { epoch: 0, val: 5.5 };
965
966 reporter.add_summarized_trace(vec![(0, summary)], "avg_time", &[]);
967
968 let bytes = reporter.as_bytes();
969 let csv = String::from_utf8_lossy(&bytes);
970
971 let expected =
973 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc\t0\tavg_time\t0.0\t5.5\t\n";
974 assert_eq!(csv, expected);
975 }
976}