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 hover_text = format!(
679 "{}: {:+.1}%<br>Commit: {}<br>Confidence: {:.1}%",
680 symbol,
681 cp.magnitude_pct,
682 &cp.commit_sha[..8.min(cp.commit_sha.len())],
683 cp.confidence * 100.0
684 );
685
686 x_coords.push(x_pos);
688 y_coords.push(y_value);
689 hover_texts.push(hover_text);
690 marker_colors.push(color.to_string());
691 }
692
693 let trace = Scatter::new(x_coords, y_coords)
694 .mode(Mode::Markers)
695 .marker(
696 plotly::common::Marker::new()
697 .color_array(marker_colors)
698 .size(12),
699 )
700 .show_legend(true)
701 .hover_text_array(hover_texts);
702
703 let trace = Self::configure_trace_legend(
704 trace,
705 group_values,
706 measurement_name,
707 &measurement_display,
708 "Change Points",
709 "change_points",
710 );
711
712 self.current_plot.add_trace(trace);
713 }
714
715 fn prepare_hover_text(&self, indices: impl Iterator<Item = usize>) -> Vec<String> {
731 indices
732 .map(|idx| {
733 if let Some(commit) = self.all_commits.get(idx) {
735 format!(
736 "Commit: {}<br>Author: {}<br>Title: {}",
737 &commit.commit[..7.min(commit.commit.len())],
738 commit.author,
739 commit.title
740 )
741 } else {
742 format!("Commit index: {}", idx)
744 }
745 })
746 .collect()
747 }
748}
749
750impl<'a> Reporter<'a> for PlotlyReporter {
751 fn add_commits(&mut self, commits: &'a [Commit]) {
752 self.all_commits = commits.to_vec();
754 self.size = commits.len();
755 }
756
757 fn begin_section(&mut self, section_id: &str, placeholder: &str) {
758 self.current_section_id = Some(section_id.to_string());
759 self.current_placeholder = Some(placeholder.to_string());
760
761 let config = Configuration::default().responsive(true).fill_frame(false);
763 let mut plot = Plot::new();
764 plot.set_configuration(config);
765
766 let enumerated_commits = self.all_commits.iter().rev().enumerate();
768 let (commit_nrs, short_hashes): (Vec<_>, Vec<_>) = enumerated_commits
769 .map(|(n, c)| {
770 (
771 n as f64,
772 c.commit[..DEFAULT_COMMIT_HASH_DISPLAY_LENGTH].to_owned(),
773 )
774 })
775 .unzip();
776 let x_axis = Axis::new()
777 .tick_values(commit_nrs)
778 .tick_text(short_hashes)
779 .tick_angle(45.0)
780 .tick_font(Font::new().family("monospace"));
781 let layout = Layout::new()
782 .title(Title::from("Performance Measurements"))
783 .x_axis(x_axis)
784 .legend(
785 Legend::new()
786 .group_click(plotly::layout::GroupClick::ToggleItem)
787 .orientation(plotly::common::Orientation::Horizontal),
788 );
789
790 plot.set_layout(layout);
791 self.current_plot = plot;
792 self.measurement_units.clear();
793 }
794
795 fn end_section(&mut self) -> Result<SectionOutput> {
796 let section_id = self
797 .current_section_id
798 .take()
799 .ok_or_else(|| anyhow!("end_section called without begin_section"))?;
800
801 let placeholder = self
802 .current_placeholder
803 .take()
804 .ok_or_else(|| anyhow!("end_section called without placeholder"))?;
805
806 let final_plot = if let Some(y_axis) = self.compute_y_axis() {
808 let mut plot_with_y_axis = self.current_plot.clone();
809 let mut layout = plot_with_y_axis.layout().clone();
810 layout = layout.y_axis(y_axis);
811 plot_with_y_axis.set_layout(layout);
812 plot_with_y_axis
813 } else {
814 self.current_plot.clone()
815 };
816
817 let (_plotly_head, plotly_body) = extract_plotly_parts(&final_plot);
819
820 Ok(SectionOutput {
821 section_id,
822 placeholder,
823 content: plotly_body.into_bytes(),
824 })
825 }
826
827 fn finalize(
828 self: Box<Self>,
829 sections: Vec<SectionOutput>,
830 metadata: &ReportMetadata,
831 ) -> Vec<u8> {
832 if let Some(template) = self.template {
834 let mut output = template;
835
836 for section in §ions {
838 output = output.replace(
839 §ion.placeholder,
840 &String::from_utf8_lossy(§ion.content),
841 );
842 }
843
844 let (plotly_head, _) = extract_plotly_parts(&Plot::new());
846 output = output
847 .replace("{{TITLE}}", &metadata.title)
848 .replace("{{PLOTLY_HEAD}}", &plotly_head)
849 .replace("{{CUSTOM_CSS}}", &metadata.custom_css)
850 .replace("{{TIMESTAMP}}", &metadata.timestamp)
851 .replace("{{COMMIT_RANGE}}", &metadata.commit_range)
852 .replace("{{DEPTH}}", &metadata.depth.to_string())
853 .replace("{{AUDIT_SECTION}}", "");
854
855 output.into_bytes()
856 } else {
857 if sections.len() != 1 {
859 panic!("Multiple sections require template");
860 }
861 sections[0].content.clone()
862 }
863 }
864
865 fn add_trace(
866 &mut self,
867 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
868 measurement_name: &str,
869 group_values: &[String],
870 ) {
871 let indices: Vec<usize> = indexed_measurements.iter().map(|(i, _)| *i).collect();
873
874 let (x, y) = self.convert_to_x_y(
875 indexed_measurements
876 .into_iter()
877 .map(|(i, m)| (i, m.val))
878 .collect_vec(),
879 );
880
881 self.measurement_units
883 .push(config::measurement_unit(measurement_name));
884
885 let measurement_display = format_measurement_with_unit(measurement_name);
886
887 let hover_texts = self.prepare_hover_text(indices.into_iter());
889
890 let trace = plotly::BoxPlot::new_xy(x, y).hover_text_array(hover_texts);
891
892 let trace = if !group_values.is_empty() {
893 let group_label = group_values.join("/");
895 trace
896 .name(&group_label)
897 .legend_group(measurement_name)
898 .legend_group_title(LegendGroupTitle::from(measurement_display))
899 .show_legend(true)
900 } else {
901 trace.name(&measurement_display)
902 };
903
904 self.current_plot.add_trace(trace);
905 }
906
907 fn add_summarized_trace(
908 &mut self,
909 indexed_measurements: Vec<(usize, MeasurementSummary)>,
910 measurement_name: &str,
911 group_values: &[String],
912 ) {
913 let indices: Vec<usize> = indexed_measurements.iter().map(|(i, _)| *i).collect();
915
916 let (x, y) = self.convert_to_x_y(
917 indexed_measurements
918 .into_iter()
919 .map(|(i, m)| (i, m.val))
920 .collect_vec(),
921 );
922
923 self.measurement_units
925 .push(config::measurement_unit(measurement_name));
926
927 let measurement_display = format_measurement_with_unit(measurement_name);
928
929 let hover_texts = self.prepare_hover_text(indices.into_iter());
931
932 let trace = plotly::Scatter::new(x, y)
933 .name(&measurement_display)
934 .hover_text_array(hover_texts)
935 .hover_info(plotly::common::HoverInfo::Text);
936
937 let trace = if !group_values.is_empty() {
938 let group_label = group_values.join("/");
940 trace
941 .name(&group_label)
942 .legend_group(measurement_name)
943 .legend_group_title(LegendGroupTitle::from(measurement_display))
944 .show_legend(true)
945 } else {
946 trace.name(&measurement_display)
947 };
948
949 self.current_plot.add_trace(trace);
950 }
951
952 fn add_epoch_boundaries(
953 &mut self,
954 transitions: &[EpochTransition],
955 commit_indices: &[usize],
956 measurement_name: &str,
957 group_values: &[String],
958 y_min: f64,
959 y_max: f64,
960 ) {
961 self.add_epoch_boundary_traces(
962 transitions,
963 commit_indices,
964 measurement_name,
965 group_values,
966 y_min,
967 y_max,
968 );
969 }
970
971 fn add_change_points(
972 &mut self,
973 change_points: &[ChangePoint],
974 values: &[f64],
975 commit_indices: &[usize],
976 measurement_name: &str,
977 group_values: &[String],
978 ) {
979 self.add_change_point_traces_with_indices(
980 change_points,
981 values,
982 commit_indices,
983 measurement_name,
984 group_values,
985 );
986 }
987
988 fn as_bytes(&self) -> Vec<u8> {
989 let final_plot = if let Some(y_axis) = self.compute_y_axis() {
991 let mut plot_with_y_axis = self.current_plot.clone();
992 let mut layout = plot_with_y_axis.layout().clone();
993 layout = layout.y_axis(y_axis);
994 plot_with_y_axis.set_layout(layout);
995 plot_with_y_axis
996 } else {
997 self.current_plot.clone()
998 };
999
1000 let template = self.template.as_deref().unwrap_or(DEFAULT_HTML_TEMPLATE);
1003
1004 let default_metadata = ReportMetadata {
1006 title: "Performance Measurements".to_string(),
1007 custom_css: String::new(),
1008 timestamp: String::new(),
1009 commit_range: String::new(),
1010 depth: 0,
1011 };
1012 let metadata = self.metadata.as_ref().unwrap_or(&default_metadata);
1013
1014 let (plotly_head, plotly_body) = extract_plotly_parts(&final_plot);
1016 let output = template
1017 .replace("{{TITLE}}", &metadata.title)
1018 .replace("{{PLOTLY_HEAD}}", &plotly_head)
1019 .replace("{{PLOTLY_BODY}}", &plotly_body)
1020 .replace("{{CUSTOM_CSS}}", &metadata.custom_css)
1021 .replace("{{TIMESTAMP}}", &metadata.timestamp)
1022 .replace("{{COMMIT_RANGE}}", &metadata.commit_range)
1023 .replace("{{DEPTH}}", &metadata.depth.to_string())
1024 .replace("{{AUDIT_SECTION}}", ""); output.as_bytes().to_vec()
1027 }
1028}
1029
1030struct CsvReporter<'a> {
1031 hashes: Vec<String>,
1032 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
1033 summarized_measurements: Vec<(usize, String, Option<String>, MeasurementSummary)>,
1034}
1035
1036impl CsvReporter<'_> {
1037 fn new() -> Self {
1038 CsvReporter {
1039 hashes: Vec::new(),
1040 indexed_measurements: Vec::new(),
1041 summarized_measurements: Vec::new(),
1042 }
1043 }
1044}
1045
1046impl<'a> Reporter<'a> for CsvReporter<'a> {
1047 fn add_commits(&mut self, hashes: &'a [Commit]) {
1048 self.hashes = hashes.iter().map(|c| c.commit.to_owned()).collect();
1049 }
1050
1051 fn begin_section(&mut self, _section_id: &str, _placeholder: &str) {
1052 }
1054
1055 fn end_section(&mut self) -> Result<SectionOutput> {
1056 Ok(SectionOutput {
1059 section_id: "csv".to_string(),
1060 placeholder: String::new(),
1061 content: Vec::new(),
1062 })
1063 }
1064
1065 fn finalize(
1066 self: Box<Self>,
1067 _sections: Vec<SectionOutput>,
1068 _metadata: &ReportMetadata,
1069 ) -> Vec<u8> {
1070 if self.indexed_measurements.is_empty() && self.summarized_measurements.is_empty() {
1073 return Vec::new();
1074 }
1075
1076 let mut lines = Vec::new();
1077 lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
1078
1079 for (index, measurement_data) in &self.indexed_measurements {
1081 let commit = &self.hashes[*index];
1082 let row = CsvMeasurementRow::from_measurement(commit, measurement_data);
1083 lines.push(row.to_csv_line());
1084 }
1085
1086 for (index, measurement_name, group_value, summary) in &self.summarized_measurements {
1088 let commit = &self.hashes[*index];
1089 let row = CsvMeasurementRow::from_summary(
1090 commit,
1091 measurement_name,
1092 summary,
1093 group_value.as_ref(),
1094 );
1095 lines.push(row.to_csv_line());
1096 }
1097
1098 let mut output = lines.join("\n");
1099 output.push('\n');
1100 output.into_bytes()
1101 }
1102
1103 fn add_trace(
1104 &mut self,
1105 indexed_measurements: Vec<(usize, &'a MeasurementData)>,
1106 _measurement_name: &str,
1107 _group_values: &[String],
1108 ) {
1109 self.indexed_measurements
1110 .extend_from_slice(indexed_measurements.as_slice());
1111 }
1112
1113 fn as_bytes(&self) -> Vec<u8> {
1114 if self.indexed_measurements.is_empty() && self.summarized_measurements.is_empty() {
1115 return Vec::new();
1116 }
1117
1118 let mut lines = Vec::new();
1119
1120 lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
1122
1123 for (index, measurement_data) in &self.indexed_measurements {
1125 let commit = &self.hashes[*index];
1126 let row = CsvMeasurementRow::from_measurement(commit, measurement_data);
1127 lines.push(row.to_csv_line());
1128 }
1129
1130 for (index, measurement_name, group_value, summary) in &self.summarized_measurements {
1132 let commit = &self.hashes[*index];
1133 let row = CsvMeasurementRow::from_summary(
1134 commit,
1135 measurement_name,
1136 summary,
1137 group_value.as_ref(),
1138 );
1139 lines.push(row.to_csv_line());
1140 }
1141
1142 let mut output = lines.join("\n");
1143 output.push('\n');
1144 output.into_bytes()
1145 }
1146
1147 fn add_summarized_trace(
1148 &mut self,
1149 _indexed_measurements: Vec<(usize, MeasurementSummary)>,
1150 _measurement_name: &str,
1151 _group_values: &[String],
1152 ) {
1153 let group_label = if !_group_values.is_empty() {
1156 Some(_group_values.join("/"))
1157 } else {
1158 None
1159 };
1160
1161 for (index, summary) in _indexed_measurements.into_iter() {
1162 self.summarized_measurements.push((
1163 index,
1164 _measurement_name.to_string(),
1165 group_label.clone(),
1166 summary,
1167 ));
1168 }
1169 }
1170
1171 fn add_epoch_boundaries(
1172 &mut self,
1173 _transitions: &[EpochTransition],
1174 _commit_indices: &[usize],
1175 _measurement_name: &str,
1176 _group_values: &[String],
1177 _y_min: f64,
1178 _y_max: f64,
1179 ) {
1180 }
1182
1183 fn add_change_points(
1184 &mut self,
1185 _change_points: &[ChangePoint],
1186 _values: &[f64],
1187 _commit_indices: &[usize],
1188 _measurement_name: &str,
1189 _group_values: &[String],
1190 ) {
1191 }
1193}
1194
1195fn compute_group_values_to_process<'a>(
1203 filtered_measurements: impl Iterator<Item = &'a MeasurementData> + Clone,
1204 separate_by: &[String],
1205 context_id: &str, ) -> Result<Vec<Vec<String>>> {
1207 if separate_by.is_empty() {
1208 return Ok(vec![vec![]]);
1209 }
1210
1211 let group_values: Vec<Vec<String>> = filtered_measurements
1212 .filter_map(|m| {
1213 let values: Vec<String> = separate_by
1214 .iter()
1215 .filter_map(|key| m.key_values.get(key).cloned())
1216 .collect();
1217
1218 if values.len() == separate_by.len() {
1219 Some(values)
1220 } else {
1221 None
1222 }
1223 })
1224 .unique()
1225 .collect();
1226
1227 if group_values.is_empty() {
1228 bail!(
1229 "{}: Invalid separator supplied, no measurements have all required keys: {:?}",
1230 context_id,
1231 separate_by
1232 );
1233 }
1234
1235 Ok(group_values)
1236}
1237
1238fn filter_measurements_by_criteria<'a>(
1248 commits: &'a [Commit],
1249 filters: &[regex::Regex],
1250 key_values: &[(String, String)],
1251) -> Vec<Vec<&'a MeasurementData>> {
1252 commits
1253 .iter()
1254 .map(|commit| {
1255 commit
1256 .measurements
1257 .iter()
1258 .filter(|m| {
1259 if !filters.is_empty() && !crate::filter::matches_any_filter(&m.name, filters) {
1261 return false;
1262 }
1263 m.key_values_is_superset_of(key_values)
1265 })
1266 .collect()
1267 })
1268 .collect()
1269}
1270
1271fn collect_measurement_data_for_change_detection<'a>(
1276 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1277 commits: &[Commit],
1278 reduction_func: ReductionFunc,
1279) -> (Vec<usize>, Vec<f64>, Vec<u32>, Vec<String>) {
1280 let measurement_data: Vec<(usize, f64, u32, String)> = group_measurements
1281 .enumerate()
1282 .flat_map(|(i, ms)| {
1283 let commit_sha = commits[i].commit.clone();
1284 ms.reduce_by(reduction_func)
1285 .into_iter()
1286 .map(move |m| (i, m.val, m.epoch, commit_sha.clone()))
1287 })
1288 .collect();
1289
1290 let commit_indices: Vec<usize> = measurement_data.iter().map(|(i, _, _, _)| *i).collect();
1291 let values: Vec<f64> = measurement_data.iter().map(|(_, v, _, _)| *v).collect();
1292 let epochs: Vec<u32> = measurement_data.iter().map(|(_, _, e, _)| *e).collect();
1293 let commit_shas: Vec<String> = measurement_data
1294 .iter()
1295 .map(|(_, _, _, s)| s.clone())
1296 .collect();
1297
1298 (commit_indices, values, epochs, commit_shas)
1299}
1300
1301fn add_trace_for_measurement_group<'a>(
1306 reporter: &mut dyn Reporter<'a>,
1307 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1308 measurement_name: &str,
1309 group_value: &[String],
1310 aggregate_by: Option<ReductionFunc>,
1311) {
1312 if let Some(reduction_func) = aggregate_by {
1313 let trace_measurements = group_measurements
1314 .enumerate()
1315 .flat_map(move |(i, ms)| {
1316 ms.reduce_by(reduction_func)
1317 .into_iter()
1318 .map(move |m| (i, m))
1319 })
1320 .collect_vec();
1321
1322 reporter.add_summarized_trace(trace_measurements, measurement_name, group_value);
1323 } else {
1324 let trace_measurements: Vec<_> = group_measurements
1325 .enumerate()
1326 .flat_map(|(i, ms)| ms.map(move |m| (i, m)))
1327 .collect();
1328
1329 reporter.add_trace(trace_measurements, measurement_name, group_value);
1330 }
1331}
1332
1333struct PreparedDetectionData {
1335 indices: Vec<usize>,
1337 values: Vec<f64>,
1339 epochs: Vec<u32>,
1341 commit_shas: Vec<String>,
1343 y_min: f64,
1345 y_max: f64,
1347}
1348
1349fn prepare_detection_data(params: &ChangePointDetectionParams) -> Option<PreparedDetectionData> {
1358 if params.values.is_empty() {
1359 return None;
1360 }
1361
1362 log::debug!(
1363 "Preparing detection data for {}: {} measurements, indices {:?}, epochs {:?}",
1364 params.measurement_name,
1365 params.values.len(),
1366 params.commit_indices,
1367 params.epochs
1368 );
1369
1370 let y_min = params.values.iter().copied().fold(f64::INFINITY, f64::min) * 0.9;
1372 let y_max = params
1373 .values
1374 .iter()
1375 .copied()
1376 .fold(f64::NEG_INFINITY, f64::max)
1377 * 1.1;
1378
1379 Some(PreparedDetectionData {
1384 indices: params.commit_indices.iter().rev().copied().collect(),
1385 values: params.values.iter().rev().copied().collect(),
1386 epochs: params.epochs.iter().rev().copied().collect(),
1387 commit_shas: params.commit_shas.iter().rev().cloned().collect(),
1388 y_min,
1389 y_max,
1390 })
1391}
1392
1393fn add_epoch_traces(
1397 reporter: &mut dyn Reporter,
1398 params: &ChangePointDetectionParams,
1399 prepared: &PreparedDetectionData,
1400) {
1401 let transitions = crate::change_point::detect_epoch_transitions(&prepared.epochs);
1402 log::debug!(
1403 "Epoch transitions for {}: {:?}",
1404 params.measurement_name,
1405 transitions
1406 );
1407 reporter.add_epoch_boundaries(
1408 &transitions,
1409 &prepared.indices,
1410 params.measurement_name,
1411 params.group_values,
1412 prepared.y_min,
1413 prepared.y_max,
1414 );
1415}
1416
1417fn add_change_point_traces(
1422 reporter: &mut dyn Reporter,
1423 params: &ChangePointDetectionParams,
1424 prepared: &PreparedDetectionData,
1425) {
1426 let config = crate::config::change_point_config(params.measurement_name);
1427 let raw_cps = crate::change_point::detect_change_points(&prepared.values, &config);
1428 log::debug!(
1429 "Raw change points for {}: {:?}",
1430 params.measurement_name,
1431 raw_cps
1432 );
1433
1434 let enriched_cps = crate::change_point::enrich_change_points(
1435 &raw_cps,
1436 &prepared.values,
1437 &prepared.commit_shas,
1438 &config,
1439 );
1440 log::debug!(
1441 "Enriched change points for {}: {:?}",
1442 params.measurement_name,
1443 enriched_cps
1444 );
1445
1446 reporter.add_change_points(
1447 &enriched_cps,
1448 &prepared.values,
1449 &prepared.indices,
1450 params.measurement_name,
1451 params.group_values,
1452 );
1453}
1454
1455fn add_change_point_and_epoch_traces(
1465 reporter: &mut dyn Reporter,
1466 params: ChangePointDetectionParams,
1467) {
1468 let Some(prepared) = prepare_detection_data(¶ms) else {
1469 return;
1470 };
1471
1472 if params.show_epochs {
1473 add_epoch_traces(reporter, ¶ms, &prepared);
1474 }
1475
1476 if params.show_changes {
1477 add_change_point_traces(reporter, ¶ms, &prepared);
1478 }
1479}
1480
1481#[allow(clippy::too_many_arguments)]
1499fn add_detection_traces_if_requested<'a>(
1500 reporter: &mut dyn Reporter<'a>,
1501 group_measurements: impl Iterator<Item = impl Iterator<Item = &'a MeasurementData>> + Clone,
1502 commits: &[Commit],
1503 measurement_name: &str,
1504 group_value: &[String],
1505 aggregate_by: Option<ReductionFunc>,
1506 show_epochs: bool,
1507 show_changes: bool,
1508) {
1509 if !show_epochs && !show_changes {
1510 return;
1511 }
1512
1513 let reduction_func = aggregate_by.unwrap_or(ReductionFunc::Min);
1514
1515 let (commit_indices, values, epochs, commit_shas) =
1516 collect_measurement_data_for_change_detection(group_measurements, commits, reduction_func);
1517
1518 let detection_params = ChangePointDetectionParams {
1519 commit_indices: &commit_indices,
1520 values: &values,
1521 epochs: &epochs,
1522 commit_shas: &commit_shas,
1523 measurement_name,
1524 group_values: group_value,
1525 show_epochs,
1526 show_changes,
1527 };
1528
1529 add_change_point_and_epoch_traces(reporter, detection_params);
1530}
1531
1532fn wrap_patterns_for_regex(patterns: &[String]) -> Option<String> {
1535 if patterns.is_empty() {
1536 None
1537 } else {
1538 Some(
1539 patterns
1540 .iter()
1541 .map(|p| format!("(?:{})", p))
1542 .collect::<Vec<_>>()
1543 .join("|"),
1544 )
1545 }
1546}
1547
1548fn build_single_section_config(
1551 combined_patterns: &[String],
1552 key_values: &[(String, String)],
1553 separate_by: Vec<String>,
1554 aggregate_by: Option<ReductionFunc>,
1555 show_epochs: bool,
1556 show_changes: bool,
1557) -> SectionConfig {
1558 SectionConfig {
1559 id: "main".to_string(),
1560 placeholder: "{{PLOTLY_BODY}}".to_string(),
1561 measurement_filter: wrap_patterns_for_regex(combined_patterns),
1562 key_value_filter: key_values.to_vec(),
1563 separate_by,
1564 aggregate_by,
1565 depth: None,
1566 show_epochs,
1567 show_changes,
1568 }
1569}
1570
1571fn merge_show_flags(
1574 sections: Vec<SectionConfig>,
1575 global_show_epochs: bool,
1576 global_show_changes: bool,
1577) -> Vec<SectionConfig> {
1578 sections
1579 .into_iter()
1580 .map(|sc| SectionConfig {
1581 show_epochs: sc.show_epochs || global_show_epochs,
1582 show_changes: sc.show_changes || global_show_changes,
1583 ..sc
1584 })
1585 .collect()
1586}
1587
1588#[allow(clippy::too_many_arguments)]
1595fn prepare_sections_and_metadata(
1596 output_format: OutputFormat,
1597 template_config: &ReportTemplateConfig,
1598 combined_patterns: &[String],
1599 key_values: &[(String, String)],
1600 separate_by: Vec<String>,
1601 aggregate_by: Option<ReductionFunc>,
1602 show_epochs: bool,
1603 show_changes: bool,
1604 commits: &[Commit],
1605) -> Result<(Vec<SectionConfig>, Option<String>, ReportMetadata)> {
1606 match output_format {
1607 OutputFormat::Html => {
1608 let template_path = template_config
1610 .template_path
1611 .clone()
1612 .or(config::report_template_path());
1613 let template_str = if let Some(path) = template_path {
1614 load_template(&path)?
1615 } else {
1616 DEFAULT_HTML_TEMPLATE.to_string()
1617 };
1618
1619 let sections = match parse_template_sections(&template_str)? {
1621 sections if sections.is_empty() => {
1622 log::info!(
1623 "Single-section template detected. Using CLI arguments for filtering/aggregation."
1624 );
1625 vec![build_single_section_config(
1626 combined_patterns,
1627 key_values,
1628 separate_by,
1629 aggregate_by,
1630 show_epochs,
1631 show_changes,
1632 )]
1633 }
1634 sections => {
1635 log::info!(
1636 "Multi-section template detected with {} sections. CLI arguments for filtering/aggregation will be ignored.",
1637 sections.len()
1638 );
1639 merge_show_flags(sections, show_epochs, show_changes)
1640 }
1641 };
1642
1643 let resolved_title = template_config.title.clone().or_else(config::report_title);
1645 let custom_css_content = load_custom_css(template_config.custom_css_path.as_ref())?;
1646 let metadata = ReportMetadata::new(resolved_title, custom_css_content, commits);
1647
1648 Ok((sections, Some(template_str), metadata))
1649 }
1650 OutputFormat::Csv => {
1651 if template_config.template_path.is_some() {
1653 log::warn!("Template argument is ignored for CSV output format");
1654 }
1655
1656 let section = build_single_section_config(
1658 combined_patterns,
1659 key_values,
1660 separate_by,
1661 aggregate_by,
1662 show_epochs,
1663 show_changes,
1664 );
1665
1666 let metadata = ReportMetadata::new(None, String::new(), commits);
1668
1669 Ok((vec![section], None, metadata))
1670 }
1671 }
1672}
1673
1674fn process_section<'a>(
1679 reporter: &mut dyn Reporter<'a>,
1680 commits: &'a [Commit],
1681 section: &SectionConfig,
1682) -> Result<SectionOutput> {
1683 reporter.begin_section(§ion.id, §ion.placeholder);
1684
1685 let section_commits = if let Some(depth) = section.depth {
1687 if depth > commits.len() {
1688 log::warn!(
1689 "Section '{}' requested depth {} but only {} commits available",
1690 section.id,
1691 depth,
1692 commits.len()
1693 );
1694 commits
1695 } else {
1696 &commits[..depth]
1697 }
1698 } else {
1699 commits
1700 };
1701
1702 let filters = if let Some(ref pattern) = section.measurement_filter {
1704 crate::filter::compile_filters(std::slice::from_ref(pattern))?
1705 } else {
1706 vec![]
1707 };
1708
1709 let relevant_measurements: Vec<Vec<&MeasurementData>> =
1710 filter_measurements_by_criteria(section_commits, &filters, §ion.key_value_filter);
1711
1712 let unique_measurement_names: Vec<_> = relevant_measurements
1713 .iter()
1714 .flat_map(|ms| ms.iter().map(|m| &m.name))
1715 .unique()
1716 .collect();
1717
1718 if unique_measurement_names.is_empty() {
1719 log::warn!("Section '{}' has no matching measurements", section.id);
1720 return Ok(SectionOutput {
1722 section_id: section.id.clone(),
1723 placeholder: section.placeholder.clone(),
1724 content: Vec::new(),
1725 });
1726 }
1727
1728 for measurement_name in unique_measurement_names {
1730 let filtered_for_grouping = relevant_measurements
1731 .iter()
1732 .flat_map(|ms| ms.iter().copied().filter(|m| m.name == *measurement_name));
1733
1734 let group_values_to_process = compute_group_values_to_process(
1735 filtered_for_grouping,
1736 §ion.separate_by,
1737 &format!("Section '{}'", section.id),
1738 )?;
1739
1740 for group_value in group_values_to_process {
1741 let group_measurements_vec: Vec<Vec<&MeasurementData>> = relevant_measurements
1742 .iter()
1743 .map(|ms| {
1744 ms.iter()
1745 .filter(|m| {
1746 if m.name != *measurement_name {
1747 return false;
1748 }
1749 if group_value.is_empty() {
1750 return true;
1751 }
1752 section.separate_by.iter().zip(group_value.iter()).all(
1753 |(key, expected_val)| {
1754 m.key_values
1755 .get(key)
1756 .map(|v| v == expected_val)
1757 .unwrap_or(false)
1758 },
1759 )
1760 })
1761 .copied()
1762 .collect()
1763 })
1764 .collect();
1765
1766 add_trace_for_measurement_group(
1768 reporter,
1769 group_measurements_vec.iter().map(|v| v.iter().copied()),
1770 measurement_name,
1771 &group_value,
1772 section.aggregate_by,
1773 );
1774
1775 add_detection_traces_if_requested(
1777 reporter,
1778 group_measurements_vec.iter().map(|v| v.iter().copied()),
1779 section_commits,
1780 measurement_name,
1781 &group_value,
1782 section.aggregate_by,
1783 section.show_epochs,
1784 section.show_changes,
1785 );
1786 }
1787 }
1788
1789 reporter.end_section()
1790}
1791
1792#[allow(clippy::too_many_arguments)]
1793pub fn report(
1794 start_commit: &str,
1795 output: PathBuf,
1796 separate_by: Vec<String>,
1797 num_commits: usize,
1798 key_values: &[(String, String)],
1799 aggregate_by: Option<ReductionFunc>,
1800 combined_patterns: &[String],
1801 template_config: ReportTemplateConfig,
1802 show_epochs: bool,
1803 show_changes: bool,
1804) -> Result<()> {
1805 let _filters = crate::filter::compile_filters(combined_patterns)?;
1808
1809 let commits: Vec<Commit> =
1810 measurement_retrieval::walk_commits_from(start_commit, num_commits)?.try_collect()?;
1811
1812 if commits.is_empty() {
1813 bail!(
1814 "No commits found in repository. Ensure commits exist and were pushed to the remote."
1815 );
1816 }
1817
1818 let output_format = OutputFormat::from_path(&output)
1820 .ok_or_else(|| anyhow!("Could not determine output format from file extension"))?;
1821
1822 let (sections, template_str, metadata) = prepare_sections_and_metadata(
1824 output_format,
1825 &template_config,
1826 combined_patterns,
1827 key_values,
1828 separate_by.clone(),
1829 aggregate_by,
1830 show_epochs,
1831 show_changes,
1832 &commits,
1833 )?;
1834
1835 let mut reporter: Box<dyn Reporter> = match output_format {
1837 OutputFormat::Html => {
1838 let template = template_str.expect("HTML requires template");
1839 Box::new(PlotlyReporter::with_template(template, metadata.clone()))
1840 }
1841 OutputFormat::Csv => Box::new(CsvReporter::new()),
1842 };
1843
1844 reporter.add_commits(&commits);
1846
1847 let section_outputs = sections
1848 .iter()
1849 .map(|section| process_section(&mut *reporter, &commits, section))
1850 .collect::<Result<Vec<SectionOutput>>>()?;
1851
1852 let has_measurements_from_sections = section_outputs.iter().any(|s| !s.content.is_empty());
1856
1857 let report_bytes = reporter.finalize(section_outputs, &metadata);
1859
1860 let has_measurements = match output_format {
1863 OutputFormat::Html => has_measurements_from_sections,
1864 OutputFormat::Csv => !report_bytes.is_empty(),
1865 };
1866
1867 let is_multi_section = sections.len() > 1;
1871 if !is_multi_section && !has_measurements {
1872 bail!("No performance measurements found.");
1873 }
1874
1875 write_output(&output, &report_bytes)?;
1877
1878 Ok(())
1879}
1880
1881#[cfg(test)]
1882mod tests {
1883 use super::*;
1884
1885 #[test]
1886 fn test_convert_to_x_y_empty() {
1887 let reporter = PlotlyReporter::new();
1888 let (x, y) = reporter.convert_to_x_y(vec![]);
1889 assert!(x.is_empty());
1890 assert!(y.is_empty());
1891 }
1892
1893 #[test]
1894 fn test_convert_to_x_y_single_value() {
1895 let mut reporter = PlotlyReporter::new();
1896 reporter.size = 3;
1897 let (x, y) = reporter.convert_to_x_y(vec![(0, 1.5)]);
1898 assert_eq!(x, vec![2]);
1899 assert_eq!(y, vec![1.5]);
1900 }
1901
1902 #[test]
1903 fn test_convert_to_x_y_multiple_values() {
1904 let mut reporter = PlotlyReporter::new();
1905 reporter.size = 5;
1906 let (x, y) = reporter.convert_to_x_y(vec![(0, 10.0), (2, 20.0), (4, 30.0)]);
1907 assert_eq!(x, vec![4, 2, 0]);
1908 assert_eq!(y, vec![10.0, 20.0, 30.0]);
1909 }
1910
1911 #[test]
1912 fn test_convert_to_x_y_negative_values() {
1913 let mut reporter = PlotlyReporter::new();
1914 reporter.size = 2;
1915 let (x, y) = reporter.convert_to_x_y(vec![(0, -5.5), (1, -10.2)]);
1916 assert_eq!(x, vec![1, 0]);
1917 assert_eq!(y, vec![-5.5, -10.2]);
1918 }
1919
1920 #[test]
1921 fn test_plotly_reporter_as_bytes_not_empty() {
1922 let reporter = PlotlyReporter::new();
1923 let bytes = reporter.as_bytes();
1924 assert!(!bytes.is_empty());
1925 let html = String::from_utf8_lossy(&bytes);
1927 assert!(html.contains("plotly") || html.contains("Plotly"));
1928 }
1929
1930 #[test]
1931 fn test_plotly_reporter_uses_default_template() {
1932 let reporter = PlotlyReporter::new();
1933 let bytes = reporter.as_bytes();
1934 let html = String::from_utf8_lossy(&bytes);
1935
1936 assert!(html.contains("<!DOCTYPE html>"));
1938 assert!(html.contains("<html>"));
1939 assert!(html.contains("<head>"));
1940 assert!(html.contains("<title>Performance Measurements</title>"));
1941 assert!(html.contains("</head>"));
1942 assert!(html.contains("<body>"));
1943 assert!(html.contains("</body>"));
1944 assert!(html.contains("</html>"));
1945 assert!(html.contains("plotly") || html.contains("Plotly"));
1947 }
1948
1949 #[test]
1950 fn test_format_measurement_with_unit_no_unit() {
1951 let result = format_measurement_with_unit("unknown_measurement");
1953 assert_eq!(result, "unknown_measurement");
1954 }
1955
1956 #[test]
1957 fn test_extract_plotly_parts() {
1958 let mut plot = Plot::new();
1960 let trace = plotly::Scatter::new(vec![1, 2, 3], vec![4, 5, 6]).name("test");
1961 plot.add_trace(trace);
1962
1963 let (head, body) = extract_plotly_parts(&plot);
1964
1965 assert!(head.contains("<script"));
1967 assert!(head.contains("plotly"));
1968
1969 assert!(body.contains("<div"));
1971 assert!(body.contains("<script"));
1972 assert!(body.contains("Plotly.newPlot"));
1973 }
1974
1975 #[test]
1976 fn test_extract_plotly_parts_structure() {
1977 let mut plot = Plot::new();
1979 let trace = plotly::Scatter::new(vec![1], vec![1]).name("data");
1980 plot.add_trace(trace);
1981
1982 let (head, body) = extract_plotly_parts(&plot);
1983
1984 assert!(!head.contains("<html>"));
1986 assert!(!head.contains("<head>"));
1987 assert!(!head.contains("<body>"));
1988
1989 assert!(!body.contains("<html>"));
1991 assert!(!body.contains("<head>"));
1992 assert!(!body.contains("<body>"));
1993 }
1994
1995 #[test]
1996 fn test_report_metadata_new() {
1997 use crate::data::Commit;
1998
1999 let commits = vec![
2000 Commit {
2001 commit: "abc1234567890".to_string(),
2002 title: "test: commit 1".to_string(),
2003 author: "Test Author".to_string(),
2004 measurements: vec![],
2005 },
2006 Commit {
2007 commit: "def0987654321".to_string(),
2008 title: "test: commit 2".to_string(),
2009 author: "Test Author".to_string(),
2010 measurements: vec![],
2011 },
2012 ];
2013
2014 let metadata =
2015 ReportMetadata::new(Some("Custom Title".to_string()), "".to_string(), &commits);
2016
2017 assert_eq!(metadata.title, "Custom Title");
2018 assert_eq!(metadata.commit_range, "def0987..abc1234");
2019 assert_eq!(metadata.depth, 2);
2020 }
2021
2022 #[test]
2023 fn test_report_metadata_new_default_title() {
2024 use crate::data::Commit;
2025
2026 let commits = vec![Commit {
2027 commit: "abc1234567890".to_string(),
2028 title: "test: commit".to_string(),
2029 author: "Test Author".to_string(),
2030 measurements: vec![],
2031 }];
2032
2033 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2034
2035 assert_eq!(metadata.title, "Performance Measurements");
2036 assert_eq!(metadata.commit_range, "abc1234");
2037 assert_eq!(metadata.depth, 1);
2038 }
2039
2040 #[test]
2041 fn test_report_metadata_new_empty_commits() {
2042 let commits = vec![];
2043 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2044
2045 assert_eq!(metadata.commit_range, "No commits");
2046 assert_eq!(metadata.depth, 0);
2047 }
2048
2049 #[test]
2050 fn test_compute_y_axis_empty_measurements() {
2051 let reporter = PlotlyReporter::new();
2052 let y_axis = reporter.compute_y_axis();
2053 assert!(y_axis.is_none());
2054 }
2055
2056 #[test]
2057 fn test_compute_y_axis_single_unit() {
2058 let mut reporter = PlotlyReporter::new();
2059 reporter.measurement_units.push(Some("ms".to_string()));
2060 reporter.measurement_units.push(Some("ms".to_string()));
2061 reporter.measurement_units.push(Some("ms".to_string()));
2062
2063 let y_axis = reporter.compute_y_axis();
2064 assert!(y_axis.is_some());
2065 }
2066
2067 #[test]
2068 fn test_compute_y_axis_mixed_units() {
2069 let mut reporter = PlotlyReporter::new();
2070 reporter.measurement_units.push(Some("ms".to_string()));
2071 reporter.measurement_units.push(Some("bytes".to_string()));
2072
2073 let y_axis = reporter.compute_y_axis();
2074 assert!(y_axis.is_none());
2075 }
2076
2077 #[test]
2078 fn test_compute_y_axis_no_units() {
2079 let mut reporter = PlotlyReporter::new();
2080 reporter.measurement_units.push(None);
2081 reporter.measurement_units.push(None);
2082
2083 let y_axis = reporter.compute_y_axis();
2084 assert!(y_axis.is_none());
2085 }
2086
2087 #[test]
2088 fn test_compute_y_axis_some_with_unit_some_without() {
2089 let mut reporter = PlotlyReporter::new();
2090 reporter.measurement_units.push(Some("ms".to_string()));
2091 reporter.measurement_units.push(None);
2092
2093 let y_axis = reporter.compute_y_axis();
2094 assert!(y_axis.is_none());
2095 }
2096
2097 #[test]
2098 fn test_plotly_reporter_adds_units_to_legend() {
2099 use crate::data::Commit;
2100
2101 let mut reporter = PlotlyReporter::new();
2102
2103 let commits = vec![
2105 Commit {
2106 commit: "abc123".to_string(),
2107 title: "test: commit 1".to_string(),
2108 author: "Test Author".to_string(),
2109 measurements: vec![],
2110 },
2111 Commit {
2112 commit: "def456".to_string(),
2113 title: "test: commit 2".to_string(),
2114 author: "Test Author".to_string(),
2115 measurements: vec![],
2116 },
2117 ];
2118 reporter.add_commits(&commits);
2119
2120 reporter.measurement_units.push(Some("ms".to_string()));
2122
2123 let bytes = reporter.as_bytes();
2125 let html = String::from_utf8_lossy(&bytes);
2126
2127 assert!(!html.is_empty());
2129 assert!(html.contains("plotly") || html.contains("Plotly"));
2130 }
2131
2132 #[test]
2133 fn test_plotly_reporter_y_axis_with_same_units() {
2134 let mut reporter = PlotlyReporter::new();
2135
2136 reporter.measurement_units.push(Some("ms".to_string()));
2138 reporter.measurement_units.push(Some("ms".to_string()));
2139
2140 let bytes = reporter.as_bytes();
2142 let html = String::from_utf8_lossy(&bytes);
2143
2144 assert!(html.contains("Value (ms)"));
2146 }
2147
2148 #[test]
2149 fn test_plotly_reporter_no_y_axis_with_mixed_units() {
2150 let mut reporter = PlotlyReporter::new();
2151
2152 reporter.measurement_units.push(Some("ms".to_string()));
2154 reporter.measurement_units.push(Some("bytes".to_string()));
2155
2156 let bytes = reporter.as_bytes();
2158 let html = String::from_utf8_lossy(&bytes);
2159
2160 assert!(!html.contains("Value (ms)"));
2162 assert!(!html.contains("Value (bytes)"));
2163 }
2164
2165 #[test]
2166 fn test_csv_reporter_as_bytes_empty_on_init() {
2167 let reporter = CsvReporter::new();
2168 let bytes = reporter.as_bytes();
2169 assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
2171 }
2172
2173 #[test]
2174 fn test_csv_reporter_includes_header() {
2175 use crate::data::{Commit, MeasurementData};
2176 use std::collections::HashMap;
2177
2178 let mut reporter = CsvReporter::new();
2179
2180 let commits = vec![Commit {
2182 commit: "abc123".to_string(),
2183 title: "test: commit".to_string(),
2184 author: "Test Author".to_string(),
2185 measurements: vec![],
2186 }];
2187 reporter.add_commits(&commits);
2188
2189 let measurement = MeasurementData {
2191 epoch: 0,
2192 name: "test_measurement".to_string(),
2193 timestamp: 1234.0,
2194 val: 42.5,
2195 key_values: HashMap::new(),
2196 };
2197 reporter.add_trace(vec![(0, &measurement)], "test_measurement", &[]);
2198
2199 let bytes = reporter.as_bytes();
2201 let csv = String::from_utf8_lossy(&bytes);
2202
2203 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2205
2206 assert!(csv.contains("abc123"));
2208 assert!(csv.contains("test_measurement"));
2209 assert!(csv.contains("42.5"));
2210 }
2211
2212 #[test]
2213 fn test_csv_exact_output_single_measurement() {
2214 use crate::data::{Commit, MeasurementData};
2215 use std::collections::HashMap;
2216
2217 let mut reporter = CsvReporter::new();
2218
2219 let commits = vec![Commit {
2220 commit: "abc123def456".to_string(),
2221 title: "test: commit".to_string(),
2222 author: "Test Author".to_string(),
2223 measurements: vec![],
2224 }];
2225 reporter.add_commits(&commits);
2226
2227 let measurement = MeasurementData {
2228 epoch: 0,
2229 name: "build_time".to_string(),
2230 timestamp: 1234567890.5,
2231 val: 42.0,
2232 key_values: HashMap::new(),
2233 };
2234 reporter.add_trace(vec![(0, &measurement)], "build_time", &[]);
2235
2236 let bytes = reporter.as_bytes();
2237 let csv = String::from_utf8_lossy(&bytes);
2238
2239 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc123def456\t0\tbuild_time\t1234567890.5\t42.0\t\n";
2240 assert_eq!(csv, expected);
2241 }
2242
2243 #[test]
2244 fn test_csv_exact_output_with_metadata() {
2245 use crate::data::{Commit, MeasurementData};
2246 use std::collections::HashMap;
2247
2248 let mut reporter = CsvReporter::new();
2249
2250 let commits = vec![Commit {
2251 commit: "commit123".to_string(),
2252 title: "test: commit".to_string(),
2253 author: "Test Author".to_string(),
2254 measurements: vec![],
2255 }];
2256 reporter.add_commits(&commits);
2257
2258 let mut metadata = HashMap::new();
2259 metadata.insert("os".to_string(), "linux".to_string());
2260 metadata.insert("arch".to_string(), "x64".to_string());
2261
2262 let measurement = MeasurementData {
2263 epoch: 1,
2264 name: "test".to_string(),
2265 timestamp: 1000.0,
2266 val: 3.5,
2267 key_values: metadata,
2268 };
2269 reporter.add_trace(vec![(0, &measurement)], "test", &[]);
2270
2271 let bytes = reporter.as_bytes();
2272 let csv = String::from_utf8_lossy(&bytes);
2273
2274 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2276 assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
2277 assert!(csv.contains("os=linux"));
2279 assert!(csv.contains("arch=x64"));
2280 assert!(csv.ends_with('\n'));
2282 }
2283
2284 #[test]
2285 fn test_csv_exact_output_multiple_measurements() {
2286 use crate::data::{Commit, MeasurementData};
2287 use std::collections::HashMap;
2288
2289 let mut reporter = CsvReporter::new();
2290
2291 let commits = vec![
2292 Commit {
2293 commit: "commit1".to_string(),
2294 title: "test: commit 1".to_string(),
2295 author: "Test Author".to_string(),
2296 measurements: vec![],
2297 },
2298 Commit {
2299 commit: "commit2".to_string(),
2300 title: "test: commit 2".to_string(),
2301 author: "Test Author".to_string(),
2302 measurements: vec![],
2303 },
2304 ];
2305 reporter.add_commits(&commits);
2306
2307 let m1 = MeasurementData {
2308 epoch: 0,
2309 name: "timer".to_string(),
2310 timestamp: 100.0,
2311 val: 1.5,
2312 key_values: HashMap::new(),
2313 };
2314
2315 let m2 = MeasurementData {
2316 epoch: 0,
2317 name: "timer".to_string(),
2318 timestamp: 200.0,
2319 val: 2.0,
2320 key_values: HashMap::new(),
2321 };
2322
2323 reporter.add_trace(vec![(0, &m1), (1, &m2)], "timer", &[]);
2324
2325 let bytes = reporter.as_bytes();
2326 let csv = String::from_utf8_lossy(&bytes);
2327
2328 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n\
2329 commit1\t0\ttimer\t100.0\t1.5\t\n\
2330 commit2\t0\ttimer\t200.0\t2.0\t\n";
2331 assert_eq!(csv, expected);
2332 }
2333
2334 #[test]
2335 fn test_csv_exact_output_whole_number_formatting() {
2336 use crate::data::{Commit, MeasurementData};
2337 use std::collections::HashMap;
2338
2339 let mut reporter = CsvReporter::new();
2340
2341 let commits = vec![Commit {
2342 commit: "hash1".to_string(),
2343 title: "test: commit".to_string(),
2344 author: "Test Author".to_string(),
2345 measurements: vec![],
2346 }];
2347 reporter.add_commits(&commits);
2348
2349 let measurement = MeasurementData {
2350 epoch: 0,
2351 name: "count".to_string(),
2352 timestamp: 500.0,
2353 val: 10.0,
2354 key_values: HashMap::new(),
2355 };
2356 reporter.add_trace(vec![(0, &measurement)], "count", &[]);
2357
2358 let bytes = reporter.as_bytes();
2359 let csv = String::from_utf8_lossy(&bytes);
2360
2361 let expected =
2363 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nhash1\t0\tcount\t500.0\t10.0\t\n";
2364 assert_eq!(csv, expected);
2365 }
2366
2367 #[test]
2368 fn test_csv_exact_output_summarized_measurement() {
2369 use crate::data::{Commit, MeasurementSummary};
2370
2371 let mut reporter = CsvReporter::new();
2372
2373 let commits = vec![Commit {
2374 commit: "abc".to_string(),
2375 title: "test: commit".to_string(),
2376 author: "Test Author".to_string(),
2377 measurements: vec![],
2378 }];
2379 reporter.add_commits(&commits);
2380
2381 let summary = MeasurementSummary { epoch: 0, val: 5.5 };
2382
2383 reporter.add_summarized_trace(vec![(0, summary)], "avg_time", &[]);
2384
2385 let bytes = reporter.as_bytes();
2386 let csv = String::from_utf8_lossy(&bytes);
2387
2388 let expected =
2390 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc\t0\tavg_time\t0.0\t5.5\t\n";
2391 assert_eq!(csv, expected);
2392 }
2393
2394 #[test]
2395 fn test_epoch_boundary_traces_hidden_by_default() {
2396 use crate::change_point::EpochTransition;
2397 use crate::data::Commit;
2398
2399 let mut reporter = PlotlyReporter::new();
2400
2401 let commits = vec![
2402 Commit {
2403 commit: "abc123".to_string(),
2404 title: "test: commit 1".to_string(),
2405 author: "Test Author".to_string(),
2406 measurements: vec![],
2407 },
2408 Commit {
2409 commit: "def456".to_string(),
2410 title: "test: commit 2".to_string(),
2411 author: "Test Author".to_string(),
2412 measurements: vec![],
2413 },
2414 Commit {
2415 commit: "ghi789".to_string(),
2416 title: "test: commit 3".to_string(),
2417 author: "Test Author".to_string(),
2418 measurements: vec![],
2419 },
2420 ];
2421 reporter.add_commits(&commits);
2422
2423 let transitions = vec![EpochTransition {
2424 index: 1,
2425 from_epoch: 1,
2426 to_epoch: 2,
2427 }];
2428
2429 let commit_indices = vec![0, 1, 2];
2430 let group_values: Vec<String> = vec![];
2431 reporter.add_epoch_boundary_traces(
2432 &transitions,
2433 &commit_indices,
2434 "test_metric",
2435 &group_values,
2436 0.0,
2437 100.0,
2438 );
2439
2440 let bytes = reporter.as_bytes();
2441 let html = String::from_utf8_lossy(&bytes);
2442 assert!(html.contains("legendonly"));
2444 assert!(html.contains("test_metric (Epochs)"));
2446 }
2447
2448 #[test]
2449 fn test_epoch_boundary_traces_empty() {
2450 use crate::change_point::EpochTransition;
2451
2452 let mut reporter = PlotlyReporter::new();
2453 reporter.size = 10;
2454
2455 let transitions: Vec<EpochTransition> = vec![];
2456 let commit_indices: Vec<usize> = vec![];
2457 let group_values: Vec<String> = vec![];
2458 reporter.add_epoch_boundary_traces(
2459 &transitions,
2460 &commit_indices,
2461 "test",
2462 &group_values,
2463 0.0,
2464 100.0,
2465 );
2466
2467 let bytes = reporter.as_bytes();
2469 assert!(!bytes.is_empty());
2470 }
2471
2472 #[test]
2473 fn test_change_point_traces_hidden_by_default() {
2474 use crate::change_point::{ChangeDirection, ChangePoint};
2475 use crate::data::Commit;
2476
2477 let mut reporter = PlotlyReporter::new();
2478
2479 let commits = vec![
2480 Commit {
2481 commit: "abc123".to_string(),
2482 title: "test: commit 1".to_string(),
2483 author: "Test Author".to_string(),
2484 measurements: vec![],
2485 },
2486 Commit {
2487 commit: "def456".to_string(),
2488 title: "test: commit 2".to_string(),
2489 author: "Test Author".to_string(),
2490 measurements: vec![],
2491 },
2492 ];
2493 reporter.add_commits(&commits);
2494
2495 let change_points = vec![ChangePoint {
2496 index: 1,
2497 commit_sha: "def456".to_string(),
2498 magnitude_pct: 50.0,
2499 confidence: 0.9,
2500 direction: ChangeDirection::Increase,
2501 }];
2502
2503 let values = vec![50.0, 75.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2505 reporter.add_change_point_traces_with_indices(
2506 &change_points,
2507 &values,
2508 &commit_indices,
2509 "build_time",
2510 &[],
2511 );
2512
2513 let bytes = reporter.as_bytes();
2514 let html = String::from_utf8_lossy(&bytes);
2515 assert!(html.contains("build_time (Change Points)"));
2517 assert!(html.contains("\"mode\":\"markers\""));
2519 }
2520
2521 #[test]
2522 fn test_change_point_traces_both_directions() {
2523 use crate::change_point::{ChangeDirection, ChangePoint};
2524 use crate::data::Commit;
2525
2526 let mut reporter = PlotlyReporter::new();
2527
2528 let commits: Vec<Commit> = (0..5)
2529 .map(|i| Commit {
2530 commit: format!("sha{:06}", i),
2531 title: format!("test: commit {}", i),
2532 author: "Test Author".to_string(),
2533 measurements: vec![],
2534 })
2535 .collect();
2536 reporter.add_commits(&commits);
2537
2538 let change_points = vec![
2539 ChangePoint {
2540 index: 2,
2541 commit_sha: "sha000002".to_string(),
2542 magnitude_pct: 25.0,
2543 confidence: 0.85,
2544 direction: ChangeDirection::Increase,
2545 },
2546 ChangePoint {
2547 index: 4,
2548 commit_sha: "sha000004".to_string(),
2549 magnitude_pct: -30.0,
2550 confidence: 0.90,
2551 direction: ChangeDirection::Decrease,
2552 },
2553 ];
2554
2555 let values = vec![50.0, 55.0, 62.5, 60.0, 42.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2557 reporter.add_change_point_traces_with_indices(
2558 &change_points,
2559 &values,
2560 &commit_indices,
2561 "metric",
2562 &[],
2563 );
2564
2565 let bytes = reporter.as_bytes();
2566 let html = String::from_utf8_lossy(&bytes);
2567 assert!(html.contains("metric (Change Points)"));
2569 assert!(html.contains("⚠ Regression"));
2571 assert!(html.contains("✓ Improvement"));
2572 }
2573
2574 #[test]
2575 fn test_change_point_traces_empty() {
2576 let mut reporter = PlotlyReporter::new();
2577 reporter.size = 10;
2578
2579 let change_points: Vec<ChangePoint> = vec![];
2580 let values = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
2581 let commit_indices: Vec<usize> = (0..reporter.size).collect();
2582 reporter.add_change_point_traces_with_indices(
2583 &change_points,
2584 &values,
2585 &commit_indices,
2586 "test",
2587 &[],
2588 );
2589
2590 let bytes = reporter.as_bytes();
2592 assert!(!bytes.is_empty());
2593 }
2594
2595 #[test]
2596 fn test_change_point_hover_text_format() {
2597 use crate::change_point::{ChangeDirection, ChangePoint};
2598 use crate::data::Commit;
2599
2600 let mut reporter = PlotlyReporter::new();
2601
2602 let commits = vec![
2603 Commit {
2604 commit: "abc123def".to_string(),
2605 title: "test: commit 1".to_string(),
2606 author: "Test Author".to_string(),
2607 measurements: vec![],
2608 },
2609 Commit {
2610 commit: "xyz789abc".to_string(),
2611 title: "test: commit 2".to_string(),
2612 author: "Test Author".to_string(),
2613 measurements: vec![],
2614 },
2615 ];
2616 reporter.add_commits(&commits);
2617
2618 let change_points = vec![ChangePoint {
2619 index: 1,
2620 commit_sha: "xyz789abc".to_string(),
2621 magnitude_pct: 23.5,
2622 confidence: 0.88,
2623 direction: ChangeDirection::Increase,
2624 }];
2625
2626 let values = vec![100.0, 123.5]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2628 reporter.add_change_point_traces_with_indices(
2629 &change_points,
2630 &values,
2631 &commit_indices,
2632 "test",
2633 &[],
2634 );
2635
2636 let bytes = reporter.as_bytes();
2637 let html = String::from_utf8_lossy(&bytes);
2638 assert!(html.contains("+23.5%"));
2640 assert!(html.contains("xyz789"));
2641 }
2642
2643 #[test]
2644 fn test_hover_text_matches_x_axis() {
2645 use crate::data::{Commit, MeasurementData};
2646
2647 let mut reporter = PlotlyReporter::new();
2648
2649 let commits = vec![
2654 Commit {
2655 commit: "ccccccc3333333333333333333333333333333".to_string(),
2656 title: "third commit (newest)".to_string(),
2657 author: "Author C".to_string(),
2658 measurements: vec![MeasurementData {
2659 name: "test_metric".to_string(),
2660 val: 300.0,
2661 epoch: 0,
2662 timestamp: 0.0,
2663 key_values: std::collections::HashMap::new(),
2664 }],
2665 },
2666 Commit {
2667 commit: "bbbbbbb2222222222222222222222222222222".to_string(),
2668 title: "second commit (middle)".to_string(),
2669 author: "Author B".to_string(),
2670 measurements: vec![MeasurementData {
2671 name: "test_metric".to_string(),
2672 val: 200.0,
2673 epoch: 0,
2674 timestamp: 0.0,
2675 key_values: std::collections::HashMap::new(),
2676 }],
2677 },
2678 Commit {
2679 commit: "aaaaaaa1111111111111111111111111111111".to_string(),
2680 title: "first commit (oldest)".to_string(),
2681 author: "Author A".to_string(),
2682 measurements: vec![MeasurementData {
2683 name: "test_metric".to_string(),
2684 val: 100.0,
2685 epoch: 0,
2686 timestamp: 0.0,
2687 key_values: std::collections::HashMap::new(),
2688 }],
2689 },
2690 ];
2691 reporter.add_commits(&commits);
2692
2693 reporter.begin_section("test_section", "{{PLACEHOLDER}}");
2694
2695 let indexed_measurements = vec![
2697 (0, &commits[0].measurements[0]),
2698 (2, &commits[2].measurements[0]),
2699 ];
2700
2701 reporter.add_trace(indexed_measurements, "test_metric", &[]);
2702
2703 let bytes = reporter.as_bytes();
2704 let html = String::from_utf8_lossy(&bytes);
2705
2706 let json_str = extract_plotly_data_array(&html)
2708 .expect("Failed to extract Plotly config object from HTML");
2709
2710 let plotly_config: serde_json::Value =
2711 serde_json::from_str(&json_str).expect("Failed to parse Plotly JSON config");
2712
2713 let plotly_data = plotly_config["data"]
2715 .as_array()
2716 .expect("Config should have 'data' field as array");
2717
2718 let trace = plotly_data.first().expect("Should have at least one trace");
2720
2721 let x_array = trace["x"].as_array().expect("Trace should have x array");
2723 let y_array = trace["y"].as_array().expect("Trace should have y array");
2724
2725 let hover_array = trace
2727 .get("text")
2728 .or_else(|| trace.get("hovertext"))
2729 .and_then(|v| v.as_array())
2730 .expect("Trace should have text or hovertext array");
2731
2732 assert_eq!(x_array.len(), 2, "Should have 2 x values");
2734 assert_eq!(y_array.len(), 2, "Should have 2 y values");
2735 assert_eq!(hover_array.len(), 2, "Should have 2 hover texts");
2736
2737 for i in 0..x_array.len() {
2744 let x = x_array[i].as_u64().expect("x value should be a number") as usize;
2745 let y = y_array[i].as_f64().expect("y value should be a number");
2746 let hover = hover_array[i]
2747 .as_str()
2748 .expect("hover text should be a string");
2749
2750 if x == 0 {
2751 assert_eq!(y, 100.0, "x=0 should have y=100.0 (oldest commit value)");
2753 assert!(
2754 hover.contains("aaaaaaa"),
2755 "x=0 hover should contain oldest commit hash 'aaaaaaa', but got: {}",
2756 hover
2757 );
2758 assert!(
2759 hover.contains("Author A"),
2760 "x=0 hover should contain oldest commit author 'Author A', but got: {}",
2761 hover
2762 );
2763 assert!(
2764 hover.contains("first commit"),
2765 "x=0 hover should contain oldest commit title 'first commit', but got: {}",
2766 hover
2767 );
2768 } else if x == 2 {
2769 assert_eq!(y, 300.0, "x=2 should have y=300.0 (newest commit value)");
2771 assert!(
2772 hover.contains("ccccccc"),
2773 "x=2 hover should contain newest commit hash 'ccccccc', but got: {}",
2774 hover
2775 );
2776 assert!(
2777 hover.contains("Author C"),
2778 "x=2 hover should contain newest commit author 'Author C', but got: {}",
2779 hover
2780 );
2781 assert!(
2782 hover.contains("third commit"),
2783 "x=2 hover should contain newest commit title 'third commit', but got: {}",
2784 hover
2785 );
2786 } else {
2787 panic!("Unexpected x value: {}", x);
2788 }
2789 }
2790 }
2791
2792 fn extract_plotly_data_array(html: &str) -> Result<String, String> {
2798 let start_pattern = "Plotly.newPlot(";
2800 let start = html
2801 .find(start_pattern)
2802 .ok_or_else(|| "Could not find Plotly.newPlot call in HTML".to_string())?;
2803
2804 let after_start = start + start_pattern.len();
2806 let first_comma_offset = html[after_start..]
2807 .find(',')
2808 .ok_or_else(|| "Could not find first comma after Plotly.newPlot".to_string())?;
2809 let obj_start_pos = after_start + first_comma_offset + 1;
2810
2811 let remaining = &html[obj_start_pos..];
2813 let trimmed = remaining.trim_start();
2814 let brace_offset = remaining.len() - trimmed.len();
2815
2816 if !trimmed.starts_with('{') {
2817 return Err(format!(
2818 "Expected config object to start with '{{', but found: {}",
2819 &trimmed[..20.min(trimmed.len())]
2820 ));
2821 }
2822
2823 let obj_begin = obj_start_pos + brace_offset;
2824
2825 let mut depth = 0;
2827 let mut end = obj_begin;
2828
2829 for (i, ch) in html[obj_begin..].chars().enumerate() {
2830 match ch {
2831 '{' => depth += 1,
2832 '}' => {
2833 depth -= 1;
2834 if depth == 0 {
2835 end = obj_begin + i + 1;
2836 break;
2837 }
2838 }
2839 _ => {}
2840 }
2841 }
2842
2843 if depth != 0 {
2844 return Err("Unmatched braces in config object".to_string());
2845 }
2846
2847 Ok(html[obj_begin..end].to_string())
2848 }
2849
2850 #[test]
2851 fn test_default_template_has_no_sections() {
2852 let sections = parse_template_sections(DEFAULT_HTML_TEMPLATE)
2855 .expect("Failed to parse default template");
2856 assert!(sections.is_empty());
2857 }
2858
2859 #[test]
2860 fn test_wrap_patterns_for_regex_empty() {
2861 let patterns = vec![];
2863 let result = wrap_patterns_for_regex(&patterns);
2864 assert_eq!(result, None);
2865 }
2866
2867 #[test]
2868 fn test_wrap_patterns_for_regex_single() {
2869 let patterns = vec!["test.*".to_string()];
2871 let result = wrap_patterns_for_regex(&patterns);
2872 assert_eq!(result, Some("(?:test.*)".to_string()));
2873 }
2874
2875 #[test]
2876 fn test_wrap_patterns_for_regex_multiple() {
2877 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2879 let result = wrap_patterns_for_regex(&patterns);
2880 assert_eq!(result, Some("(?:test.*)|(?:bench.*)".to_string()));
2881 }
2882
2883 #[test]
2884 fn test_wrap_patterns_for_regex_complex() {
2885 let patterns = vec!["^test-[0-9]+$".to_string(), "bench-(foo|bar)".to_string()];
2887 let result = wrap_patterns_for_regex(&patterns);
2888 assert_eq!(
2889 result,
2890 Some("(?:^test-[0-9]+$)|(?:bench-(foo|bar))".to_string())
2891 );
2892 }
2893
2894 #[test]
2895 fn test_build_single_section_config_no_filters() {
2896 let section = build_single_section_config(&[], &[], vec![], None, false, false);
2898
2899 assert_eq!(section.id, "main");
2900 assert_eq!(section.placeholder, "{{PLOTLY_BODY}}");
2901 assert_eq!(section.measurement_filter, None);
2902 assert!(section.key_value_filter.is_empty());
2903 assert!(section.separate_by.is_empty());
2904 assert_eq!(section.aggregate_by, None);
2905 assert_eq!(section.depth, None);
2906 assert!(!section.show_epochs);
2907 assert!(!section.show_changes);
2908 }
2909
2910 #[test]
2911 fn test_build_single_section_config_with_patterns() {
2912 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2914 let section = build_single_section_config(&patterns, &[], vec![], None, false, false);
2915
2916 assert_eq!(
2917 section.measurement_filter,
2918 Some("(?:test.*)|(?:bench.*)".to_string())
2919 );
2920 }
2921
2922 #[test]
2923 fn test_build_single_section_config_with_all_params() {
2924 let patterns = vec!["test.*".to_string()];
2926 let kv_filters = vec![
2927 ("os".to_string(), "linux".to_string()),
2928 ("arch".to_string(), "x64".to_string()),
2929 ];
2930 let separate = vec!["os".to_string(), "arch".to_string()];
2931
2932 let section = build_single_section_config(
2933 &patterns,
2934 &kv_filters,
2935 separate.clone(),
2936 Some(ReductionFunc::Median),
2937 true,
2938 true,
2939 );
2940
2941 assert_eq!(section.measurement_filter, Some("(?:test.*)".to_string()));
2942 assert_eq!(section.key_value_filter, kv_filters);
2943 assert_eq!(section.separate_by, separate);
2944 assert_eq!(section.aggregate_by, Some(ReductionFunc::Median));
2945 assert!(section.show_epochs);
2946 assert!(section.show_changes);
2947 }
2948
2949 #[test]
2950 fn test_merge_show_flags_both_false() {
2951 let sections = vec![SectionConfig {
2953 id: "test".to_string(),
2954 placeholder: "{{SECTION[test]}}".to_string(),
2955 measurement_filter: None,
2956 key_value_filter: vec![],
2957 separate_by: vec![],
2958 aggregate_by: None,
2959 depth: None,
2960 show_epochs: false,
2961 show_changes: false,
2962 }];
2963
2964 let merged = merge_show_flags(sections, false, false);
2965
2966 assert_eq!(merged.len(), 1);
2967 assert!(!merged[0].show_epochs);
2968 assert!(!merged[0].show_changes);
2969 }
2970
2971 #[test]
2972 fn test_merge_show_flags_section_true_global_false() {
2973 let sections = vec![SectionConfig {
2975 id: "test".to_string(),
2976 placeholder: "{{SECTION[test]}}".to_string(),
2977 measurement_filter: None,
2978 key_value_filter: vec![],
2979 separate_by: vec![],
2980 aggregate_by: None,
2981 depth: None,
2982 show_epochs: true,
2983 show_changes: true,
2984 }];
2985
2986 let merged = merge_show_flags(sections, false, false);
2987
2988 assert_eq!(merged.len(), 1);
2989 assert!(merged[0].show_epochs);
2990 assert!(merged[0].show_changes);
2991 }
2992
2993 #[test]
2994 fn test_merge_show_flags_section_false_global_true() {
2995 let sections = vec![SectionConfig {
2997 id: "test".to_string(),
2998 placeholder: "{{SECTION[test]}}".to_string(),
2999 measurement_filter: None,
3000 key_value_filter: vec![],
3001 separate_by: vec![],
3002 aggregate_by: None,
3003 depth: None,
3004 show_epochs: false,
3005 show_changes: false,
3006 }];
3007
3008 let merged = merge_show_flags(sections, true, true);
3009
3010 assert_eq!(merged.len(), 1);
3011 assert!(merged[0].show_epochs);
3012 assert!(merged[0].show_changes);
3013 }
3014
3015 #[test]
3016 fn test_merge_show_flags_both_true() {
3017 let sections = vec![SectionConfig {
3019 id: "test".to_string(),
3020 placeholder: "{{SECTION[test]}}".to_string(),
3021 measurement_filter: None,
3022 key_value_filter: vec![],
3023 separate_by: vec![],
3024 aggregate_by: None,
3025 depth: None,
3026 show_epochs: true,
3027 show_changes: true,
3028 }];
3029
3030 let merged = merge_show_flags(sections, true, true);
3031
3032 assert_eq!(merged.len(), 1);
3033 assert!(merged[0].show_epochs);
3034 assert!(merged[0].show_changes);
3035 }
3036
3037 #[test]
3038 fn test_merge_show_flags_mixed_flags() {
3039 let sections = vec![SectionConfig {
3041 id: "test".to_string(),
3042 placeholder: "{{SECTION[test]}}".to_string(),
3043 measurement_filter: None,
3044 key_value_filter: vec![],
3045 separate_by: vec![],
3046 aggregate_by: None,
3047 depth: None,
3048 show_epochs: true,
3049 show_changes: false,
3050 }];
3051
3052 let merged = merge_show_flags(sections, false, true);
3053
3054 assert_eq!(merged.len(), 1);
3055 assert!(merged[0].show_epochs); assert!(merged[0].show_changes); }
3058
3059 #[test]
3060 fn test_merge_show_flags_multiple_sections() {
3061 let sections = vec![
3063 SectionConfig {
3064 id: "section1".to_string(),
3065 placeholder: "{{SECTION[section1]}}".to_string(),
3066 measurement_filter: None,
3067 key_value_filter: vec![],
3068 separate_by: vec![],
3069 aggregate_by: None,
3070 depth: None,
3071 show_epochs: false,
3072 show_changes: false,
3073 },
3074 SectionConfig {
3075 id: "section2".to_string(),
3076 placeholder: "{{SECTION[section2]}}".to_string(),
3077 measurement_filter: None,
3078 key_value_filter: vec![],
3079 separate_by: vec![],
3080 aggregate_by: None,
3081 depth: None,
3082 show_epochs: true,
3083 show_changes: false,
3084 },
3085 ];
3086
3087 let merged = merge_show_flags(sections, true, true);
3088
3089 assert_eq!(merged.len(), 2);
3090 assert!(merged[0].show_epochs);
3092 assert!(merged[0].show_changes);
3093 assert!(merged[1].show_epochs);
3094 assert!(merged[1].show_changes);
3095 }
3096}