git_perf/
reporting.rs

1use std::{
2    fs::File,
3    io::{self, ErrorKind, Write},
4    path::{Path, PathBuf},
5};
6
7use anyhow::anyhow;
8use anyhow::{bail, Result};
9use itertools::Itertools;
10use plotly::{
11    common::{Font, LegendGroupTitle, Title},
12    layout::{Axis, Legend},
13    Configuration, Layout, Plot,
14};
15
16use crate::{
17    data::{Commit, MeasurementData, MeasurementSummary},
18    measurement_retrieval::{self, MeasurementReducer},
19    serialization::{serialize_single, DELIMITER},
20    stats::ReductionFunc,
21};
22
23trait Reporter<'a> {
24    fn add_commits(&mut self, hashes: &'a [Commit]);
25    fn add_trace(
26        &mut self,
27        indexed_measurements: Vec<(usize, &'a MeasurementData)>,
28        measurement_name: &str,
29        group_value: Option<&String>,
30    );
31    fn add_summarized_trace(
32        &mut self,
33        indexed_measurements: Vec<(usize, MeasurementSummary)>,
34        measurement_name: &str,
35        group_value: Option<&String>,
36    );
37    fn as_bytes(&self) -> Vec<u8>;
38}
39
40struct PlotlyReporter {
41    plot: Plot,
42    // Manual axis data reversal implementation: plotly-rs does not support autorange="reversed"
43    // The autorange parameter only accepts boolean values, requiring manual index reversal
44    // to achieve reversed axis display (newest commits on right, oldest on left)
45    // See: https://github.com/kaihowl/git-perf/issues/339
46    size: usize,
47}
48
49impl PlotlyReporter {
50    fn new() -> PlotlyReporter {
51        let config = Configuration::default().responsive(true).fill_frame(true);
52        let mut plot = Plot::new();
53        plot.set_configuration(config);
54        PlotlyReporter { plot, size: 0 }
55    }
56
57    fn convert_to_x_y(&self, indexed_measurements: Vec<(usize, f64)>) -> (Vec<usize>, Vec<f64>) {
58        indexed_measurements
59            .iter()
60            .map(|(i, m)| (self.size - i - 1, *m))
61            .unzip()
62    }
63}
64
65impl<'a> Reporter<'a> for PlotlyReporter {
66    fn add_commits(&mut self, commits: &'a [Commit]) {
67        let enumerated_commits = commits.iter().rev().enumerate();
68        self.size = commits.len();
69
70        let (commit_nrs, short_hashes): (Vec<_>, Vec<_>) = enumerated_commits
71            .map(|(n, c)| (n as f64, c.commit[..6].to_owned()))
72            .unzip();
73        let x_axis = Axis::new()
74            .tick_values(commit_nrs)
75            .tick_text(short_hashes)
76            .tick_angle(45.0)
77            .tick_font(Font::new().family("monospace"));
78        let layout = Layout::new()
79            .title(Title::new("Performance Measurements"))
80            .x_axis(x_axis)
81            .legend(
82                Legend::new()
83                    .group_click(plotly::layout::GroupClick::ToggleItem)
84                    .orientation(plotly::common::Orientation::Horizontal),
85            );
86
87        self.plot.set_layout(layout);
88    }
89
90    fn add_trace(
91        &mut self,
92        indexed_measurements: Vec<(usize, &'a MeasurementData)>,
93        measurement_name: &str,
94        group_value: Option<&String>,
95    ) {
96        let (x, y) = self.convert_to_x_y(
97            indexed_measurements
98                .into_iter()
99                .map(|(i, m)| (i, m.val))
100                .collect_vec(),
101        );
102
103        let trace = plotly::BoxPlot::new_xy(x, y);
104
105        let trace = if let Some(group_value) = group_value {
106            trace
107                .name(group_value)
108                .legend_group(measurement_name)
109                .legend_group_title(LegendGroupTitle::new(measurement_name))
110        } else {
111            trace.name(measurement_name)
112        };
113
114        self.plot.add_trace(trace);
115    }
116
117    fn add_summarized_trace(
118        &mut self,
119        indexed_measurements: Vec<(usize, MeasurementSummary)>,
120        measurement_name: &str,
121        group_value: Option<&String>,
122    ) {
123        let (x, y) = self.convert_to_x_y(
124            indexed_measurements
125                .into_iter()
126                .map(|(i, m)| (i, m.val))
127                .collect_vec(),
128        );
129
130        let trace = plotly::Scatter::new(x, y).name(measurement_name);
131
132        let trace = if let Some(group_value) = group_value {
133            trace
134                .name(group_value)
135                .legend_group(measurement_name)
136                .legend_group_title(LegendGroupTitle::new(measurement_name))
137        } else {
138            trace.name(measurement_name)
139        };
140
141        self.plot.add_trace(trace);
142    }
143
144    fn as_bytes(&self) -> Vec<u8> {
145        self.plot.to_html().as_bytes().to_vec()
146    }
147}
148
149struct CsvReporter<'a> {
150    hashes: Vec<String>,
151    indexed_measurements: Vec<(usize, &'a MeasurementData)>,
152    summarized_measurements: Vec<(usize, String, Option<String>, MeasurementSummary)>,
153}
154
155impl CsvReporter<'_> {
156    fn new() -> Self {
157        CsvReporter {
158            hashes: Vec::new(),
159            indexed_measurements: Vec::new(),
160            summarized_measurements: Vec::new(),
161        }
162    }
163}
164
165impl<'a> Reporter<'a> for CsvReporter<'a> {
166    fn add_commits(&mut self, hashes: &'a [Commit]) {
167        self.hashes = hashes.iter().map(|c| c.commit.to_owned()).collect();
168    }
169
170    fn add_trace(
171        &mut self,
172        indexed_measurements: Vec<(usize, &'a MeasurementData)>,
173        _measurement_name: &str,
174        _group_value: Option<&String>,
175    ) {
176        self.indexed_measurements
177            .extend_from_slice(indexed_measurements.as_slice());
178    }
179
180    fn as_bytes(&self) -> Vec<u8> {
181        let mut lines = Vec::new();
182
183        // Raw measurements
184        lines.extend(
185            self.indexed_measurements
186                .iter()
187                .map(|(index, measurement_data)| {
188                    let ser_measurement = serialize_single(measurement_data, "\t");
189                    let commit = &self.hashes[*index];
190                    format!("{commit}{DELIMITER}{ser_measurement}")
191                }),
192        );
193
194        // Summarized measurements: synthesize a MeasurementData so we can reuse serialize_single
195        lines.extend(self.summarized_measurements.iter().map(
196            |(index, measurement_name, group_value, summary)| {
197                let mut key_values = std::collections::HashMap::new();
198                if let Some(gv) = group_value.as_ref() {
199                    key_values.insert("group".to_string(), gv.clone());
200                }
201                let synthesized = MeasurementData {
202                    epoch: summary.epoch,
203                    name: measurement_name.clone(),
204                    timestamp: 0.0,
205                    val: summary.val,
206                    key_values,
207                };
208                let ser_measurement = serialize_single(&synthesized, "\t");
209                let commit = &self.hashes[*index];
210                format!("{commit}{DELIMITER}{ser_measurement}")
211            },
212        ));
213
214        lines.join("").into_bytes()
215    }
216
217    fn add_summarized_trace(
218        &mut self,
219        _indexed_measurements: Vec<(usize, MeasurementSummary)>,
220        _measurement_name: &str,
221        _group_value: Option<&String>,
222    ) {
223        // Store summarized data to be serialized in as_bytes
224        for (index, summary) in _indexed_measurements.into_iter() {
225            self.summarized_measurements.push((
226                index,
227                _measurement_name.to_string(),
228                _group_value.cloned(),
229                summary,
230            ));
231        }
232    }
233}
234
235struct ReporterFactory {}
236
237impl ReporterFactory {
238    fn from_file_name(path: &Path) -> Option<Box<dyn Reporter<'_> + '_>> {
239        if path == Path::new("-") {
240            return Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>);
241        }
242        let mut res = None;
243        if let Some(ext) = path.extension() {
244            let extension = ext.to_ascii_lowercase().into_string().unwrap();
245            res = match extension.as_str() {
246                "html" => Some(Box::new(PlotlyReporter::new()) as Box<dyn Reporter<'_>>),
247                "csv" => Some(Box::new(CsvReporter::new()) as Box<dyn Reporter<'_>>),
248                _ => None,
249            }
250        }
251        res
252    }
253}
254
255pub fn report(
256    output: PathBuf,
257    separate_by: Option<String>,
258    num_commits: usize,
259    measurement_names: &[String],
260    key_values: &[(String, String)],
261    aggregate_by: Option<ReductionFunc>,
262) -> Result<()> {
263    let commits: Vec<Commit> = measurement_retrieval::walk_commits(num_commits)?.try_collect()?;
264
265    if commits.is_empty() {
266        bail!(
267            "No commits found in repository. Ensure commits exist and were pushed to the remote."
268        );
269    }
270
271    let mut plot =
272        ReporterFactory::from_file_name(&output).ok_or(anyhow!("Could not infer output format"))?;
273
274    plot.add_commits(&commits);
275
276    let relevant = |m: &MeasurementData| {
277        if !measurement_names.is_empty() && !measurement_names.contains(&m.name) {
278            return false;
279        }
280        // Filter using subset relation: key_values ⊆ measurement.key_values
281        m.key_values_is_superset_of(key_values)
282    };
283
284    let relevant_measurements = commits
285        .iter()
286        .map(|commit| commit.measurements.iter().filter(|m| relevant(m)));
287
288    let unique_measurement_names: Vec<_> = relevant_measurements
289        .clone()
290        .flat_map(|m| m.map(|m| &m.name))
291        .unique()
292        .collect();
293
294    if unique_measurement_names.is_empty() {
295        bail!("No performance measurements found.")
296    }
297
298    for measurement_name in unique_measurement_names {
299        let filtered_measurements = relevant_measurements
300            .clone()
301            .map(|ms| ms.filter(|m| m.name == *measurement_name));
302
303        let group_values = if let Some(separate_by) = &separate_by {
304            filtered_measurements
305                .clone()
306                .flat_map(|ms| {
307                    ms.flat_map(|m| {
308                        m.key_values
309                            .iter()
310                            .filter(|(k, _v)| *k == separate_by)
311                            .map(|(_k, v)| v)
312                    })
313                })
314                .unique()
315                .map(|val| (Some(separate_by), Some(val)))
316                .collect_vec()
317        } else {
318            vec![(None, None)]
319        };
320
321        if group_values.is_empty() {
322            bail!("Invalid separator supplied, no measurements.")
323        }
324
325        for (group_key, group_value) in group_values {
326            let group_measurements = filtered_measurements.clone().map(|ms| {
327                ms.filter(|m| {
328                    group_key
329                        .map(|gk| m.key_values.get(gk) == group_value)
330                        .unwrap_or(true)
331                })
332            });
333
334            if let Some(reduction_func) = aggregate_by {
335                let trace_measurements = group_measurements
336                    .clone()
337                    .enumerate()
338                    .flat_map(move |(i, ms)| {
339                        ms.reduce_by(reduction_func)
340                            .into_iter()
341                            .map(move |m| (i, m))
342                    })
343                    .collect_vec();
344                plot.add_summarized_trace(trace_measurements, measurement_name, group_value);
345            } else {
346                let trace_measurements: Vec<_> = group_measurements
347                    .clone()
348                    .enumerate()
349                    .flat_map(|(i, ms)| ms.map(move |m| (i, m)))
350                    .collect();
351                plot.add_trace(trace_measurements, measurement_name, group_value);
352            }
353        }
354    }
355
356    if output == Path::new("-") {
357        match io::stdout().write_all(&plot.as_bytes()) {
358            Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()),
359            res => res,
360        }?;
361    } else {
362        File::create(&output)?.write_all(&plot.as_bytes())?;
363    }
364
365    Ok(())
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    #[test]
373    fn test_convert_to_x_y_empty() {
374        let reporter = PlotlyReporter::new();
375        let (x, y) = reporter.convert_to_x_y(vec![]);
376        assert!(x.is_empty());
377        assert!(y.is_empty());
378    }
379
380    #[test]
381    fn test_convert_to_x_y_single_value() {
382        let mut reporter = PlotlyReporter::new();
383        reporter.size = 3;
384        let (x, y) = reporter.convert_to_x_y(vec![(0, 1.5)]);
385        assert_eq!(x, vec![2]);
386        assert_eq!(y, vec![1.5]);
387    }
388
389    #[test]
390    fn test_convert_to_x_y_multiple_values() {
391        let mut reporter = PlotlyReporter::new();
392        reporter.size = 5;
393        let (x, y) = reporter.convert_to_x_y(vec![(0, 10.0), (2, 20.0), (4, 30.0)]);
394        assert_eq!(x, vec![4, 2, 0]);
395        assert_eq!(y, vec![10.0, 20.0, 30.0]);
396    }
397
398    #[test]
399    fn test_convert_to_x_y_negative_values() {
400        let mut reporter = PlotlyReporter::new();
401        reporter.size = 2;
402        let (x, y) = reporter.convert_to_x_y(vec![(0, -5.5), (1, -10.2)]);
403        assert_eq!(x, vec![1, 0]);
404        assert_eq!(y, vec![-5.5, -10.2]);
405    }
406
407    #[test]
408    fn test_plotly_reporter_as_bytes_not_empty() {
409        let reporter = PlotlyReporter::new();
410        let bytes = reporter.as_bytes();
411        assert!(!bytes.is_empty());
412        // HTML output should contain plotly-related content
413        let html = String::from_utf8_lossy(&bytes);
414        assert!(html.contains("plotly") || html.contains("Plotly"));
415    }
416
417    #[test]
418    fn test_reporter_factory_html_extension() {
419        let path = Path::new("output.html");
420        let reporter = ReporterFactory::from_file_name(path);
421        assert!(reporter.is_some());
422    }
423
424    #[test]
425    fn test_reporter_factory_csv_extension() {
426        let path = Path::new("output.csv");
427        let reporter = ReporterFactory::from_file_name(path);
428        assert!(reporter.is_some());
429    }
430
431    #[test]
432    fn test_reporter_factory_stdout() {
433        let path = Path::new("-");
434        let reporter = ReporterFactory::from_file_name(path);
435        assert!(reporter.is_some());
436    }
437
438    #[test]
439    fn test_reporter_factory_unsupported_extension() {
440        let path = Path::new("output.txt");
441        let reporter = ReporterFactory::from_file_name(path);
442        assert!(reporter.is_none());
443    }
444
445    #[test]
446    fn test_reporter_factory_no_extension() {
447        let path = Path::new("output");
448        let reporter = ReporterFactory::from_file_name(path);
449        assert!(reporter.is_none());
450    }
451
452    #[test]
453    fn test_reporter_factory_uppercase_extension() {
454        let path = Path::new("output.HTML");
455        let reporter = ReporterFactory::from_file_name(path);
456        assert!(reporter.is_some());
457    }
458
459    #[test]
460    fn test_csv_reporter_as_bytes_empty_on_init() {
461        let reporter = CsvReporter::new();
462        let bytes = reporter.as_bytes();
463        // Empty reporter should produce empty bytes
464        assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
465    }
466}