1use std::{
2 collections::HashMap,
3 fs::{self, File},
4 io::{self, ErrorKind, Write},
5 path::{Path, PathBuf},
6};
7
8use anyhow::anyhow;
9use anyhow::{bail, Result};
10use chrono::Utc;
11use itertools::Itertools;
12use plotly::{
13 common::{DashType, Font, LegendGroupTitle, Line, Mode, Title, Visible},
14 layout::{Axis, Legend},
15 Configuration, Layout, Plot, Scatter,
16};
17
18use crate::{
19 change_point::{ChangeDirection, ChangePoint, EpochTransition},
20 config,
21 data::{Commit, MeasurementData, MeasurementSummary},
22 measurement_retrieval::{self, MeasurementReducer},
23 reporting_config::{parse_template_sections, SectionConfig},
24 stats::ReductionFunc,
25};
26
27pub use crate::reporting_config::ReportTemplateConfig;
29
30#[derive(Clone)]
32struct ReportMetadata {
33 title: String,
34 custom_css: String,
35 timestamp: String,
36 commit_range: String,
37 depth: usize,
38}
39
40impl ReportMetadata {
41 fn new(
42 title: Option<String>,
43 custom_css_content: String,
44 commits: &[Commit],
45 ) -> ReportMetadata {
46 let timestamp = Utc::now().format("%Y-%m-%d %H:%M:%S UTC").to_string();
47
48 let commit_range = if commits.is_empty() {
49 "No commits".to_string()
50 } else if commits.len() == 1 {
51 commits[0].commit[..7].to_string()
52 } else {
53 format!(
54 "{}..{}",
55 &commits.last().unwrap().commit[..7],
56 &commits[0].commit[..7]
57 )
58 };
59
60 let depth = commits.len();
61
62 let default_title = "Performance Measurements".to_string();
63 let title = title.unwrap_or(default_title);
64
65 ReportMetadata {
66 title,
67 custom_css: custom_css_content,
68 timestamp,
69 commit_range,
70 depth,
71 }
72 }
73}
74
75struct ChangePointDetectionParams<'a> {
77 commit_indices: &'a [usize],
78 values: &'a [f64],
79 epochs: &'a [u32],
80 commit_shas: &'a [String],
81 measurement_name: &'a str,
82 group_values: &'a [String],
83 show_epochs: bool,
84 show_changes: bool,
85}
86
87fn extract_plotly_parts(plot: &Plot) -> (String, String) {
97 let plotly_head = Plot::online_cdn_js();
100
101 let plotly_body = plot.to_inline_html(None);
105
106 (plotly_head, plotly_body)
107}
108
109fn load_template(template_path: &Path) -> Result<String> {
111 if !template_path.exists() {
112 bail!("Template file not found: {}", template_path.display());
113 }
114
115 let template_content = fs::read_to_string(template_path).map_err(|e| {
116 anyhow!(
117 "Failed to read template file {}: {}",
118 template_path.display(),
119 e
120 )
121 })?;
122
123 Ok(template_content)
124}
125
126fn load_custom_css(custom_css_path: Option<&PathBuf>) -> Result<String> {
128 let css_path = match custom_css_path {
129 Some(path) => path.clone(),
130 None => {
131 if let Some(config_path) = config::report_custom_css_path() {
133 config_path
134 } else {
135 return Ok(String::new());
137 }
138 }
139 };
140
141 if !css_path.exists() {
142 bail!("Custom CSS file not found: {}", css_path.display());
143 }
144
145 fs::read_to_string(&css_path).map_err(|e| {
146 anyhow!(
147 "Failed to read custom CSS file {}: {}",
148 css_path.display(),
149 e
150 )
151 })
152}
153
154const DEFAULT_COMMIT_HASH_DISPLAY_LENGTH: usize = 6;
159
160const REGRESSION_COLOR: &str = "rgba(220, 53, 69, 0.8)";
164
165const IMPROVEMENT_COLOR: &str = "rgba(40, 167, 69, 0.8)";
168
169const EPOCH_MARKER_COLOR: &str = "gray";
171
172const EPOCH_MARKER_LINE_WIDTH: f64 = 2.0;
175
176const DEFAULT_HTML_TEMPLATE: &str = r#"<!DOCTYPE html>
180<html>
181<head>
182 <meta charset="utf-8">
183 <title>{{TITLE}}</title>
184 {{PLOTLY_HEAD}}
185 <style>{{CUSTOM_CSS}}</style>
186</head>
187<body>
188 {{PLOTLY_BODY}}
189</body>
190</html>"#;
191
192fn write_output(output_path: &Path, bytes: &[u8]) -> Result<()> {
197 if output_path == Path::new("-") {
198 match io::stdout().write_all(bytes) {
200 Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()),
201 res => res,
202 }
203 } else {
204 File::create(output_path)?.write_all(bytes)
206 }?;
207 Ok(())
208}
209
210fn format_measurement_with_unit(measurement_name: &str) -> String {
213 match config::measurement_unit(measurement_name) {
214 Some(unit) => format!("{} ({})", measurement_name, unit),
215 None => measurement_name.to_string(),
216 }
217}
218
219#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221enum OutputFormat {
222 Html,
223 Csv,
224}
225
226impl OutputFormat {
227 fn from_path(path: &Path) -> Option<OutputFormat> {
229 if path == Path::new("-") {
230 return Some(OutputFormat::Csv);
231 }
232
233 path.extension()
234 .and_then(|ext| ext.to_str())
235 .and_then(|ext_str| match ext_str.to_ascii_lowercase().as_str() {
236 "html" => Some(OutputFormat::Html),
237 "csv" => Some(OutputFormat::Csv),
238 _ => None,
239 })
240 }
241}
242
243struct CsvMeasurementRow {
246 commit: String,
247 epoch: u32,
248 measurement: String,
249 timestamp: f64,
250 value: f64,
251 unit: String,
252 metadata: HashMap<String, String>,
253}
254
255impl CsvMeasurementRow {
256 fn from_measurement(commit: &str, measurement: &MeasurementData) -> Self {
258 let unit = config::measurement_unit(&measurement.name).unwrap_or_default();
259 CsvMeasurementRow {
260 commit: commit.to_string(),
261 epoch: measurement.epoch,
262 measurement: measurement.name.clone(),
263 timestamp: measurement.timestamp,
264 value: measurement.val,
265 unit,
266 metadata: measurement.key_values.clone(),
267 }
268 }
269
270 fn from_summary(
272 commit: &str,
273 measurement_name: &str,
274 summary: &MeasurementSummary,
275 group_value: Option<&String>,
276 ) -> Self {
277 let unit = config::measurement_unit(measurement_name).unwrap_or_default();
278 let mut metadata = HashMap::new();
279 if let Some(gv) = group_value {
280 metadata.insert("group".to_string(), gv.clone());
281 }
282 CsvMeasurementRow {
283 commit: commit.to_string(),
284 epoch: summary.epoch,
285 measurement: measurement_name.to_string(),
286 timestamp: 0.0,
287 value: summary.val,
288 unit,
289 metadata,
290 }
291 }
292
293 fn to_csv_line(&self) -> String {
296 let value_str = if self.value.fract() == 0.0 && self.value.is_finite() {
299 format!("{:.1}", self.value)
300 } else {
301 self.value.to_string()
302 };
303
304 let timestamp_str = if self.timestamp.fract() == 0.0 && self.timestamp.is_finite() {
305 format!("{:.1}", self.timestamp)
306 } else {
307 self.timestamp.to_string()
308 };
309
310 let mut line = format!(
311 "{}\t{}\t{}\t{}\t{}\t{}",
312 self.commit, self.epoch, self.measurement, timestamp_str, value_str, self.unit
313 );
314
315 for (k, v) in &self.metadata {
317 line.push('\t');
318 line.push_str(k);
319 line.push('=');
320 line.push_str(v);
321 }
322
323 line
324 }
325}
326
327struct SectionOutput {
332 #[allow(dead_code)]
333 section_id: String,
334 placeholder: String, content: Vec<u8>, }
337
338trait Reporter<'a> {
339 fn add_commits(&mut self, hashes: &'a [Commit]);
340
341 fn begin_section(&mut self, section_id: &str, placeholder: &str);
343 fn end_section(&mut self) -> Result<SectionOutput>;
344 fn finalize(
345 self: Box<Self>,
346 sections: Vec<SectionOutput>,
347 metadata: &ReportMetadata,
348 ) -> Vec<u8>;
349
350 fn add_trace(
352 &mut self,
353 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
354 measurement_name: &str,
355 group_values: &[String],
356 );
357 fn add_summarized_trace(
358 &mut self,
359 indexed_measurements: Vec<(usize, MeasurementSummary)>,
360 measurement_name: &str,
361 group_values: &[String],
362 );
363 fn add_epoch_boundaries(
364 &mut self,
365 transitions: &[EpochTransition],
366 commit_indices: &[usize],
367 measurement_name: &str,
368 group_values: &[String],
369 y_min: f64,
370 y_max: f64,
371 );
372 fn add_change_points(
373 &mut self,
374 change_points: &[ChangePoint],
375 values: &[f64],
376 commit_indices: &[usize],
377 measurement_name: &str,
378 group_values: &[String],
379 );
380
381 #[allow(dead_code)]
383 fn as_bytes(&self) -> Vec<u8>;
384}
385
386struct PlotlyReporter {
387 all_commits: Vec<Commit>,
389 size: usize,
394 template: Option<String>,
395 #[allow(dead_code)]
396 metadata: Option<ReportMetadata>,
397
398 current_section_id: Option<String>,
400 current_placeholder: Option<String>,
401 current_plot: Plot,
402 measurement_units: Vec<Option<String>>,
404}
405
406impl PlotlyReporter {
407 #[allow(dead_code)]
408 fn new() -> PlotlyReporter {
409 let config = Configuration::default().responsive(true).fill_frame(false);
410 let mut plot = Plot::new();
411 plot.set_configuration(config);
412 PlotlyReporter {
413 all_commits: Vec::new(),
414 size: 0,
415 template: None,
416 metadata: None,
417 current_section_id: None,
418 current_placeholder: None,
419 current_plot: plot,
420 measurement_units: Vec::new(),
421 }
422 }
423
424 fn with_template(template: String, metadata: ReportMetadata) -> PlotlyReporter {
425 let config = Configuration::default().responsive(true).fill_frame(false);
426 let mut plot = Plot::new();
427 plot.set_configuration(config);
428 PlotlyReporter {
429 all_commits: Vec::new(),
430 size: 0,
431 template: Some(template),
432 metadata: Some(metadata),
433 current_section_id: None,
434 current_placeholder: None,
435 current_plot: plot,
436 measurement_units: Vec::new(),
437 }
438 }
439
440 fn convert_to_x_y(&self, indexed_measurements: Vec<(usize, f64)>) -> (Vec<usize>, Vec<f64>) {
441 indexed_measurements
442 .iter()
443 .map(|(i, m)| (self.size - i - 1, *m))
444 .unzip()
445 }
446
447 fn compute_y_axis(&self) -> Option<Axis> {
449 if self.measurement_units.is_empty() {
451 return None;
452 }
453
454 let first_unit = self.measurement_units.first();
455 let all_same_unit = self
456 .measurement_units
457 .iter()
458 .all(|u| u == first_unit.unwrap());
459
460 if all_same_unit {
461 if let Some(Some(unit)) = first_unit {
462 return Some(Axis::new().title(Title::from(format!("Value ({})", unit))));
464 }
465 }
466 None
467 }
468
469 fn add_vertical_line_segment(
473 x_coords: &mut Vec<Option<usize>>,
474 y_coords: &mut Vec<Option<f64>>,
475 hover_texts: &mut Vec<String>,
476 x_pos: usize,
477 y_min: f64,
478 y_max: f64,
479 hover_text: String,
480 ) {
481 x_coords.push(Some(x_pos));
483 y_coords.push(Some(y_min));
484 hover_texts.push(hover_text.clone());
485
486 x_coords.push(Some(x_pos));
488 y_coords.push(Some(y_max));
489 hover_texts.push(hover_text);
490
491 x_coords.push(None);
493 y_coords.push(None);
494 hover_texts.push(String::new());
495 }
496
497 fn configure_trace_legend<X, Y>(
502 trace: Box<Scatter<X, Y>>,
503 group_values: &[String],
504 measurement_name: &str,
505 measurement_display: &str,
506 label_suffix: &str,
507 legend_group_suffix: &str,
508 ) -> Box<Scatter<X, Y>>
509 where
510 X: serde::Serialize + Clone,
511 Y: serde::Serialize + Clone,
512 {
513 if !group_values.is_empty() {
514 let group_label = group_values.join("/");
515 trace
516 .name(format!("{} ({})", group_label, label_suffix))
517 .legend_group(format!("{}_{}", measurement_name, legend_group_suffix))
518 .legend_group_title(LegendGroupTitle::from(
519 format!("{} - {}", measurement_display, label_suffix).as_str(),
520 ))
521 } else {
522 trace
523 .name(format!("{} ({})", measurement_display, label_suffix))
524 .legend_group(format!("{}_{}", measurement_name, legend_group_suffix))
525 }
526 }
527
528 fn process_vertical_marker(
532 &self,
533 index: usize,
534 commit_indices: &[usize],
535 measurement_name: &str,
536 marker_type: &str,
537 ) -> Result<usize, ()> {
538 if index >= commit_indices.len() {
539 log::warn!(
540 "[{}] {} index {} out of bounds (max: {})",
541 measurement_name,
542 marker_type,
543 index,
544 commit_indices.len()
545 );
546 return Err(());
547 }
548 let commit_idx = commit_indices[index];
549 let x_pos = self.size - commit_idx - 1;
550 Ok(x_pos)
551 }
552
553 pub fn add_epoch_boundary_traces(
560 &mut self,
561 transitions: &[EpochTransition],
562 commit_indices: &[usize],
563 measurement_name: &str,
564 group_values: &[String],
565 y_min: f64,
566 y_max: f64,
567 ) {
568 if transitions.is_empty() {
569 return;
570 }
571
572 let mut x_coords: Vec<Option<usize>> = vec![];
573 let mut y_coords: Vec<Option<f64>> = vec![];
574 let mut hover_texts: Vec<String> = vec![];
575
576 for transition in transitions {
577 let x_pos = match self.process_vertical_marker(
578 transition.index,
579 commit_indices,
580 measurement_name,
581 "Epoch transition",
582 ) {
583 Ok(pos) => pos,
584 Err(()) => continue,
585 };
586
587 let hover_text = format!("Epoch {}→{}", transition.from_epoch, transition.to_epoch);
588
589 Self::add_vertical_line_segment(
590 &mut x_coords,
591 &mut y_coords,
592 &mut hover_texts,
593 x_pos,
594 y_min,
595 y_max,
596 hover_text,
597 );
598 }
599
600 let measurement_display = format_measurement_with_unit(measurement_name);
601
602 let trace = Scatter::new(x_coords, y_coords)
603 .visible(Visible::LegendOnly)
604 .mode(Mode::Lines)
605 .line(
606 Line::new()
607 .color(EPOCH_MARKER_COLOR)
608 .dash(DashType::Dash)
609 .width(EPOCH_MARKER_LINE_WIDTH),
610 )
611 .show_legend(true)
612 .hover_text_array(hover_texts);
613
614 let trace = Self::configure_trace_legend(
615 trace,
616 group_values,
617 measurement_name,
618 &measurement_display,
619 "Epochs",
620 "epochs",
621 );
622
623 self.current_plot.add_trace(trace);
624 }
625
626 pub fn add_change_point_traces_with_indices(
631 &mut self,
632 change_points: &[ChangePoint],
633 values: &[f64],
634 commit_indices: &[usize],
635 measurement_name: &str,
636 group_values: &[String],
637 ) {
638 if change_points.is_empty() {
639 return;
640 }
641
642 let measurement_display = format_measurement_with_unit(measurement_name);
643
644 let mut x_coords: Vec<usize> = vec![];
646 let mut y_coords: Vec<f64> = vec![];
647 let mut hover_texts: Vec<String> = vec![];
648 let mut marker_colors: Vec<String> = vec![];
649
650 for cp in change_points {
651 let x_pos = match self.process_vertical_marker(
652 cp.index,
653 commit_indices,
654 measurement_name,
655 "Change point",
656 ) {
657 Ok(pos) => pos,
658 Err(()) => continue,
659 };
660
661 let y_value = if cp.index < values.len() {
663 values[cp.index]
664 } else {
665 log::warn!(
666 "Change point index {} out of bounds for values (len={})",
667 cp.index,
668 values.len()
669 );
670 continue;
671 };
672
673 let (color, symbol) = match cp.direction {
674 ChangeDirection::Increase => (REGRESSION_COLOR, "⚠ Regression"),
675 ChangeDirection::Decrease => (IMPROVEMENT_COLOR, "✓ Improvement"),
676 };
677
678 let (author, title) = self
680 .all_commits
681 .get(cp.index)
682 .map(|c| (c.author.as_str(), c.title.as_str()))
683 .unwrap_or(("Unknown", "Unknown"));
684
685 let hover_text = format!(
686 "{}: {:+.1}%<br>Commit: {}<br>Author: {}<br>Title: {}<br>Confidence: {:.1}%",
687 symbol,
688 cp.magnitude_pct,
689 &cp.commit_sha[..8.min(cp.commit_sha.len())],
690 author,
691 title,
692 cp.confidence * 100.0
693 );
694
695 x_coords.push(x_pos);
697 y_coords.push(y_value);
698 hover_texts.push(hover_text);
699 marker_colors.push(color.to_string());
700 }
701
702 let trace = Scatter::new(x_coords, y_coords)
703 .mode(Mode::Markers)
704 .marker(
705 plotly::common::Marker::new()
706 .color_array(marker_colors)
707 .size(12),
708 )
709 .show_legend(true)
710 .hover_text_array(hover_texts);
711
712 let trace = Self::configure_trace_legend(
713 trace,
714 group_values,
715 measurement_name,
716 &measurement_display,
717 "Change Points",
718 "change_points",
719 );
720
721 self.current_plot.add_trace(trace);
722 }
723
724 fn prepare_hover_text(&self, indices: impl Iterator<Item = usize>) -> Vec<String> {
740 indices
741 .map(|idx| {
742 if let Some(commit) = self.all_commits.get(idx) {
744 format!(
745 "Commit: {}<br>Author: {}<br>Title: {}",
746 &commit.commit[..7.min(commit.commit.len())],
747 commit.author,
748 commit.title
749 )
750 } else {
751 format!("Commit index: {}", idx)
753 }
754 })
755 .collect()
756 }
757}
758
759impl<'a> Reporter<'a> for PlotlyReporter {
760 fn add_commits(&mut self, commits: &'a [Commit]) {
761 self.all_commits = commits.to_vec();
763 self.size = commits.len();
764 }
765
766 fn begin_section(&mut self, section_id: &str, placeholder: &str) {
767 self.current_section_id = Some(section_id.to_string());
768 self.current_placeholder = Some(placeholder.to_string());
769
770 let config = Configuration::default().responsive(true).fill_frame(false);
772 let mut plot = Plot::new();
773 plot.set_configuration(config);
774
775 let enumerated_commits = self.all_commits.iter().rev().enumerate();
777 let (commit_nrs, short_hashes): (Vec<_>, Vec<_>) = enumerated_commits
778 .map(|(n, c)| {
779 (
780 n as f64,
781 c.commit[..DEFAULT_COMMIT_HASH_DISPLAY_LENGTH].to_owned(),
782 )
783 })
784 .unzip();
785 let x_axis = Axis::new()
786 .tick_values(commit_nrs)
787 .tick_text(short_hashes)
788 .tick_angle(45.0)
789 .tick_font(Font::new().family("monospace"));
790 let layout = Layout::new()
791 .title(Title::from("Performance Measurements"))
792 .x_axis(x_axis)
793 .legend(
794 Legend::new()
795 .group_click(plotly::layout::GroupClick::ToggleItem)
796 .orientation(plotly::common::Orientation::Horizontal),
797 );
798
799 plot.set_layout(layout);
800 self.current_plot = plot;
801 self.measurement_units.clear();
802 }
803
804 fn end_section(&mut self) -> Result<SectionOutput> {
805 let section_id = self
806 .current_section_id
807 .take()
808 .ok_or_else(|| anyhow!("end_section called without begin_section"))?;
809
810 let placeholder = self
811 .current_placeholder
812 .take()
813 .ok_or_else(|| anyhow!("end_section called without placeholder"))?;
814
815 let final_plot = if let Some(y_axis) = self.compute_y_axis() {
817 let mut plot_with_y_axis = self.current_plot.clone();
818 let mut layout = plot_with_y_axis.layout().clone();
819 layout = layout.y_axis(y_axis);
820 plot_with_y_axis.set_layout(layout);
821 plot_with_y_axis
822 } else {
823 self.current_plot.clone()
824 };
825
826 let (_plotly_head, plotly_body) = extract_plotly_parts(&final_plot);
828
829 Ok(SectionOutput {
830 section_id,
831 placeholder,
832 content: plotly_body.into_bytes(),
833 })
834 }
835
836 fn finalize(
837 self: Box<Self>,
838 sections: Vec<SectionOutput>,
839 metadata: &ReportMetadata,
840 ) -> Vec<u8> {
841 if let Some(template) = self.template {
843 let mut output = template;
844
845 for section in §ions {
847 output = output.replace(
848 §ion.placeholder,
849 &String::from_utf8_lossy(§ion.content),
850 );
851 }
852
853 let (plotly_head, _) = extract_plotly_parts(&Plot::new());
855 output = output
856 .replace("{{TITLE}}", &metadata.title)
857 .replace("{{PLOTLY_HEAD}}", &plotly_head)
858 .replace("{{CUSTOM_CSS}}", &metadata.custom_css)
859 .replace("{{TIMESTAMP}}", &metadata.timestamp)
860 .replace("{{COMMIT_RANGE}}", &metadata.commit_range)
861 .replace("{{DEPTH}}", &metadata.depth.to_string())
862 .replace("{{AUDIT_SECTION}}", "");
863
864 output.into_bytes()
865 } else {
866 if sections.len() != 1 {
868 panic!("Multiple sections require template");
869 }
870 sections[0].content.clone()
871 }
872 }
873
874 fn add_trace(
875 &mut self,
876 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
877 measurement_name: &str,
878 group_values: &[String],
879 ) {
880 let indices: Vec<usize> = indexed_measurements.iter().map(|(i, _)| *i).collect();
882
883 let (x, y) = self.convert_to_x_y(
884 indexed_measurements
885 .into_iter()
886 .map(|(i, m)| (i, m.val))
887 .collect_vec(),
888 );
889
890 self.measurement_units
892 .push(config::measurement_unit(measurement_name));
893
894 let measurement_display = format_measurement_with_unit(measurement_name);
895
896 let hover_texts = self.prepare_hover_text(indices.into_iter());
898
899 let trace = plotly::BoxPlot::new_xy(x, y).hover_text_array(hover_texts);
900
901 let trace = if !group_values.is_empty() {
902 let group_label = group_values.join("/");
904 trace
905 .name(&group_label)
906 .legend_group(measurement_name)
907 .legend_group_title(LegendGroupTitle::from(measurement_display))
908 .show_legend(true)
909 } else {
910 trace.name(&measurement_display)
911 };
912
913 self.current_plot.add_trace(trace);
914 }
915
916 fn add_summarized_trace(
917 &mut self,
918 indexed_measurements: Vec<(usize, MeasurementSummary)>,
919 measurement_name: &str,
920 group_values: &[String],
921 ) {
922 let indices: Vec<usize> = indexed_measurements.iter().map(|(i, _)| *i).collect();
924
925 let (x, y) = self.convert_to_x_y(
926 indexed_measurements
927 .into_iter()
928 .map(|(i, m)| (i, m.val))
929 .collect_vec(),
930 );
931
932 self.measurement_units
934 .push(config::measurement_unit(measurement_name));
935
936 let measurement_display = format_measurement_with_unit(measurement_name);
937
938 let hover_texts = self.prepare_hover_text(indices.into_iter());
940
941 let trace = plotly::Scatter::new(x, y)
942 .name(&measurement_display)
943 .hover_text_array(hover_texts)
944 .hover_info(plotly::common::HoverInfo::Text);
945
946 let trace = if !group_values.is_empty() {
947 let group_label = group_values.join("/");
949 trace
950 .name(&group_label)
951 .legend_group(measurement_name)
952 .legend_group_title(LegendGroupTitle::from(measurement_display))
953 .show_legend(true)
954 } else {
955 trace.name(&measurement_display)
956 };
957
958 self.current_plot.add_trace(trace);
959 }
960
961 fn add_epoch_boundaries(
962 &mut self,
963 transitions: &[EpochTransition],
964 commit_indices: &[usize],
965 measurement_name: &str,
966 group_values: &[String],
967 y_min: f64,
968 y_max: f64,
969 ) {
970 self.add_epoch_boundary_traces(
971 transitions,
972 commit_indices,
973 measurement_name,
974 group_values,
975 y_min,
976 y_max,
977 );
978 }
979
980 fn add_change_points(
981 &mut self,
982 change_points: &[ChangePoint],
983 values: &[f64],
984 commit_indices: &[usize],
985 measurement_name: &str,
986 group_values: &[String],
987 ) {
988 self.add_change_point_traces_with_indices(
989 change_points,
990 values,
991 commit_indices,
992 measurement_name,
993 group_values,
994 );
995 }
996
997 fn as_bytes(&self) -> Vec<u8> {
998 let final_plot = if let Some(y_axis) = self.compute_y_axis() {
1000 let mut plot_with_y_axis = self.current_plot.clone();
1001 let mut layout = plot_with_y_axis.layout().clone();
1002 layout = layout.y_axis(y_axis);
1003 plot_with_y_axis.set_layout(layout);
1004 plot_with_y_axis
1005 } else {
1006 self.current_plot.clone()
1007 };
1008
1009 let template = self.template.as_deref().unwrap_or(DEFAULT_HTML_TEMPLATE);
1012
1013 let default_metadata = ReportMetadata {
1015 title: "Performance Measurements".to_string(),
1016 custom_css: String::new(),
1017 timestamp: String::new(),
1018 commit_range: String::new(),
1019 depth: 0,
1020 };
1021 let metadata = self.metadata.as_ref().unwrap_or(&default_metadata);
1022
1023 let (plotly_head, plotly_body) = extract_plotly_parts(&final_plot);
1025 let output = template
1026 .replace("{{TITLE}}", &metadata.title)
1027 .replace("{{PLOTLY_HEAD}}", &plotly_head)
1028 .replace("{{PLOTLY_BODY}}", &plotly_body)
1029 .replace("{{CUSTOM_CSS}}", &metadata.custom_css)
1030 .replace("{{TIMESTAMP}}", &metadata.timestamp)
1031 .replace("{{COMMIT_RANGE}}", &metadata.commit_range)
1032 .replace("{{DEPTH}}", &metadata.depth.to_string())
1033 .replace("{{AUDIT_SECTION}}", ""); output.as_bytes().to_vec()
1036 }
1037}
1038
1039struct CsvReporter<'a> {
1040 hashes: Vec<String>,
1041 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
1042 summarized_measurements: Vec<(usize, String, Option<String>, MeasurementSummary)>,
1043}
1044
1045impl CsvReporter<'_> {
1046 fn new() -> Self {
1047 CsvReporter {
1048 hashes: Vec::new(),
1049 indexed_measurements: Vec::new(),
1050 summarized_measurements: Vec::new(),
1051 }
1052 }
1053}
1054
1055impl<'a> Reporter<'a> for CsvReporter<'a> {
1056 fn add_commits(&mut self, hashes: &'a [Commit]) {
1057 self.hashes = hashes.iter().map(|c| c.commit.to_owned()).collect();
1058 }
1059
1060 fn begin_section(&mut self, _section_id: &str, _placeholder: &str) {
1061 }
1063
1064 fn end_section(&mut self) -> Result<SectionOutput> {
1065 Ok(SectionOutput {
1068 section_id: "csv".to_string(),
1069 placeholder: String::new(),
1070 content: Vec::new(),
1071 })
1072 }
1073
1074 fn finalize(
1075 self: Box<Self>,
1076 _sections: Vec<SectionOutput>,
1077 _metadata: &ReportMetadata,
1078 ) -> Vec<u8> {
1079 if self.indexed_measurements.is_empty() && self.summarized_measurements.is_empty() {
1082 return Vec::new();
1083 }
1084
1085 let mut lines = Vec::new();
1086 lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
1087
1088 for (index, measurement_data) in &self.indexed_measurements {
1090 let commit = &self.hashes[*index];
1091 let row = CsvMeasurementRow::from_measurement(commit, measurement_data);
1092 lines.push(row.to_csv_line());
1093 }
1094
1095 for (index, measurement_name, group_value, summary) in &self.summarized_measurements {
1097 let commit = &self.hashes[*index];
1098 let row = CsvMeasurementRow::from_summary(
1099 commit,
1100 measurement_name,
1101 summary,
1102 group_value.as_ref(),
1103 );
1104 lines.push(row.to_csv_line());
1105 }
1106
1107 let mut output = lines.join("\n");
1108 output.push('\n');
1109 output.into_bytes()
1110 }
1111
1112 fn add_trace(
1113 &mut self,
1114 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
1115 _measurement_name: &str,
1116 _group_values: &[String],
1117 ) {
1118 self.indexed_measurements
1119 .extend_from_slice(indexed_measurements.as_slice());
1120 }
1121
1122 fn as_bytes(&self) -> Vec<u8> {
1123 if self.indexed_measurements.is_empty() && self.summarized_measurements.is_empty() {
1124 return Vec::new();
1125 }
1126
1127 let mut lines = Vec::new();
1128
1129 lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
1131
1132 for (index, measurement_data) in &self.indexed_measurements {
1134 let commit = &self.hashes[*index];
1135 let row = CsvMeasurementRow::from_measurement(commit, measurement_data);
1136 lines.push(row.to_csv_line());
1137 }
1138
1139 for (index, measurement_name, group_value, summary) in &self.summarized_measurements {
1141 let commit = &self.hashes[*index];
1142 let row = CsvMeasurementRow::from_summary(
1143 commit,
1144 measurement_name,
1145 summary,
1146 group_value.as_ref(),
1147 );
1148 lines.push(row.to_csv_line());
1149 }
1150
1151 let mut output = lines.join("\n");
1152 output.push('\n');
1153 output.into_bytes()
1154 }
1155
1156 fn add_summarized_trace(
1157 &mut self,
1158 _indexed_measurements: Vec<(usize, MeasurementSummary)>,
1159 _measurement_name: &str,
1160 _group_values: &[String],
1161 ) {
1162 let group_label = if !_group_values.is_empty() {
1165 Some(_group_values.join("/"))
1166 } else {
1167 None
1168 };
1169
1170 for (index, summary) in _indexed_measurements.into_iter() {
1171 self.summarized_measurements.push((
1172 index,
1173 _measurement_name.to_string(),
1174 group_label.clone(),
1175 summary,
1176 ));
1177 }
1178 }
1179
1180 fn add_epoch_boundaries(
1181 &mut self,
1182 _transitions: &[EpochTransition],
1183 _commit_indices: &[usize],
1184 _measurement_name: &str,
1185 _group_values: &[String],
1186 _y_min: f64,
1187 _y_max: f64,
1188 ) {
1189 }
1191
1192 fn add_change_points(
1193 &mut self,
1194 _change_points: &[ChangePoint],
1195 _values: &[f64],
1196 _commit_indices: &[usize],
1197 _measurement_name: &str,
1198 _group_values: &[String],
1199 ) {
1200 }
1202}
1203
1204fn compute_group_values_to_process<'a>(
1212 filtered_measurements: impl Iterator<Item = &'a MeasurementData> + Clone,
1213 separate_by: &[String],
1214 context_id: &str, ) -> Result<Vec<Vec<String>>> {
1216 if separate_by.is_empty() {
1217 return Ok(vec![vec![]]);
1218 }
1219
1220 let group_values: Vec<Vec<String>> = filtered_measurements
1221 .filter_map(|m| {
1222 let values: Vec<String> = separate_by
1223 .iter()
1224 .filter_map(|key| m.key_values.get(key).cloned())
1225 .collect();
1226
1227 if values.len() == separate_by.len() {
1228 Some(values)
1229 } else {
1230 None
1231 }
1232 })
1233 .unique()
1234 .collect();
1235
1236 if group_values.is_empty() {
1237 bail!(
1238 "{}: Invalid separator supplied, no measurements have all required keys: {:?}",
1239 context_id,
1240 separate_by
1241 );
1242 }
1243
1244 Ok(group_values)
1245}
1246
1247fn filter_measurements_by_criteria<'a>(
1257 commits: &'a [Commit],
1258 filters: &[regex::Regex],
1259 key_values: &[(String, String)],
1260) -> Vec<Vec<&'a MeasurementData>> {
1261 commits
1262 .iter()
1263 .map(|commit| {
1264 commit
1265 .measurements
1266 .iter()
1267 .filter(|m| {
1268 if !filters.is_empty() && !crate::filter::matches_any_filter(&m.name, filters) {
1270 return false;
1271 }
1272 m.key_values_is_superset_of(key_values)
1274 })
1275 .collect()
1276 })
1277 .collect()
1278}
1279
1280fn collect_measurement_data_for_change_detection<'a>(
1285 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1286 commits: &[Commit],
1287 reduction_func: ReductionFunc,
1288) -> (Vec<usize>, Vec<f64>, Vec<u32>, Vec<String>) {
1289 let measurement_data: Vec<(usize, f64, u32, String)> = group_measurements
1290 .enumerate()
1291 .flat_map(|(i, ms)| {
1292 let commit_sha = commits[i].commit.clone();
1293 ms.reduce_by(reduction_func)
1294 .into_iter()
1295 .map(move |m| (i, m.val, m.epoch, commit_sha.clone()))
1296 })
1297 .collect();
1298
1299 let commit_indices: Vec<usize> = measurement_data.iter().map(|(i, _, _, _)| *i).collect();
1300 let values: Vec<f64> = measurement_data.iter().map(|(_, v, _, _)| *v).collect();
1301 let epochs: Vec<u32> = measurement_data.iter().map(|(_, _, e, _)| *e).collect();
1302 let commit_shas: Vec<String> = measurement_data
1303 .iter()
1304 .map(|(_, _, _, s)| s.clone())
1305 .collect();
1306
1307 (commit_indices, values, epochs, commit_shas)
1308}
1309
1310fn add_trace_for_measurement_group<'a>(
1315 reporter: &mut dyn Reporter<'a>,
1316 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1317 measurement_name: &str,
1318 group_value: &[String],
1319 aggregate_by: Option<ReductionFunc>,
1320) {
1321 if let Some(reduction_func) = aggregate_by {
1322 let trace_measurements = group_measurements
1323 .enumerate()
1324 .flat_map(move |(i, ms)| {
1325 ms.reduce_by(reduction_func)
1326 .into_iter()
1327 .map(move |m| (i, m))
1328 })
1329 .collect_vec();
1330
1331 reporter.add_summarized_trace(trace_measurements, measurement_name, group_value);
1332 } else {
1333 let trace_measurements: Vec<_> = group_measurements
1334 .enumerate()
1335 .flat_map(|(i, ms)| ms.map(move |m| (i, m)))
1336 .collect();
1337
1338 reporter.add_trace(trace_measurements, measurement_name, group_value);
1339 }
1340}
1341
1342struct PreparedDetectionData {
1344 indices: Vec<usize>,
1346 values: Vec<f64>,
1348 epochs: Vec<u32>,
1350 commit_shas: Vec<String>,
1352 y_min: f64,
1354 y_max: f64,
1356}
1357
1358fn prepare_detection_data(params: &ChangePointDetectionParams) -> Option<PreparedDetectionData> {
1367 if params.values.is_empty() {
1368 return None;
1369 }
1370
1371 log::debug!(
1372 "Preparing detection data for {}: {} measurements, indices {:?}, epochs {:?}",
1373 params.measurement_name,
1374 params.values.len(),
1375 params.commit_indices,
1376 params.epochs
1377 );
1378
1379 let y_min = params.values.iter().copied().fold(f64::INFINITY, f64::min) * 0.9;
1381 let y_max = params
1382 .values
1383 .iter()
1384 .copied()
1385 .fold(f64::NEG_INFINITY, f64::max)
1386 * 1.1;
1387
1388 Some(PreparedDetectionData {
1393 indices: params.commit_indices.iter().rev().copied().collect(),
1394 values: params.values.iter().rev().copied().collect(),
1395 epochs: params.epochs.iter().rev().copied().collect(),
1396 commit_shas: params.commit_shas.iter().rev().cloned().collect(),
1397 y_min,
1398 y_max,
1399 })
1400}
1401
1402fn add_epoch_traces(
1406 reporter: &mut dyn Reporter,
1407 params: &ChangePointDetectionParams,
1408 prepared: &PreparedDetectionData,
1409) {
1410 let transitions = crate::change_point::detect_epoch_transitions(&prepared.epochs);
1411 log::debug!(
1412 "Epoch transitions for {}: {:?}",
1413 params.measurement_name,
1414 transitions
1415 );
1416 reporter.add_epoch_boundaries(
1417 &transitions,
1418 &prepared.indices,
1419 params.measurement_name,
1420 params.group_values,
1421 prepared.y_min,
1422 prepared.y_max,
1423 );
1424}
1425
1426fn add_change_point_traces(
1431 reporter: &mut dyn Reporter,
1432 params: &ChangePointDetectionParams,
1433 prepared: &PreparedDetectionData,
1434) {
1435 let config = crate::config::change_point_config(params.measurement_name);
1436 if !config.enabled {
1437 return;
1438 }
1439 let raw_cps = crate::change_point::detect_change_points(&prepared.values, &config);
1440 log::debug!(
1441 "Raw change points for {}: {:?}",
1442 params.measurement_name,
1443 raw_cps
1444 );
1445
1446 let enriched_cps = crate::change_point::enrich_change_points(
1447 &raw_cps,
1448 &prepared.values,
1449 &prepared.commit_shas,
1450 &config,
1451 );
1452 log::debug!(
1453 "Enriched change points for {}: {:?}",
1454 params.measurement_name,
1455 enriched_cps
1456 );
1457
1458 reporter.add_change_points(
1459 &enriched_cps,
1460 &prepared.values,
1461 &prepared.indices,
1462 params.measurement_name,
1463 params.group_values,
1464 );
1465}
1466
1467fn add_change_point_and_epoch_traces(
1477 reporter: &mut dyn Reporter,
1478 params: ChangePointDetectionParams,
1479) {
1480 let Some(prepared) = prepare_detection_data(¶ms) else {
1481 return;
1482 };
1483
1484 if params.show_epochs {
1485 add_epoch_traces(reporter, ¶ms, &prepared);
1486 }
1487
1488 if params.show_changes {
1489 add_change_point_traces(reporter, ¶ms, &prepared);
1490 }
1491}
1492
1493#[allow(clippy::too_many_arguments)]
1511fn add_detection_traces_if_requested<'a>(
1512 reporter: &mut dyn Reporter<'a>,
1513 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1514 commits: &[Commit],
1515 measurement_name: &str,
1516 group_value: &[String],
1517 aggregate_by: Option<ReductionFunc>,
1518 show_epochs: bool,
1519 show_changes: bool,
1520) {
1521 if !show_epochs && !show_changes {
1522 return;
1523 }
1524
1525 let reduction_func = aggregate_by.unwrap_or(ReductionFunc::Min);
1526
1527 let (commit_indices, values, epochs, commit_shas) =
1528 collect_measurement_data_for_change_detection(group_measurements, commits, reduction_func);
1529
1530 let detection_params = ChangePointDetectionParams {
1531 commit_indices: &commit_indices,
1532 values: &values,
1533 epochs: &epochs,
1534 commit_shas: &commit_shas,
1535 measurement_name,
1536 group_values: group_value,
1537 show_epochs,
1538 show_changes,
1539 };
1540
1541 add_change_point_and_epoch_traces(reporter, detection_params);
1542}
1543
1544fn wrap_patterns_for_regex(patterns: &[String]) -> Option<String> {
1547 if patterns.is_empty() {
1548 None
1549 } else {
1550 Some(
1551 patterns
1552 .iter()
1553 .map(|p| format!("(?:{})", p))
1554 .collect::<Vec<_>>()
1555 .join("|"),
1556 )
1557 }
1558}
1559
1560fn build_single_section_config(
1563 combined_patterns: &[String],
1564 key_values: &[(String, String)],
1565 separate_by: Vec<String>,
1566 aggregate_by: Option<ReductionFunc>,
1567 show_epochs: bool,
1568 show_changes: bool,
1569) -> SectionConfig {
1570 SectionConfig {
1571 id: "main".to_string(),
1572 placeholder: "{{PLOTLY_BODY}}".to_string(),
1573 measurement_filter: wrap_patterns_for_regex(combined_patterns),
1574 key_value_filter: key_values.to_vec(),
1575 separate_by,
1576 aggregate_by,
1577 depth: None,
1578 show_epochs,
1579 show_changes,
1580 }
1581}
1582
1583fn merge_show_flags(
1586 sections: Vec<SectionConfig>,
1587 global_show_epochs: bool,
1588 global_show_changes: bool,
1589) -> Vec<SectionConfig> {
1590 sections
1591 .into_iter()
1592 .map(|sc| SectionConfig {
1593 show_epochs: sc.show_epochs || global_show_epochs,
1594 show_changes: sc.show_changes || global_show_changes,
1595 ..sc
1596 })
1597 .collect()
1598}
1599
1600#[allow(clippy::too_many_arguments)]
1607fn prepare_sections_and_metadata(
1608 output_format: OutputFormat,
1609 template_config: &ReportTemplateConfig,
1610 combined_patterns: &[String],
1611 key_values: &[(String, String)],
1612 separate_by: Vec<String>,
1613 aggregate_by: Option<ReductionFunc>,
1614 show_epochs: bool,
1615 show_changes: bool,
1616 commits: &[Commit],
1617) -> Result<(Vec<SectionConfig>, Option<String>, ReportMetadata)> {
1618 match output_format {
1619 OutputFormat::Html => {
1620 let template_path = template_config
1622 .template_path
1623 .clone()
1624 .or(config::report_template_path());
1625 let template_str = if let Some(path) = template_path {
1626 load_template(&path)?
1627 } else {
1628 DEFAULT_HTML_TEMPLATE.to_string()
1629 };
1630
1631 let sections = match parse_template_sections(&template_str)? {
1633 sections if sections.is_empty() => {
1634 log::info!(
1635 "Single-section template detected. Using CLI arguments for filtering/aggregation."
1636 );
1637 vec![build_single_section_config(
1638 combined_patterns,
1639 key_values,
1640 separate_by,
1641 aggregate_by,
1642 show_epochs,
1643 show_changes,
1644 )]
1645 }
1646 sections => {
1647 log::info!(
1648 "Multi-section template detected with {} sections. CLI arguments for filtering/aggregation will be ignored.",
1649 sections.len()
1650 );
1651 merge_show_flags(sections, show_epochs, show_changes)
1652 }
1653 };
1654
1655 let resolved_title = template_config.title.clone().or_else(config::report_title);
1657 let custom_css_content = load_custom_css(template_config.custom_css_path.as_ref())?;
1658 let metadata = ReportMetadata::new(resolved_title, custom_css_content, commits);
1659
1660 Ok((sections, Some(template_str), metadata))
1661 }
1662 OutputFormat::Csv => {
1663 if template_config.template_path.is_some() {
1665 log::warn!("Template argument is ignored for CSV output format");
1666 }
1667
1668 let section = build_single_section_config(
1670 combined_patterns,
1671 key_values,
1672 separate_by,
1673 aggregate_by,
1674 show_epochs,
1675 show_changes,
1676 );
1677
1678 let metadata = ReportMetadata::new(None, String::new(), commits);
1680
1681 Ok((vec![section], None, metadata))
1682 }
1683 }
1684}
1685
1686fn process_section<'a>(
1691 reporter: &mut dyn Reporter<'a>,
1692 commits: &'a [Commit],
1693 section: &SectionConfig,
1694) -> Result<SectionOutput> {
1695 reporter.begin_section(§ion.id, §ion.placeholder);
1696
1697 let section_commits = if let Some(depth) = section.depth {
1699 if depth > commits.len() {
1700 log::warn!(
1701 "Section '{}' requested depth {} but only {} commits available",
1702 section.id,
1703 depth,
1704 commits.len()
1705 );
1706 commits
1707 } else {
1708 &commits[..depth]
1709 }
1710 } else {
1711 commits
1712 };
1713
1714 let filters = if let Some(ref pattern) = section.measurement_filter {
1716 crate::filter::compile_filters(std::slice::from_ref(pattern))?
1717 } else {
1718 vec![]
1719 };
1720
1721 let relevant_measurements: Vec<Vec<&MeasurementData>> =
1722 filter_measurements_by_criteria(section_commits, &filters, §ion.key_value_filter);
1723
1724 let unique_measurement_names: Vec<_> = relevant_measurements
1725 .iter()
1726 .flat_map(|ms| ms.iter().map(|m| &m.name))
1727 .unique()
1728 .collect();
1729
1730 if unique_measurement_names.is_empty() {
1731 log::warn!("Section '{}' has no matching measurements", section.id);
1732 return Ok(SectionOutput {
1734 section_id: section.id.clone(),
1735 placeholder: section.placeholder.clone(),
1736 content: Vec::new(),
1737 });
1738 }
1739
1740 for measurement_name in unique_measurement_names {
1742 let filtered_for_grouping = relevant_measurements
1743 .iter()
1744 .flat_map(|ms| ms.iter().copied().filter(|m| m.name == *measurement_name));
1745
1746 let group_values_to_process = compute_group_values_to_process(
1747 filtered_for_grouping,
1748 §ion.separate_by,
1749 &format!("Section '{}'", section.id),
1750 )?;
1751
1752 for group_value in group_values_to_process {
1753 let group_measurements_vec: Vec<Vec<&MeasurementData>> = relevant_measurements
1754 .iter()
1755 .map(|ms| {
1756 ms.iter()
1757 .filter(|m| {
1758 if m.name != *measurement_name {
1759 return false;
1760 }
1761 if group_value.is_empty() {
1762 return true;
1763 }
1764 section.separate_by.iter().zip(group_value.iter()).all(
1765 |(key, expected_val)| {
1766 m.key_values
1767 .get(key)
1768 .map(|v| v == expected_val)
1769 .unwrap_or(false)
1770 },
1771 )
1772 })
1773 .copied()
1774 .collect()
1775 })
1776 .collect();
1777
1778 add_trace_for_measurement_group(
1780 reporter,
1781 group_measurements_vec.iter().map(|v| v.iter().copied()),
1782 measurement_name,
1783 &group_value,
1784 section.aggregate_by,
1785 );
1786
1787 add_detection_traces_if_requested(
1789 reporter,
1790 group_measurements_vec.iter().map(|v| v.iter().copied()),
1791 section_commits,
1792 measurement_name,
1793 &group_value,
1794 section.aggregate_by,
1795 section.show_epochs,
1796 section.show_changes,
1797 );
1798 }
1799 }
1800
1801 reporter.end_section()
1802}
1803
1804#[allow(clippy::too_many_arguments)]
1805pub fn report(
1806 start_commit: &str,
1807 output: PathBuf,
1808 separate_by: Vec<String>,
1809 num_commits: usize,
1810 since: Option<&str>,
1811 until: Option<&str>,
1812 key_values: &[(String, String)],
1813 aggregate_by: Option<ReductionFunc>,
1814 combined_patterns: &[String],
1815 template_config: ReportTemplateConfig,
1816 show_epochs: bool,
1817 show_changes: bool,
1818) -> Result<()> {
1819 let _filters = crate::filter::compile_filters(combined_patterns)?;
1822
1823 let commits: Vec<Commit> =
1824 measurement_retrieval::walk_commits_from(start_commit, num_commits, since, until)?
1825 .try_collect()?;
1826
1827 if commits.is_empty() {
1828 bail!(
1829 "No commits found in repository. Ensure commits exist and were pushed to the remote."
1830 );
1831 }
1832
1833 let output_format = OutputFormat::from_path(&output)
1835 .ok_or_else(|| anyhow!("Could not determine output format from file extension"))?;
1836
1837 let (sections, template_str, metadata) = prepare_sections_and_metadata(
1839 output_format,
1840 &template_config,
1841 combined_patterns,
1842 key_values,
1843 separate_by.clone(),
1844 aggregate_by,
1845 show_epochs,
1846 show_changes,
1847 &commits,
1848 )?;
1849
1850 let mut reporter: Box<dyn Reporter> = match output_format {
1852 OutputFormat::Html => {
1853 let template = template_str.expect("HTML requires template");
1854 Box::new(PlotlyReporter::with_template(template, metadata.clone()))
1855 }
1856 OutputFormat::Csv => Box::new(CsvReporter::new()),
1857 };
1858
1859 reporter.add_commits(&commits);
1861
1862 let section_outputs = sections
1863 .iter()
1864 .map(|section| process_section(&mut *reporter, &commits, section))
1865 .collect::<Result<Vec<SectionOutput>>>()?;
1866
1867 let has_measurements_from_sections = section_outputs.iter().any(|s| !s.content.is_empty());
1871
1872 let report_bytes = reporter.finalize(section_outputs, &metadata);
1874
1875 let has_measurements = match output_format {
1878 OutputFormat::Html => has_measurements_from_sections,
1879 OutputFormat::Csv => !report_bytes.is_empty(),
1880 };
1881
1882 let is_multi_section = sections.len() > 1;
1886 if !is_multi_section && !has_measurements {
1887 bail!("No performance measurements found.");
1888 }
1889
1890 write_output(&output, &report_bytes)?;
1892
1893 Ok(())
1894}
1895
1896#[cfg(test)]
1897mod tests {
1898 use super::*;
1899
1900 #[test]
1901 fn test_convert_to_x_y_empty() {
1902 let reporter = PlotlyReporter::new();
1903 let (x, y) = reporter.convert_to_x_y(vec![]);
1904 assert!(x.is_empty());
1905 assert!(y.is_empty());
1906 }
1907
1908 #[test]
1909 fn test_convert_to_x_y_single_value() {
1910 let mut reporter = PlotlyReporter::new();
1911 reporter.size = 3;
1912 let (x, y) = reporter.convert_to_x_y(vec![(0, 1.5)]);
1913 assert_eq!(x, vec![2]);
1914 assert_eq!(y, vec![1.5]);
1915 }
1916
1917 #[test]
1918 fn test_convert_to_x_y_multiple_values() {
1919 let mut reporter = PlotlyReporter::new();
1920 reporter.size = 5;
1921 let (x, y) = reporter.convert_to_x_y(vec![(0, 10.0), (2, 20.0), (4, 30.0)]);
1922 assert_eq!(x, vec![4, 2, 0]);
1923 assert_eq!(y, vec![10.0, 20.0, 30.0]);
1924 }
1925
1926 #[test]
1927 fn test_convert_to_x_y_negative_values() {
1928 let mut reporter = PlotlyReporter::new();
1929 reporter.size = 2;
1930 let (x, y) = reporter.convert_to_x_y(vec![(0, -5.5), (1, -10.2)]);
1931 assert_eq!(x, vec![1, 0]);
1932 assert_eq!(y, vec![-5.5, -10.2]);
1933 }
1934
1935 #[test]
1936 fn test_plotly_reporter_as_bytes_not_empty() {
1937 let reporter = PlotlyReporter::new();
1938 let bytes = reporter.as_bytes();
1939 assert!(!bytes.is_empty());
1940 let html = String::from_utf8_lossy(&bytes);
1942 assert!(html.contains("plotly") || html.contains("Plotly"));
1943 }
1944
1945 #[test]
1946 fn test_plotly_reporter_uses_default_template() {
1947 let reporter = PlotlyReporter::new();
1948 let bytes = reporter.as_bytes();
1949 let html = String::from_utf8_lossy(&bytes);
1950
1951 assert!(html.contains("<!DOCTYPE html>"));
1953 assert!(html.contains("<html>"));
1954 assert!(html.contains("<head>"));
1955 assert!(html.contains("<title>Performance Measurements</title>"));
1956 assert!(html.contains("</head>"));
1957 assert!(html.contains("<body>"));
1958 assert!(html.contains("</body>"));
1959 assert!(html.contains("</html>"));
1960 assert!(html.contains("plotly") || html.contains("Plotly"));
1962 }
1963
1964 #[test]
1965 fn test_format_measurement_with_unit_no_unit() {
1966 let result = format_measurement_with_unit("unknown_measurement");
1968 assert_eq!(result, "unknown_measurement");
1969 }
1970
1971 #[test]
1972 fn test_extract_plotly_parts() {
1973 let mut plot = Plot::new();
1975 let trace = plotly::Scatter::new(vec![1, 2, 3], vec![4, 5, 6]).name("test");
1976 plot.add_trace(trace);
1977
1978 let (head, body) = extract_plotly_parts(&plot);
1979
1980 assert!(head.contains("<script"));
1982 assert!(head.contains("plotly"));
1983
1984 assert!(body.contains("<div"));
1986 assert!(body.contains("<script"));
1987 assert!(body.contains("Plotly.newPlot"));
1988 }
1989
1990 #[test]
1991 fn test_extract_plotly_parts_structure() {
1992 let mut plot = Plot::new();
1994 let trace = plotly::Scatter::new(vec![1], vec![1]).name("data");
1995 plot.add_trace(trace);
1996
1997 let (head, body) = extract_plotly_parts(&plot);
1998
1999 assert!(!head.contains("<html>"));
2001 assert!(!head.contains("<head>"));
2002 assert!(!head.contains("<body>"));
2003
2004 assert!(!body.contains("<html>"));
2006 assert!(!body.contains("<head>"));
2007 assert!(!body.contains("<body>"));
2008 }
2009
2010 #[test]
2011 fn test_report_metadata_new() {
2012 use crate::data::Commit;
2013
2014 let commits = vec![
2015 Commit {
2016 commit: "abc1234567890".to_string(),
2017 title: "test: commit 1".to_string(),
2018 author: "Test Author".to_string(),
2019 measurements: vec![],
2020 },
2021 Commit {
2022 commit: "def0987654321".to_string(),
2023 title: "test: commit 2".to_string(),
2024 author: "Test Author".to_string(),
2025 measurements: vec![],
2026 },
2027 ];
2028
2029 let metadata =
2030 ReportMetadata::new(Some("Custom Title".to_string()), "".to_string(), &commits);
2031
2032 assert_eq!(metadata.title, "Custom Title");
2033 assert_eq!(metadata.commit_range, "def0987..abc1234");
2034 assert_eq!(metadata.depth, 2);
2035 }
2036
2037 #[test]
2038 fn test_report_metadata_new_default_title() {
2039 use crate::data::Commit;
2040
2041 let commits = vec![Commit {
2042 commit: "abc1234567890".to_string(),
2043 title: "test: commit".to_string(),
2044 author: "Test Author".to_string(),
2045 measurements: vec![],
2046 }];
2047
2048 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2049
2050 assert_eq!(metadata.title, "Performance Measurements");
2051 assert_eq!(metadata.commit_range, "abc1234");
2052 assert_eq!(metadata.depth, 1);
2053 }
2054
2055 #[test]
2056 fn test_report_metadata_new_empty_commits() {
2057 let commits = vec![];
2058 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2059
2060 assert_eq!(metadata.commit_range, "No commits");
2061 assert_eq!(metadata.depth, 0);
2062 }
2063
2064 #[test]
2065 fn test_compute_y_axis_empty_measurements() {
2066 let reporter = PlotlyReporter::new();
2067 let y_axis = reporter.compute_y_axis();
2068 assert!(y_axis.is_none());
2069 }
2070
2071 #[test]
2072 fn test_compute_y_axis_single_unit() {
2073 let mut reporter = PlotlyReporter::new();
2074 reporter.measurement_units.push(Some("ms".to_string()));
2075 reporter.measurement_units.push(Some("ms".to_string()));
2076 reporter.measurement_units.push(Some("ms".to_string()));
2077
2078 let y_axis = reporter.compute_y_axis();
2079 assert!(y_axis.is_some());
2080 }
2081
2082 #[test]
2083 fn test_compute_y_axis_mixed_units() {
2084 let mut reporter = PlotlyReporter::new();
2085 reporter.measurement_units.push(Some("ms".to_string()));
2086 reporter.measurement_units.push(Some("bytes".to_string()));
2087
2088 let y_axis = reporter.compute_y_axis();
2089 assert!(y_axis.is_none());
2090 }
2091
2092 #[test]
2093 fn test_compute_y_axis_no_units() {
2094 let mut reporter = PlotlyReporter::new();
2095 reporter.measurement_units.push(None);
2096 reporter.measurement_units.push(None);
2097
2098 let y_axis = reporter.compute_y_axis();
2099 assert!(y_axis.is_none());
2100 }
2101
2102 #[test]
2103 fn test_compute_y_axis_some_with_unit_some_without() {
2104 let mut reporter = PlotlyReporter::new();
2105 reporter.measurement_units.push(Some("ms".to_string()));
2106 reporter.measurement_units.push(None);
2107
2108 let y_axis = reporter.compute_y_axis();
2109 assert!(y_axis.is_none());
2110 }
2111
2112 #[test]
2113 fn test_plotly_reporter_adds_units_to_legend() {
2114 use crate::data::Commit;
2115
2116 let mut reporter = PlotlyReporter::new();
2117
2118 let commits = vec![
2120 Commit {
2121 commit: "abc123".to_string(),
2122 title: "test: commit 1".to_string(),
2123 author: "Test Author".to_string(),
2124 measurements: vec![],
2125 },
2126 Commit {
2127 commit: "def456".to_string(),
2128 title: "test: commit 2".to_string(),
2129 author: "Test Author".to_string(),
2130 measurements: vec![],
2131 },
2132 ];
2133 reporter.add_commits(&commits);
2134
2135 reporter.measurement_units.push(Some("ms".to_string()));
2137
2138 let bytes = reporter.as_bytes();
2140 let html = String::from_utf8_lossy(&bytes);
2141
2142 assert!(!html.is_empty());
2144 assert!(html.contains("plotly") || html.contains("Plotly"));
2145 }
2146
2147 #[test]
2148 fn test_plotly_reporter_y_axis_with_same_units() {
2149 let mut reporter = PlotlyReporter::new();
2150
2151 reporter.measurement_units.push(Some("ms".to_string()));
2153 reporter.measurement_units.push(Some("ms".to_string()));
2154
2155 let bytes = reporter.as_bytes();
2157 let html = String::from_utf8_lossy(&bytes);
2158
2159 assert!(html.contains("Value (ms)"));
2161 }
2162
2163 #[test]
2164 fn test_plotly_reporter_no_y_axis_with_mixed_units() {
2165 let mut reporter = PlotlyReporter::new();
2166
2167 reporter.measurement_units.push(Some("ms".to_string()));
2169 reporter.measurement_units.push(Some("bytes".to_string()));
2170
2171 let bytes = reporter.as_bytes();
2173 let html = String::from_utf8_lossy(&bytes);
2174
2175 assert!(!html.contains("Value (ms)"));
2177 assert!(!html.contains("Value (bytes)"));
2178 }
2179
2180 #[test]
2181 fn test_csv_reporter_as_bytes_empty_on_init() {
2182 let reporter = CsvReporter::new();
2183 let bytes = reporter.as_bytes();
2184 assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
2186 }
2187
2188 #[test]
2189 fn test_csv_reporter_includes_header() {
2190 use crate::data::{Commit, MeasurementData};
2191 use std::collections::HashMap;
2192
2193 let mut reporter = CsvReporter::new();
2194
2195 let commits = vec![Commit {
2197 commit: "abc123".to_string(),
2198 title: "test: commit".to_string(),
2199 author: "Test Author".to_string(),
2200 measurements: vec![],
2201 }];
2202 reporter.add_commits(&commits);
2203
2204 let measurement = MeasurementData {
2206 epoch: 0,
2207 name: "test_measurement".to_string(),
2208 timestamp: 1234.0,
2209 val: 42.5,
2210 key_values: HashMap::new(),
2211 };
2212 reporter.add_trace(vec![(0, &measurement)], "test_measurement", &[]);
2213
2214 let bytes = reporter.as_bytes();
2216 let csv = String::from_utf8_lossy(&bytes);
2217
2218 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2220
2221 assert!(csv.contains("abc123"));
2223 assert!(csv.contains("test_measurement"));
2224 assert!(csv.contains("42.5"));
2225 }
2226
2227 #[test]
2228 fn test_csv_exact_output_single_measurement() {
2229 use crate::data::{Commit, MeasurementData};
2230 use std::collections::HashMap;
2231
2232 let mut reporter = CsvReporter::new();
2233
2234 let commits = vec![Commit {
2235 commit: "abc123def456".to_string(),
2236 title: "test: commit".to_string(),
2237 author: "Test Author".to_string(),
2238 measurements: vec![],
2239 }];
2240 reporter.add_commits(&commits);
2241
2242 let measurement = MeasurementData {
2243 epoch: 0,
2244 name: "build_time".to_string(),
2245 timestamp: 1234567890.5,
2246 val: 42.0,
2247 key_values: HashMap::new(),
2248 };
2249 reporter.add_trace(vec![(0, &measurement)], "build_time", &[]);
2250
2251 let bytes = reporter.as_bytes();
2252 let csv = String::from_utf8_lossy(&bytes);
2253
2254 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc123def456\t0\tbuild_time\t1234567890.5\t42.0\t\n";
2255 assert_eq!(csv, expected);
2256 }
2257
2258 #[test]
2259 fn test_csv_exact_output_with_metadata() {
2260 use crate::data::{Commit, MeasurementData};
2261 use std::collections::HashMap;
2262
2263 let mut reporter = CsvReporter::new();
2264
2265 let commits = vec![Commit {
2266 commit: "commit123".to_string(),
2267 title: "test: commit".to_string(),
2268 author: "Test Author".to_string(),
2269 measurements: vec![],
2270 }];
2271 reporter.add_commits(&commits);
2272
2273 let mut metadata = HashMap::new();
2274 metadata.insert("os".to_string(), "linux".to_string());
2275 metadata.insert("arch".to_string(), "x64".to_string());
2276
2277 let measurement = MeasurementData {
2278 epoch: 1,
2279 name: "test".to_string(),
2280 timestamp: 1000.0,
2281 val: 3.5,
2282 key_values: metadata,
2283 };
2284 reporter.add_trace(vec![(0, &measurement)], "test", &[]);
2285
2286 let bytes = reporter.as_bytes();
2287 let csv = String::from_utf8_lossy(&bytes);
2288
2289 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2291 assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
2292 assert!(csv.contains("os=linux"));
2294 assert!(csv.contains("arch=x64"));
2295 assert!(csv.ends_with('\n'));
2297 }
2298
2299 #[test]
2300 fn test_csv_exact_output_multiple_measurements() {
2301 use crate::data::{Commit, MeasurementData};
2302 use std::collections::HashMap;
2303
2304 let mut reporter = CsvReporter::new();
2305
2306 let commits = vec![
2307 Commit {
2308 commit: "commit1".to_string(),
2309 title: "test: commit 1".to_string(),
2310 author: "Test Author".to_string(),
2311 measurements: vec![],
2312 },
2313 Commit {
2314 commit: "commit2".to_string(),
2315 title: "test: commit 2".to_string(),
2316 author: "Test Author".to_string(),
2317 measurements: vec![],
2318 },
2319 ];
2320 reporter.add_commits(&commits);
2321
2322 let m1 = MeasurementData {
2323 epoch: 0,
2324 name: "timer".to_string(),
2325 timestamp: 100.0,
2326 val: 1.5,
2327 key_values: HashMap::new(),
2328 };
2329
2330 let m2 = MeasurementData {
2331 epoch: 0,
2332 name: "timer".to_string(),
2333 timestamp: 200.0,
2334 val: 2.0,
2335 key_values: HashMap::new(),
2336 };
2337
2338 reporter.add_trace(vec![(0, &m1), (1, &m2)], "timer", &[]);
2339
2340 let bytes = reporter.as_bytes();
2341 let csv = String::from_utf8_lossy(&bytes);
2342
2343 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n\
2344 commit1\t0\ttimer\t100.0\t1.5\t\n\
2345 commit2\t0\ttimer\t200.0\t2.0\t\n";
2346 assert_eq!(csv, expected);
2347 }
2348
2349 #[test]
2350 fn test_csv_exact_output_whole_number_formatting() {
2351 use crate::data::{Commit, MeasurementData};
2352 use std::collections::HashMap;
2353
2354 let mut reporter = CsvReporter::new();
2355
2356 let commits = vec![Commit {
2357 commit: "hash1".to_string(),
2358 title: "test: commit".to_string(),
2359 author: "Test Author".to_string(),
2360 measurements: vec![],
2361 }];
2362 reporter.add_commits(&commits);
2363
2364 let measurement = MeasurementData {
2365 epoch: 0,
2366 name: "count".to_string(),
2367 timestamp: 500.0,
2368 val: 10.0,
2369 key_values: HashMap::new(),
2370 };
2371 reporter.add_trace(vec![(0, &measurement)], "count", &[]);
2372
2373 let bytes = reporter.as_bytes();
2374 let csv = String::from_utf8_lossy(&bytes);
2375
2376 let expected =
2378 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nhash1\t0\tcount\t500.0\t10.0\t\n";
2379 assert_eq!(csv, expected);
2380 }
2381
2382 #[test]
2383 fn test_csv_exact_output_summarized_measurement() {
2384 use crate::data::{Commit, MeasurementSummary};
2385
2386 let mut reporter = CsvReporter::new();
2387
2388 let commits = vec![Commit {
2389 commit: "abc".to_string(),
2390 title: "test: commit".to_string(),
2391 author: "Test Author".to_string(),
2392 measurements: vec![],
2393 }];
2394 reporter.add_commits(&commits);
2395
2396 let summary = MeasurementSummary { epoch: 0, val: 5.5 };
2397
2398 reporter.add_summarized_trace(vec![(0, summary)], "avg_time", &[]);
2399
2400 let bytes = reporter.as_bytes();
2401 let csv = String::from_utf8_lossy(&bytes);
2402
2403 let expected =
2405 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc\t0\tavg_time\t0.0\t5.5\t\n";
2406 assert_eq!(csv, expected);
2407 }
2408
2409 #[test]
2410 fn test_epoch_boundary_traces_hidden_by_default() {
2411 use crate::change_point::EpochTransition;
2412 use crate::data::Commit;
2413
2414 let mut reporter = PlotlyReporter::new();
2415
2416 let commits = vec![
2417 Commit {
2418 commit: "abc123".to_string(),
2419 title: "test: commit 1".to_string(),
2420 author: "Test Author".to_string(),
2421 measurements: vec![],
2422 },
2423 Commit {
2424 commit: "def456".to_string(),
2425 title: "test: commit 2".to_string(),
2426 author: "Test Author".to_string(),
2427 measurements: vec![],
2428 },
2429 Commit {
2430 commit: "ghi789".to_string(),
2431 title: "test: commit 3".to_string(),
2432 author: "Test Author".to_string(),
2433 measurements: vec![],
2434 },
2435 ];
2436 reporter.add_commits(&commits);
2437
2438 let transitions = vec![EpochTransition {
2439 index: 1,
2440 from_epoch: 1,
2441 to_epoch: 2,
2442 }];
2443
2444 let commit_indices = vec![0, 1, 2];
2445 let group_values: Vec<String> = vec![];
2446 reporter.add_epoch_boundary_traces(
2447 &transitions,
2448 &commit_indices,
2449 "test_metric",
2450 &group_values,
2451 0.0,
2452 100.0,
2453 );
2454
2455 let bytes = reporter.as_bytes();
2456 let html = String::from_utf8_lossy(&bytes);
2457 assert!(html.contains("legendonly"));
2459 assert!(html.contains("test_metric (Epochs)"));
2461 }
2462
2463 #[test]
2464 fn test_epoch_boundary_traces_empty() {
2465 use crate::change_point::EpochTransition;
2466
2467 let mut reporter = PlotlyReporter::new();
2468 reporter.size = 10;
2469
2470 let transitions: Vec<EpochTransition> = vec![];
2471 let commit_indices: Vec<usize> = vec![];
2472 let group_values: Vec<String> = vec![];
2473 reporter.add_epoch_boundary_traces(
2474 &transitions,
2475 &commit_indices,
2476 "test",
2477 &group_values,
2478 0.0,
2479 100.0,
2480 );
2481
2482 let bytes = reporter.as_bytes();
2484 assert!(!bytes.is_empty());
2485 }
2486
2487 #[test]
2488 fn test_change_point_traces_hidden_by_default() {
2489 use crate::change_point::{ChangeDirection, ChangePoint};
2490 use crate::data::Commit;
2491
2492 let mut reporter = PlotlyReporter::new();
2493
2494 let commits = vec![
2495 Commit {
2496 commit: "abc123".to_string(),
2497 title: "test: commit 1".to_string(),
2498 author: "Test Author".to_string(),
2499 measurements: vec![],
2500 },
2501 Commit {
2502 commit: "def456".to_string(),
2503 title: "test: commit 2".to_string(),
2504 author: "Test Author".to_string(),
2505 measurements: vec![],
2506 },
2507 ];
2508 reporter.add_commits(&commits);
2509
2510 let change_points = vec![ChangePoint {
2511 index: 1,
2512 commit_sha: "def456".to_string(),
2513 magnitude_pct: 50.0,
2514 confidence: 0.9,
2515 direction: ChangeDirection::Increase,
2516 }];
2517
2518 let values = vec![50.0, 75.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2520 reporter.add_change_point_traces_with_indices(
2521 &change_points,
2522 &values,
2523 &commit_indices,
2524 "build_time",
2525 &[],
2526 );
2527
2528 let bytes = reporter.as_bytes();
2529 let html = String::from_utf8_lossy(&bytes);
2530 assert!(html.contains("build_time (Change Points)"));
2532 assert!(html.contains("\"mode\":\"markers\""));
2534 }
2535
2536 #[test]
2537 fn test_change_point_traces_both_directions() {
2538 use crate::change_point::{ChangeDirection, ChangePoint};
2539 use crate::data::Commit;
2540
2541 let mut reporter = PlotlyReporter::new();
2542
2543 let commits: Vec<Commit> = (0..5)
2544 .map(|i| Commit {
2545 commit: format!("sha{:06}", i),
2546 title: format!("test: commit {}", i),
2547 author: "Test Author".to_string(),
2548 measurements: vec![],
2549 })
2550 .collect();
2551 reporter.add_commits(&commits);
2552
2553 let change_points = vec![
2554 ChangePoint {
2555 index: 2,
2556 commit_sha: "sha000002".to_string(),
2557 magnitude_pct: 25.0,
2558 confidence: 0.85,
2559 direction: ChangeDirection::Increase,
2560 },
2561 ChangePoint {
2562 index: 4,
2563 commit_sha: "sha000004".to_string(),
2564 magnitude_pct: -30.0,
2565 confidence: 0.90,
2566 direction: ChangeDirection::Decrease,
2567 },
2568 ];
2569
2570 let values = vec![50.0, 55.0, 62.5, 60.0, 42.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2572 reporter.add_change_point_traces_with_indices(
2573 &change_points,
2574 &values,
2575 &commit_indices,
2576 "metric",
2577 &[],
2578 );
2579
2580 let bytes = reporter.as_bytes();
2581 let html = String::from_utf8_lossy(&bytes);
2582 assert!(html.contains("metric (Change Points)"));
2584 assert!(html.contains("⚠ Regression"));
2586 assert!(html.contains("✓ Improvement"));
2587 }
2588
2589 #[test]
2590 fn test_change_point_traces_empty() {
2591 let mut reporter = PlotlyReporter::new();
2592 reporter.size = 10;
2593
2594 let change_points: Vec<ChangePoint> = vec![];
2595 let values = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
2596 let commit_indices: Vec<usize> = (0..reporter.size).collect();
2597 reporter.add_change_point_traces_with_indices(
2598 &change_points,
2599 &values,
2600 &commit_indices,
2601 "test",
2602 &[],
2603 );
2604
2605 let bytes = reporter.as_bytes();
2607 assert!(!bytes.is_empty());
2608 }
2609
2610 #[test]
2611 fn test_change_point_hover_text_format() {
2612 use crate::change_point::{ChangeDirection, ChangePoint};
2613 use crate::data::Commit;
2614
2615 let mut reporter = PlotlyReporter::new();
2616
2617 let commits = vec![
2618 Commit {
2619 commit: "abc123def".to_string(),
2620 title: "test: commit 1".to_string(),
2621 author: "Test Author".to_string(),
2622 measurements: vec![],
2623 },
2624 Commit {
2625 commit: "xyz789abc".to_string(),
2626 title: "test: commit 2".to_string(),
2627 author: "Test Author".to_string(),
2628 measurements: vec![],
2629 },
2630 ];
2631 reporter.add_commits(&commits);
2632
2633 let change_points = vec![ChangePoint {
2634 index: 1,
2635 commit_sha: "xyz789abc".to_string(),
2636 magnitude_pct: 23.5,
2637 confidence: 0.88,
2638 direction: ChangeDirection::Increase,
2639 }];
2640
2641 let values = vec![100.0, 123.5]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2643 reporter.add_change_point_traces_with_indices(
2644 &change_points,
2645 &values,
2646 &commit_indices,
2647 "test",
2648 &[],
2649 );
2650
2651 let bytes = reporter.as_bytes();
2652 let html = String::from_utf8_lossy(&bytes);
2653 assert!(html.contains("+23.5%"));
2655 assert!(html.contains("xyz789"));
2656 assert!(html.contains("Test Author"));
2657 assert!(html.contains("test: commit 2"));
2658 }
2659
2660 #[test]
2661 fn test_hover_text_matches_x_axis() {
2662 use crate::data::{Commit, MeasurementData};
2663
2664 let mut reporter = PlotlyReporter::new();
2665
2666 let commits = vec![
2671 Commit {
2672 commit: "ccccccc3333333333333333333333333333333".to_string(),
2673 title: "third commit (newest)".to_string(),
2674 author: "Author C".to_string(),
2675 measurements: vec![MeasurementData {
2676 name: "test_metric".to_string(),
2677 val: 300.0,
2678 epoch: 0,
2679 timestamp: 0.0,
2680 key_values: std::collections::HashMap::new(),
2681 }],
2682 },
2683 Commit {
2684 commit: "bbbbbbb2222222222222222222222222222222".to_string(),
2685 title: "second commit (middle)".to_string(),
2686 author: "Author B".to_string(),
2687 measurements: vec![MeasurementData {
2688 name: "test_metric".to_string(),
2689 val: 200.0,
2690 epoch: 0,
2691 timestamp: 0.0,
2692 key_values: std::collections::HashMap::new(),
2693 }],
2694 },
2695 Commit {
2696 commit: "aaaaaaa1111111111111111111111111111111".to_string(),
2697 title: "first commit (oldest)".to_string(),
2698 author: "Author A".to_string(),
2699 measurements: vec![MeasurementData {
2700 name: "test_metric".to_string(),
2701 val: 100.0,
2702 epoch: 0,
2703 timestamp: 0.0,
2704 key_values: std::collections::HashMap::new(),
2705 }],
2706 },
2707 ];
2708 reporter.add_commits(&commits);
2709
2710 reporter.begin_section("test_section", "{{PLACEHOLDER}}");
2711
2712 let indexed_measurements = vec![
2714 (0, &commits[0].measurements[0]),
2715 (2, &commits[2].measurements[0]),
2716 ];
2717
2718 reporter.add_trace(indexed_measurements, "test_metric", &[]);
2719
2720 let bytes = reporter.as_bytes();
2721 let html = String::from_utf8_lossy(&bytes);
2722
2723 let json_str = extract_plotly_data_array(&html)
2725 .expect("Failed to extract Plotly config object from HTML");
2726
2727 let plotly_config: serde_json::Value =
2728 serde_json::from_str(&json_str).expect("Failed to parse Plotly JSON config");
2729
2730 let plotly_data = plotly_config["data"]
2732 .as_array()
2733 .expect("Config should have 'data' field as array");
2734
2735 let trace = plotly_data.first().expect("Should have at least one trace");
2737
2738 let x_array = trace["x"].as_array().expect("Trace should have x array");
2740 let y_array = trace["y"].as_array().expect("Trace should have y array");
2741
2742 let hover_array = trace
2744 .get("text")
2745 .or_else(|| trace.get("hovertext"))
2746 .and_then(|v| v.as_array())
2747 .expect("Trace should have text or hovertext array");
2748
2749 assert_eq!(x_array.len(), 2, "Should have 2 x values");
2751 assert_eq!(y_array.len(), 2, "Should have 2 y values");
2752 assert_eq!(hover_array.len(), 2, "Should have 2 hover texts");
2753
2754 for i in 0..x_array.len() {
2761 let x = x_array[i].as_u64().expect("x value should be a number") as usize;
2762 let y = y_array[i].as_f64().expect("y value should be a number");
2763 let hover = hover_array[i]
2764 .as_str()
2765 .expect("hover text should be a string");
2766
2767 if x == 0 {
2768 assert_eq!(y, 100.0, "x=0 should have y=100.0 (oldest commit value)");
2770 assert!(
2771 hover.contains("aaaaaaa"),
2772 "x=0 hover should contain oldest commit hash 'aaaaaaa', but got: {}",
2773 hover
2774 );
2775 assert!(
2776 hover.contains("Author A"),
2777 "x=0 hover should contain oldest commit author 'Author A', but got: {}",
2778 hover
2779 );
2780 assert!(
2781 hover.contains("first commit"),
2782 "x=0 hover should contain oldest commit title 'first commit', but got: {}",
2783 hover
2784 );
2785 } else if x == 2 {
2786 assert_eq!(y, 300.0, "x=2 should have y=300.0 (newest commit value)");
2788 assert!(
2789 hover.contains("ccccccc"),
2790 "x=2 hover should contain newest commit hash 'ccccccc', but got: {}",
2791 hover
2792 );
2793 assert!(
2794 hover.contains("Author C"),
2795 "x=2 hover should contain newest commit author 'Author C', but got: {}",
2796 hover
2797 );
2798 assert!(
2799 hover.contains("third commit"),
2800 "x=2 hover should contain newest commit title 'third commit', but got: {}",
2801 hover
2802 );
2803 } else {
2804 panic!("Unexpected x value: {}", x);
2805 }
2806 }
2807 }
2808
2809 fn extract_plotly_data_array(html: &str) -> Result<String, String> {
2815 let start_pattern = "Plotly.newPlot(";
2817 let start = html
2818 .find(start_pattern)
2819 .ok_or_else(|| "Could not find Plotly.newPlot call in HTML".to_string())?;
2820
2821 let after_start = start + start_pattern.len();
2823 let first_comma_offset = html[after_start..]
2824 .find(',')
2825 .ok_or_else(|| "Could not find first comma after Plotly.newPlot".to_string())?;
2826 let obj_start_pos = after_start + first_comma_offset + 1;
2827
2828 let remaining = &html[obj_start_pos..];
2830 let trimmed = remaining.trim_start();
2831 let brace_offset = remaining.len() - trimmed.len();
2832
2833 if !trimmed.starts_with('{') {
2834 return Err(format!(
2835 "Expected config object to start with '{{', but found: {}",
2836 &trimmed[..20.min(trimmed.len())]
2837 ));
2838 }
2839
2840 let obj_begin = obj_start_pos + brace_offset;
2841
2842 let mut depth = 0;
2844 let mut end = obj_begin;
2845
2846 for (i, ch) in html[obj_begin..].chars().enumerate() {
2847 match ch {
2848 '{' => depth += 1,
2849 '}' => {
2850 depth -= 1;
2851 if depth == 0 {
2852 end = obj_begin + i + 1;
2853 break;
2854 }
2855 }
2856 _ => {}
2857 }
2858 }
2859
2860 if depth != 0 {
2861 return Err("Unmatched braces in config object".to_string());
2862 }
2863
2864 Ok(html[obj_begin..end].to_string())
2865 }
2866
2867 #[test]
2868 fn test_default_template_has_no_sections() {
2869 let sections = parse_template_sections(DEFAULT_HTML_TEMPLATE)
2872 .expect("Failed to parse default template");
2873 assert!(sections.is_empty());
2874 }
2875
2876 #[test]
2877 fn test_wrap_patterns_for_regex_empty() {
2878 let patterns = vec![];
2880 let result = wrap_patterns_for_regex(&patterns);
2881 assert_eq!(result, None);
2882 }
2883
2884 #[test]
2885 fn test_wrap_patterns_for_regex_single() {
2886 let patterns = vec!["test.*".to_string()];
2888 let result = wrap_patterns_for_regex(&patterns);
2889 assert_eq!(result, Some("(?:test.*)".to_string()));
2890 }
2891
2892 #[test]
2893 fn test_wrap_patterns_for_regex_multiple() {
2894 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2896 let result = wrap_patterns_for_regex(&patterns);
2897 assert_eq!(result, Some("(?:test.*)|(?:bench.*)".to_string()));
2898 }
2899
2900 #[test]
2901 fn test_wrap_patterns_for_regex_complex() {
2902 let patterns = vec!["^test-[0-9]+$".to_string(), "bench-(foo|bar)".to_string()];
2904 let result = wrap_patterns_for_regex(&patterns);
2905 assert_eq!(
2906 result,
2907 Some("(?:^test-[0-9]+$)|(?:bench-(foo|bar))".to_string())
2908 );
2909 }
2910
2911 #[test]
2912 fn test_build_single_section_config_no_filters() {
2913 let section = build_single_section_config(&[], &[], vec![], None, false, false);
2915
2916 assert_eq!(section.id, "main");
2917 assert_eq!(section.placeholder, "{{PLOTLY_BODY}}");
2918 assert_eq!(section.measurement_filter, None);
2919 assert!(section.key_value_filter.is_empty());
2920 assert!(section.separate_by.is_empty());
2921 assert_eq!(section.aggregate_by, None);
2922 assert_eq!(section.depth, None);
2923 assert!(!section.show_epochs);
2924 assert!(!section.show_changes);
2925 }
2926
2927 #[test]
2928 fn test_build_single_section_config_with_patterns() {
2929 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2931 let section = build_single_section_config(&patterns, &[], vec![], None, false, false);
2932
2933 assert_eq!(
2934 section.measurement_filter,
2935 Some("(?:test.*)|(?:bench.*)".to_string())
2936 );
2937 }
2938
2939 #[test]
2940 fn test_build_single_section_config_with_all_params() {
2941 let patterns = vec!["test.*".to_string()];
2943 let kv_filters = vec![
2944 ("os".to_string(), "linux".to_string()),
2945 ("arch".to_string(), "x64".to_string()),
2946 ];
2947 let separate = vec!["os".to_string(), "arch".to_string()];
2948
2949 let section = build_single_section_config(
2950 &patterns,
2951 &kv_filters,
2952 separate.clone(),
2953 Some(ReductionFunc::Median),
2954 true,
2955 true,
2956 );
2957
2958 assert_eq!(section.measurement_filter, Some("(?:test.*)".to_string()));
2959 assert_eq!(section.key_value_filter, kv_filters);
2960 assert_eq!(section.separate_by, separate);
2961 assert_eq!(section.aggregate_by, Some(ReductionFunc::Median));
2962 assert!(section.show_epochs);
2963 assert!(section.show_changes);
2964 }
2965
2966 #[test]
2967 fn test_merge_show_flags_both_false() {
2968 let sections = vec![SectionConfig {
2970 id: "test".to_string(),
2971 placeholder: "{{SECTION[test]}}".to_string(),
2972 measurement_filter: None,
2973 key_value_filter: vec![],
2974 separate_by: vec![],
2975 aggregate_by: None,
2976 depth: None,
2977 show_epochs: false,
2978 show_changes: false,
2979 }];
2980
2981 let merged = merge_show_flags(sections, false, false);
2982
2983 assert_eq!(merged.len(), 1);
2984 assert!(!merged[0].show_epochs);
2985 assert!(!merged[0].show_changes);
2986 }
2987
2988 #[test]
2989 fn test_merge_show_flags_section_true_global_false() {
2990 let sections = vec![SectionConfig {
2992 id: "test".to_string(),
2993 placeholder: "{{SECTION[test]}}".to_string(),
2994 measurement_filter: None,
2995 key_value_filter: vec![],
2996 separate_by: vec![],
2997 aggregate_by: None,
2998 depth: None,
2999 show_epochs: true,
3000 show_changes: true,
3001 }];
3002
3003 let merged = merge_show_flags(sections, false, false);
3004
3005 assert_eq!(merged.len(), 1);
3006 assert!(merged[0].show_epochs);
3007 assert!(merged[0].show_changes);
3008 }
3009
3010 #[test]
3011 fn test_merge_show_flags_section_false_global_true() {
3012 let sections = vec![SectionConfig {
3014 id: "test".to_string(),
3015 placeholder: "{{SECTION[test]}}".to_string(),
3016 measurement_filter: None,
3017 key_value_filter: vec![],
3018 separate_by: vec![],
3019 aggregate_by: None,
3020 depth: None,
3021 show_epochs: false,
3022 show_changes: false,
3023 }];
3024
3025 let merged = merge_show_flags(sections, true, true);
3026
3027 assert_eq!(merged.len(), 1);
3028 assert!(merged[0].show_epochs);
3029 assert!(merged[0].show_changes);
3030 }
3031
3032 #[test]
3033 fn test_merge_show_flags_both_true() {
3034 let sections = vec![SectionConfig {
3036 id: "test".to_string(),
3037 placeholder: "{{SECTION[test]}}".to_string(),
3038 measurement_filter: None,
3039 key_value_filter: vec![],
3040 separate_by: vec![],
3041 aggregate_by: None,
3042 depth: None,
3043 show_epochs: true,
3044 show_changes: true,
3045 }];
3046
3047 let merged = merge_show_flags(sections, true, true);
3048
3049 assert_eq!(merged.len(), 1);
3050 assert!(merged[0].show_epochs);
3051 assert!(merged[0].show_changes);
3052 }
3053
3054 #[test]
3055 fn test_merge_show_flags_mixed_flags() {
3056 let sections = vec![SectionConfig {
3058 id: "test".to_string(),
3059 placeholder: "{{SECTION[test]}}".to_string(),
3060 measurement_filter: None,
3061 key_value_filter: vec![],
3062 separate_by: vec![],
3063 aggregate_by: None,
3064 depth: None,
3065 show_epochs: true,
3066 show_changes: false,
3067 }];
3068
3069 let merged = merge_show_flags(sections, false, true);
3070
3071 assert_eq!(merged.len(), 1);
3072 assert!(merged[0].show_epochs); assert!(merged[0].show_changes); }
3075
3076 #[test]
3077 fn test_merge_show_flags_multiple_sections() {
3078 let sections = vec![
3080 SectionConfig {
3081 id: "section1".to_string(),
3082 placeholder: "{{SECTION[section1]}}".to_string(),
3083 measurement_filter: None,
3084 key_value_filter: vec![],
3085 separate_by: vec![],
3086 aggregate_by: None,
3087 depth: None,
3088 show_epochs: false,
3089 show_changes: false,
3090 },
3091 SectionConfig {
3092 id: "section2".to_string(),
3093 placeholder: "{{SECTION[section2]}}".to_string(),
3094 measurement_filter: None,
3095 key_value_filter: vec![],
3096 separate_by: vec![],
3097 aggregate_by: None,
3098 depth: None,
3099 show_epochs: true,
3100 show_changes: false,
3101 },
3102 ];
3103
3104 let merged = merge_show_flags(sections, true, true);
3105
3106 assert_eq!(merged.len(), 2);
3107 assert!(merged[0].show_epochs);
3109 assert!(merged[0].show_changes);
3110 assert!(merged[1].show_epochs);
3111 assert!(merged[1].show_changes);
3112 }
3113}