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 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 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 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 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 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 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 assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
465 }
466}