git_perf/
reporting.rs

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
24/// Formats a measurement name with its configured unit, if available.
25/// Returns "measurement_name (unit)" if unit is configured, otherwise just "measurement_name".
26fn 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
33/// CSV row representation of a measurement with unit column.
34/// Metadata is stored separately and concatenated during serialization.
35struct 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    /// Create a CSV row from MeasurementData
47    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    /// Create a CSV row from MeasurementSummary
61    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    /// Format as a tab-delimited CSV line
84    /// Float values are formatted to always include at least one decimal place
85    fn to_csv_line(&self) -> String {
86        // Format floats with appropriate precision
87        // If value is a whole number, format as X.0, otherwise use default precision
88        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        // Add metadata key-value pairs
106        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    // Manual axis data reversal implementation: plotly-rs does not support autorange="reversed"
137    // The autorange parameter only accepts boolean values (as of v0.13.5), requiring manual
138    // index reversal to achieve reversed axis display (newest commits on right, oldest on left)
139    // See: https://github.com/kaihowl/git-perf/issues/339
140    size: usize,
141    // Track units for all measurements to determine if we should add unit to Y-axis label
142    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    /// Returns the Y-axis with unit label if all measurements share the same unit.
165    fn compute_y_axis(&self) -> Option<Axis> {
166        // Check if all measurements have the same unit (and at least one unit exists)
167        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                // All measurements share the same unit - add it to Y-axis label
180                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        // Track unit for this measurement
226        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            // Join group values with "/" for display (only at display time)
235            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        // Track unit for this measurement
261        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            // Join group values with "/" for display (only at display time)
270            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 all measurements share the same unit, add it to Y-axis label
284        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        // Add header
335        lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
336
337        // Add raw measurements
338        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        // Add summarized measurements
345        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        // Store summarized data to be serialized in as_bytes
368        // Join group values for CSV output (flat format)
369        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    // Compile combined regex patterns (measurements as exact matches + filter patterns)
415    // early to fail fast on invalid patterns
416    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        // Apply regex filters (handles both exact measurement matches and filter patterns)
433        if !crate::filter::matches_any_filter(&m.name, &filters) {
434            return false;
435        }
436
437        // Filter using subset relation: key_values ⊆ measurement.key_values
438        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            // Find all unique combinations of the split keys
462            filtered_measurements
463                .clone()
464                .flatten()
465                .filter_map(|m| {
466                    // Extract values for all split keys
467                    let values: Vec<String> = separate_by
468                        .iter()
469                        .filter_map(|key| m.key_values.get(key).cloned())
470                        .collect();
471
472                    // Only include if all split keys are present
473                    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        // When no splits specified, create a single group with all measurements
486        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                        // Check if measurement has ALL the expected key-value pairs
503                        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        // HTML output should contain plotly-related content
597        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        // Test measurement without unit configured
646        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        // Add commits
705        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        // Add trace with a measurement (simulate tracking units)
718        reporter.measurement_units.push(Some("ms".to_string()));
719
720        // Get HTML output
721        let bytes = reporter.as_bytes();
722        let html = String::from_utf8_lossy(&bytes);
723
724        // The HTML should be generated
725        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        // Simulate multiple measurements with same unit
734        reporter.measurement_units.push(Some("ms".to_string()));
735        reporter.measurement_units.push(Some("ms".to_string()));
736
737        // Get HTML output - should include Y-axis with unit
738        let bytes = reporter.as_bytes();
739        let html = String::from_utf8_lossy(&bytes);
740
741        // The HTML should contain the Y-axis label with unit
742        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        // Simulate measurements with different units
750        reporter.measurement_units.push(Some("ms".to_string()));
751        reporter.measurement_units.push(Some("bytes".to_string()));
752
753        // Get HTML output - should NOT include Y-axis with unit
754        let bytes = reporter.as_bytes();
755        let html = String::from_utf8_lossy(&bytes);
756
757        // The HTML should not contain a Y-axis label with a specific unit
758        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        // Empty reporter should produce empty bytes
767        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        // Add commits
778        let commits = vec![Commit {
779            commit: "abc123".to_string(),
780            measurements: vec![],
781        }];
782        reporter.add_commits(&commits);
783
784        // Add a measurement
785        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        // Get CSV output
795        let bytes = reporter.as_bytes();
796        let csv = String::from_utf8_lossy(&bytes);
797
798        // Should contain header row with unit column
799        assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
800
801        // Should contain data row with commit and measurement data
802        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        // Check that header and base fields are correct
866        assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
867        assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
868        // Check that metadata is present (order may vary due to HashMap)
869        assert!(csv.contains("os=linux"));
870        assert!(csv.contains("arch=x64"));
871        // Check trailing newline
872        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        // Whole numbers should be formatted with .0
947        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        // Summarized measurements have timestamp 0.0
972        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}