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 key_values: &[(String, String)],
1811 aggregate_by: Option<ReductionFunc>,
1812 combined_patterns: &[String],
1813 template_config: ReportTemplateConfig,
1814 show_epochs: bool,
1815 show_changes: bool,
1816) -> Result<()> {
1817 let _filters = crate::filter::compile_filters(combined_patterns)?;
1820
1821 let commits: Vec<Commit> =
1822 measurement_retrieval::walk_commits_from(start_commit, num_commits)?.try_collect()?;
1823
1824 if commits.is_empty() {
1825 bail!(
1826 "No commits found in repository. Ensure commits exist and were pushed to the remote."
1827 );
1828 }
1829
1830 let output_format = OutputFormat::from_path(&output)
1832 .ok_or_else(|| anyhow!("Could not determine output format from file extension"))?;
1833
1834 let (sections, template_str, metadata) = prepare_sections_and_metadata(
1836 output_format,
1837 &template_config,
1838 combined_patterns,
1839 key_values,
1840 separate_by.clone(),
1841 aggregate_by,
1842 show_epochs,
1843 show_changes,
1844 &commits,
1845 )?;
1846
1847 let mut reporter: Box<dyn Reporter> = match output_format {
1849 OutputFormat::Html => {
1850 let template = template_str.expect("HTML requires template");
1851 Box::new(PlotlyReporter::with_template(template, metadata.clone()))
1852 }
1853 OutputFormat::Csv => Box::new(CsvReporter::new()),
1854 };
1855
1856 reporter.add_commits(&commits);
1858
1859 let section_outputs = sections
1860 .iter()
1861 .map(|section| process_section(&mut *reporter, &commits, section))
1862 .collect::<Result<Vec<SectionOutput>>>()?;
1863
1864 let has_measurements_from_sections = section_outputs.iter().any(|s| !s.content.is_empty());
1868
1869 let report_bytes = reporter.finalize(section_outputs, &metadata);
1871
1872 let has_measurements = match output_format {
1875 OutputFormat::Html => has_measurements_from_sections,
1876 OutputFormat::Csv => !report_bytes.is_empty(),
1877 };
1878
1879 let is_multi_section = sections.len() > 1;
1883 if !is_multi_section && !has_measurements {
1884 bail!("No performance measurements found.");
1885 }
1886
1887 write_output(&output, &report_bytes)?;
1889
1890 Ok(())
1891}
1892
1893#[cfg(test)]
1894mod tests {
1895 use super::*;
1896
1897 #[test]
1898 fn test_convert_to_x_y_empty() {
1899 let reporter = PlotlyReporter::new();
1900 let (x, y) = reporter.convert_to_x_y(vec![]);
1901 assert!(x.is_empty());
1902 assert!(y.is_empty());
1903 }
1904
1905 #[test]
1906 fn test_convert_to_x_y_single_value() {
1907 let mut reporter = PlotlyReporter::new();
1908 reporter.size = 3;
1909 let (x, y) = reporter.convert_to_x_y(vec![(0, 1.5)]);
1910 assert_eq!(x, vec![2]);
1911 assert_eq!(y, vec![1.5]);
1912 }
1913
1914 #[test]
1915 fn test_convert_to_x_y_multiple_values() {
1916 let mut reporter = PlotlyReporter::new();
1917 reporter.size = 5;
1918 let (x, y) = reporter.convert_to_x_y(vec![(0, 10.0), (2, 20.0), (4, 30.0)]);
1919 assert_eq!(x, vec![4, 2, 0]);
1920 assert_eq!(y, vec![10.0, 20.0, 30.0]);
1921 }
1922
1923 #[test]
1924 fn test_convert_to_x_y_negative_values() {
1925 let mut reporter = PlotlyReporter::new();
1926 reporter.size = 2;
1927 let (x, y) = reporter.convert_to_x_y(vec![(0, -5.5), (1, -10.2)]);
1928 assert_eq!(x, vec![1, 0]);
1929 assert_eq!(y, vec![-5.5, -10.2]);
1930 }
1931
1932 #[test]
1933 fn test_plotly_reporter_as_bytes_not_empty() {
1934 let reporter = PlotlyReporter::new();
1935 let bytes = reporter.as_bytes();
1936 assert!(!bytes.is_empty());
1937 let html = String::from_utf8_lossy(&bytes);
1939 assert!(html.contains("plotly") || html.contains("Plotly"));
1940 }
1941
1942 #[test]
1943 fn test_plotly_reporter_uses_default_template() {
1944 let reporter = PlotlyReporter::new();
1945 let bytes = reporter.as_bytes();
1946 let html = String::from_utf8_lossy(&bytes);
1947
1948 assert!(html.contains("<!DOCTYPE html>"));
1950 assert!(html.contains("<html>"));
1951 assert!(html.contains("<head>"));
1952 assert!(html.contains("<title>Performance Measurements</title>"));
1953 assert!(html.contains("</head>"));
1954 assert!(html.contains("<body>"));
1955 assert!(html.contains("</body>"));
1956 assert!(html.contains("</html>"));
1957 assert!(html.contains("plotly") || html.contains("Plotly"));
1959 }
1960
1961 #[test]
1962 fn test_format_measurement_with_unit_no_unit() {
1963 let result = format_measurement_with_unit("unknown_measurement");
1965 assert_eq!(result, "unknown_measurement");
1966 }
1967
1968 #[test]
1969 fn test_extract_plotly_parts() {
1970 let mut plot = Plot::new();
1972 let trace = plotly::Scatter::new(vec![1, 2, 3], vec![4, 5, 6]).name("test");
1973 plot.add_trace(trace);
1974
1975 let (head, body) = extract_plotly_parts(&plot);
1976
1977 assert!(head.contains("<script"));
1979 assert!(head.contains("plotly"));
1980
1981 assert!(body.contains("<div"));
1983 assert!(body.contains("<script"));
1984 assert!(body.contains("Plotly.newPlot"));
1985 }
1986
1987 #[test]
1988 fn test_extract_plotly_parts_structure() {
1989 let mut plot = Plot::new();
1991 let trace = plotly::Scatter::new(vec![1], vec![1]).name("data");
1992 plot.add_trace(trace);
1993
1994 let (head, body) = extract_plotly_parts(&plot);
1995
1996 assert!(!head.contains("<html>"));
1998 assert!(!head.contains("<head>"));
1999 assert!(!head.contains("<body>"));
2000
2001 assert!(!body.contains("<html>"));
2003 assert!(!body.contains("<head>"));
2004 assert!(!body.contains("<body>"));
2005 }
2006
2007 #[test]
2008 fn test_report_metadata_new() {
2009 use crate::data::Commit;
2010
2011 let commits = vec![
2012 Commit {
2013 commit: "abc1234567890".to_string(),
2014 title: "test: commit 1".to_string(),
2015 author: "Test Author".to_string(),
2016 measurements: vec![],
2017 },
2018 Commit {
2019 commit: "def0987654321".to_string(),
2020 title: "test: commit 2".to_string(),
2021 author: "Test Author".to_string(),
2022 measurements: vec![],
2023 },
2024 ];
2025
2026 let metadata =
2027 ReportMetadata::new(Some("Custom Title".to_string()), "".to_string(), &commits);
2028
2029 assert_eq!(metadata.title, "Custom Title");
2030 assert_eq!(metadata.commit_range, "def0987..abc1234");
2031 assert_eq!(metadata.depth, 2);
2032 }
2033
2034 #[test]
2035 fn test_report_metadata_new_default_title() {
2036 use crate::data::Commit;
2037
2038 let commits = vec![Commit {
2039 commit: "abc1234567890".to_string(),
2040 title: "test: commit".to_string(),
2041 author: "Test Author".to_string(),
2042 measurements: vec![],
2043 }];
2044
2045 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2046
2047 assert_eq!(metadata.title, "Performance Measurements");
2048 assert_eq!(metadata.commit_range, "abc1234");
2049 assert_eq!(metadata.depth, 1);
2050 }
2051
2052 #[test]
2053 fn test_report_metadata_new_empty_commits() {
2054 let commits = vec![];
2055 let metadata = ReportMetadata::new(None, "".to_string(), &commits);
2056
2057 assert_eq!(metadata.commit_range, "No commits");
2058 assert_eq!(metadata.depth, 0);
2059 }
2060
2061 #[test]
2062 fn test_compute_y_axis_empty_measurements() {
2063 let reporter = PlotlyReporter::new();
2064 let y_axis = reporter.compute_y_axis();
2065 assert!(y_axis.is_none());
2066 }
2067
2068 #[test]
2069 fn test_compute_y_axis_single_unit() {
2070 let mut reporter = PlotlyReporter::new();
2071 reporter.measurement_units.push(Some("ms".to_string()));
2072 reporter.measurement_units.push(Some("ms".to_string()));
2073 reporter.measurement_units.push(Some("ms".to_string()));
2074
2075 let y_axis = reporter.compute_y_axis();
2076 assert!(y_axis.is_some());
2077 }
2078
2079 #[test]
2080 fn test_compute_y_axis_mixed_units() {
2081 let mut reporter = PlotlyReporter::new();
2082 reporter.measurement_units.push(Some("ms".to_string()));
2083 reporter.measurement_units.push(Some("bytes".to_string()));
2084
2085 let y_axis = reporter.compute_y_axis();
2086 assert!(y_axis.is_none());
2087 }
2088
2089 #[test]
2090 fn test_compute_y_axis_no_units() {
2091 let mut reporter = PlotlyReporter::new();
2092 reporter.measurement_units.push(None);
2093 reporter.measurement_units.push(None);
2094
2095 let y_axis = reporter.compute_y_axis();
2096 assert!(y_axis.is_none());
2097 }
2098
2099 #[test]
2100 fn test_compute_y_axis_some_with_unit_some_without() {
2101 let mut reporter = PlotlyReporter::new();
2102 reporter.measurement_units.push(Some("ms".to_string()));
2103 reporter.measurement_units.push(None);
2104
2105 let y_axis = reporter.compute_y_axis();
2106 assert!(y_axis.is_none());
2107 }
2108
2109 #[test]
2110 fn test_plotly_reporter_adds_units_to_legend() {
2111 use crate::data::Commit;
2112
2113 let mut reporter = PlotlyReporter::new();
2114
2115 let commits = vec![
2117 Commit {
2118 commit: "abc123".to_string(),
2119 title: "test: commit 1".to_string(),
2120 author: "Test Author".to_string(),
2121 measurements: vec![],
2122 },
2123 Commit {
2124 commit: "def456".to_string(),
2125 title: "test: commit 2".to_string(),
2126 author: "Test Author".to_string(),
2127 measurements: vec![],
2128 },
2129 ];
2130 reporter.add_commits(&commits);
2131
2132 reporter.measurement_units.push(Some("ms".to_string()));
2134
2135 let bytes = reporter.as_bytes();
2137 let html = String::from_utf8_lossy(&bytes);
2138
2139 assert!(!html.is_empty());
2141 assert!(html.contains("plotly") || html.contains("Plotly"));
2142 }
2143
2144 #[test]
2145 fn test_plotly_reporter_y_axis_with_same_units() {
2146 let mut reporter = PlotlyReporter::new();
2147
2148 reporter.measurement_units.push(Some("ms".to_string()));
2150 reporter.measurement_units.push(Some("ms".to_string()));
2151
2152 let bytes = reporter.as_bytes();
2154 let html = String::from_utf8_lossy(&bytes);
2155
2156 assert!(html.contains("Value (ms)"));
2158 }
2159
2160 #[test]
2161 fn test_plotly_reporter_no_y_axis_with_mixed_units() {
2162 let mut reporter = PlotlyReporter::new();
2163
2164 reporter.measurement_units.push(Some("ms".to_string()));
2166 reporter.measurement_units.push(Some("bytes".to_string()));
2167
2168 let bytes = reporter.as_bytes();
2170 let html = String::from_utf8_lossy(&bytes);
2171
2172 assert!(!html.contains("Value (ms)"));
2174 assert!(!html.contains("Value (bytes)"));
2175 }
2176
2177 #[test]
2178 fn test_csv_reporter_as_bytes_empty_on_init() {
2179 let reporter = CsvReporter::new();
2180 let bytes = reporter.as_bytes();
2181 assert!(bytes.is_empty() || String::from_utf8_lossy(&bytes).trim().is_empty());
2183 }
2184
2185 #[test]
2186 fn test_csv_reporter_includes_header() {
2187 use crate::data::{Commit, MeasurementData};
2188 use std::collections::HashMap;
2189
2190 let mut reporter = CsvReporter::new();
2191
2192 let commits = vec![Commit {
2194 commit: "abc123".to_string(),
2195 title: "test: commit".to_string(),
2196 author: "Test Author".to_string(),
2197 measurements: vec![],
2198 }];
2199 reporter.add_commits(&commits);
2200
2201 let measurement = MeasurementData {
2203 epoch: 0,
2204 name: "test_measurement".to_string(),
2205 timestamp: 1234.0,
2206 val: 42.5,
2207 key_values: HashMap::new(),
2208 };
2209 reporter.add_trace(vec![(0, &measurement)], "test_measurement", &[]);
2210
2211 let bytes = reporter.as_bytes();
2213 let csv = String::from_utf8_lossy(&bytes);
2214
2215 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2217
2218 assert!(csv.contains("abc123"));
2220 assert!(csv.contains("test_measurement"));
2221 assert!(csv.contains("42.5"));
2222 }
2223
2224 #[test]
2225 fn test_csv_exact_output_single_measurement() {
2226 use crate::data::{Commit, MeasurementData};
2227 use std::collections::HashMap;
2228
2229 let mut reporter = CsvReporter::new();
2230
2231 let commits = vec![Commit {
2232 commit: "abc123def456".to_string(),
2233 title: "test: commit".to_string(),
2234 author: "Test Author".to_string(),
2235 measurements: vec![],
2236 }];
2237 reporter.add_commits(&commits);
2238
2239 let measurement = MeasurementData {
2240 epoch: 0,
2241 name: "build_time".to_string(),
2242 timestamp: 1234567890.5,
2243 val: 42.0,
2244 key_values: HashMap::new(),
2245 };
2246 reporter.add_trace(vec![(0, &measurement)], "build_time", &[]);
2247
2248 let bytes = reporter.as_bytes();
2249 let csv = String::from_utf8_lossy(&bytes);
2250
2251 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc123def456\t0\tbuild_time\t1234567890.5\t42.0\t\n";
2252 assert_eq!(csv, expected);
2253 }
2254
2255 #[test]
2256 fn test_csv_exact_output_with_metadata() {
2257 use crate::data::{Commit, MeasurementData};
2258 use std::collections::HashMap;
2259
2260 let mut reporter = CsvReporter::new();
2261
2262 let commits = vec![Commit {
2263 commit: "commit123".to_string(),
2264 title: "test: commit".to_string(),
2265 author: "Test Author".to_string(),
2266 measurements: vec![],
2267 }];
2268 reporter.add_commits(&commits);
2269
2270 let mut metadata = HashMap::new();
2271 metadata.insert("os".to_string(), "linux".to_string());
2272 metadata.insert("arch".to_string(), "x64".to_string());
2273
2274 let measurement = MeasurementData {
2275 epoch: 1,
2276 name: "test".to_string(),
2277 timestamp: 1000.0,
2278 val: 3.5,
2279 key_values: metadata,
2280 };
2281 reporter.add_trace(vec![(0, &measurement)], "test", &[]);
2282
2283 let bytes = reporter.as_bytes();
2284 let csv = String::from_utf8_lossy(&bytes);
2285
2286 assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2288 assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
2289 assert!(csv.contains("os=linux"));
2291 assert!(csv.contains("arch=x64"));
2292 assert!(csv.ends_with('\n'));
2294 }
2295
2296 #[test]
2297 fn test_csv_exact_output_multiple_measurements() {
2298 use crate::data::{Commit, MeasurementData};
2299 use std::collections::HashMap;
2300
2301 let mut reporter = CsvReporter::new();
2302
2303 let commits = vec![
2304 Commit {
2305 commit: "commit1".to_string(),
2306 title: "test: commit 1".to_string(),
2307 author: "Test Author".to_string(),
2308 measurements: vec![],
2309 },
2310 Commit {
2311 commit: "commit2".to_string(),
2312 title: "test: commit 2".to_string(),
2313 author: "Test Author".to_string(),
2314 measurements: vec![],
2315 },
2316 ];
2317 reporter.add_commits(&commits);
2318
2319 let m1 = MeasurementData {
2320 epoch: 0,
2321 name: "timer".to_string(),
2322 timestamp: 100.0,
2323 val: 1.5,
2324 key_values: HashMap::new(),
2325 };
2326
2327 let m2 = MeasurementData {
2328 epoch: 0,
2329 name: "timer".to_string(),
2330 timestamp: 200.0,
2331 val: 2.0,
2332 key_values: HashMap::new(),
2333 };
2334
2335 reporter.add_trace(vec![(0, &m1), (1, &m2)], "timer", &[]);
2336
2337 let bytes = reporter.as_bytes();
2338 let csv = String::from_utf8_lossy(&bytes);
2339
2340 let expected = "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n\
2341 commit1\t0\ttimer\t100.0\t1.5\t\n\
2342 commit2\t0\ttimer\t200.0\t2.0\t\n";
2343 assert_eq!(csv, expected);
2344 }
2345
2346 #[test]
2347 fn test_csv_exact_output_whole_number_formatting() {
2348 use crate::data::{Commit, MeasurementData};
2349 use std::collections::HashMap;
2350
2351 let mut reporter = CsvReporter::new();
2352
2353 let commits = vec![Commit {
2354 commit: "hash1".to_string(),
2355 title: "test: commit".to_string(),
2356 author: "Test Author".to_string(),
2357 measurements: vec![],
2358 }];
2359 reporter.add_commits(&commits);
2360
2361 let measurement = MeasurementData {
2362 epoch: 0,
2363 name: "count".to_string(),
2364 timestamp: 500.0,
2365 val: 10.0,
2366 key_values: HashMap::new(),
2367 };
2368 reporter.add_trace(vec![(0, &measurement)], "count", &[]);
2369
2370 let bytes = reporter.as_bytes();
2371 let csv = String::from_utf8_lossy(&bytes);
2372
2373 let expected =
2375 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nhash1\t0\tcount\t500.0\t10.0\t\n";
2376 assert_eq!(csv, expected);
2377 }
2378
2379 #[test]
2380 fn test_csv_exact_output_summarized_measurement() {
2381 use crate::data::{Commit, MeasurementSummary};
2382
2383 let mut reporter = CsvReporter::new();
2384
2385 let commits = vec![Commit {
2386 commit: "abc".to_string(),
2387 title: "test: commit".to_string(),
2388 author: "Test Author".to_string(),
2389 measurements: vec![],
2390 }];
2391 reporter.add_commits(&commits);
2392
2393 let summary = MeasurementSummary { epoch: 0, val: 5.5 };
2394
2395 reporter.add_summarized_trace(vec![(0, summary)], "avg_time", &[]);
2396
2397 let bytes = reporter.as_bytes();
2398 let csv = String::from_utf8_lossy(&bytes);
2399
2400 let expected =
2402 "commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\nabc\t0\tavg_time\t0.0\t5.5\t\n";
2403 assert_eq!(csv, expected);
2404 }
2405
2406 #[test]
2407 fn test_epoch_boundary_traces_hidden_by_default() {
2408 use crate::change_point::EpochTransition;
2409 use crate::data::Commit;
2410
2411 let mut reporter = PlotlyReporter::new();
2412
2413 let commits = vec![
2414 Commit {
2415 commit: "abc123".to_string(),
2416 title: "test: commit 1".to_string(),
2417 author: "Test Author".to_string(),
2418 measurements: vec![],
2419 },
2420 Commit {
2421 commit: "def456".to_string(),
2422 title: "test: commit 2".to_string(),
2423 author: "Test Author".to_string(),
2424 measurements: vec![],
2425 },
2426 Commit {
2427 commit: "ghi789".to_string(),
2428 title: "test: commit 3".to_string(),
2429 author: "Test Author".to_string(),
2430 measurements: vec![],
2431 },
2432 ];
2433 reporter.add_commits(&commits);
2434
2435 let transitions = vec![EpochTransition {
2436 index: 1,
2437 from_epoch: 1,
2438 to_epoch: 2,
2439 }];
2440
2441 let commit_indices = vec![0, 1, 2];
2442 let group_values: Vec<String> = vec![];
2443 reporter.add_epoch_boundary_traces(
2444 &transitions,
2445 &commit_indices,
2446 "test_metric",
2447 &group_values,
2448 0.0,
2449 100.0,
2450 );
2451
2452 let bytes = reporter.as_bytes();
2453 let html = String::from_utf8_lossy(&bytes);
2454 assert!(html.contains("legendonly"));
2456 assert!(html.contains("test_metric (Epochs)"));
2458 }
2459
2460 #[test]
2461 fn test_epoch_boundary_traces_empty() {
2462 use crate::change_point::EpochTransition;
2463
2464 let mut reporter = PlotlyReporter::new();
2465 reporter.size = 10;
2466
2467 let transitions: Vec<EpochTransition> = vec![];
2468 let commit_indices: Vec<usize> = vec![];
2469 let group_values: Vec<String> = vec![];
2470 reporter.add_epoch_boundary_traces(
2471 &transitions,
2472 &commit_indices,
2473 "test",
2474 &group_values,
2475 0.0,
2476 100.0,
2477 );
2478
2479 let bytes = reporter.as_bytes();
2481 assert!(!bytes.is_empty());
2482 }
2483
2484 #[test]
2485 fn test_change_point_traces_hidden_by_default() {
2486 use crate::change_point::{ChangeDirection, ChangePoint};
2487 use crate::data::Commit;
2488
2489 let mut reporter = PlotlyReporter::new();
2490
2491 let commits = vec![
2492 Commit {
2493 commit: "abc123".to_string(),
2494 title: "test: commit 1".to_string(),
2495 author: "Test Author".to_string(),
2496 measurements: vec![],
2497 },
2498 Commit {
2499 commit: "def456".to_string(),
2500 title: "test: commit 2".to_string(),
2501 author: "Test Author".to_string(),
2502 measurements: vec![],
2503 },
2504 ];
2505 reporter.add_commits(&commits);
2506
2507 let change_points = vec![ChangePoint {
2508 index: 1,
2509 commit_sha: "def456".to_string(),
2510 magnitude_pct: 50.0,
2511 confidence: 0.9,
2512 direction: ChangeDirection::Increase,
2513 }];
2514
2515 let values = vec![50.0, 75.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2517 reporter.add_change_point_traces_with_indices(
2518 &change_points,
2519 &values,
2520 &commit_indices,
2521 "build_time",
2522 &[],
2523 );
2524
2525 let bytes = reporter.as_bytes();
2526 let html = String::from_utf8_lossy(&bytes);
2527 assert!(html.contains("build_time (Change Points)"));
2529 assert!(html.contains("\"mode\":\"markers\""));
2531 }
2532
2533 #[test]
2534 fn test_change_point_traces_both_directions() {
2535 use crate::change_point::{ChangeDirection, ChangePoint};
2536 use crate::data::Commit;
2537
2538 let mut reporter = PlotlyReporter::new();
2539
2540 let commits: Vec<Commit> = (0..5)
2541 .map(|i| Commit {
2542 commit: format!("sha{:06}", i),
2543 title: format!("test: commit {}", i),
2544 author: "Test Author".to_string(),
2545 measurements: vec![],
2546 })
2547 .collect();
2548 reporter.add_commits(&commits);
2549
2550 let change_points = vec![
2551 ChangePoint {
2552 index: 2,
2553 commit_sha: "sha000002".to_string(),
2554 magnitude_pct: 25.0,
2555 confidence: 0.85,
2556 direction: ChangeDirection::Increase,
2557 },
2558 ChangePoint {
2559 index: 4,
2560 commit_sha: "sha000004".to_string(),
2561 magnitude_pct: -30.0,
2562 confidence: 0.90,
2563 direction: ChangeDirection::Decrease,
2564 },
2565 ];
2566
2567 let values = vec![50.0, 55.0, 62.5, 60.0, 42.0]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2569 reporter.add_change_point_traces_with_indices(
2570 &change_points,
2571 &values,
2572 &commit_indices,
2573 "metric",
2574 &[],
2575 );
2576
2577 let bytes = reporter.as_bytes();
2578 let html = String::from_utf8_lossy(&bytes);
2579 assert!(html.contains("metric (Change Points)"));
2581 assert!(html.contains("⚠ Regression"));
2583 assert!(html.contains("✓ Improvement"));
2584 }
2585
2586 #[test]
2587 fn test_change_point_traces_empty() {
2588 let mut reporter = PlotlyReporter::new();
2589 reporter.size = 10;
2590
2591 let change_points: Vec<ChangePoint> = vec![];
2592 let values = vec![10.0, 20.0, 30.0, 40.0, 50.0, 60.0, 70.0, 80.0, 90.0, 100.0];
2593 let commit_indices: Vec<usize> = (0..reporter.size).collect();
2594 reporter.add_change_point_traces_with_indices(
2595 &change_points,
2596 &values,
2597 &commit_indices,
2598 "test",
2599 &[],
2600 );
2601
2602 let bytes = reporter.as_bytes();
2604 assert!(!bytes.is_empty());
2605 }
2606
2607 #[test]
2608 fn test_change_point_hover_text_format() {
2609 use crate::change_point::{ChangeDirection, ChangePoint};
2610 use crate::data::Commit;
2611
2612 let mut reporter = PlotlyReporter::new();
2613
2614 let commits = vec![
2615 Commit {
2616 commit: "abc123def".to_string(),
2617 title: "test: commit 1".to_string(),
2618 author: "Test Author".to_string(),
2619 measurements: vec![],
2620 },
2621 Commit {
2622 commit: "xyz789abc".to_string(),
2623 title: "test: commit 2".to_string(),
2624 author: "Test Author".to_string(),
2625 measurements: vec![],
2626 },
2627 ];
2628 reporter.add_commits(&commits);
2629
2630 let change_points = vec![ChangePoint {
2631 index: 1,
2632 commit_sha: "xyz789abc".to_string(),
2633 magnitude_pct: 23.5,
2634 confidence: 0.88,
2635 direction: ChangeDirection::Increase,
2636 }];
2637
2638 let values = vec![100.0, 123.5]; let commit_indices: Vec<usize> = (0..reporter.size).collect();
2640 reporter.add_change_point_traces_with_indices(
2641 &change_points,
2642 &values,
2643 &commit_indices,
2644 "test",
2645 &[],
2646 );
2647
2648 let bytes = reporter.as_bytes();
2649 let html = String::from_utf8_lossy(&bytes);
2650 assert!(html.contains("+23.5%"));
2652 assert!(html.contains("xyz789"));
2653 assert!(html.contains("Test Author"));
2654 assert!(html.contains("test: commit 2"));
2655 }
2656
2657 #[test]
2658 fn test_hover_text_matches_x_axis() {
2659 use crate::data::{Commit, MeasurementData};
2660
2661 let mut reporter = PlotlyReporter::new();
2662
2663 let commits = vec![
2668 Commit {
2669 commit: "ccccccc3333333333333333333333333333333".to_string(),
2670 title: "third commit (newest)".to_string(),
2671 author: "Author C".to_string(),
2672 measurements: vec![MeasurementData {
2673 name: "test_metric".to_string(),
2674 val: 300.0,
2675 epoch: 0,
2676 timestamp: 0.0,
2677 key_values: std::collections::HashMap::new(),
2678 }],
2679 },
2680 Commit {
2681 commit: "bbbbbbb2222222222222222222222222222222".to_string(),
2682 title: "second commit (middle)".to_string(),
2683 author: "Author B".to_string(),
2684 measurements: vec![MeasurementData {
2685 name: "test_metric".to_string(),
2686 val: 200.0,
2687 epoch: 0,
2688 timestamp: 0.0,
2689 key_values: std::collections::HashMap::new(),
2690 }],
2691 },
2692 Commit {
2693 commit: "aaaaaaa1111111111111111111111111111111".to_string(),
2694 title: "first commit (oldest)".to_string(),
2695 author: "Author A".to_string(),
2696 measurements: vec![MeasurementData {
2697 name: "test_metric".to_string(),
2698 val: 100.0,
2699 epoch: 0,
2700 timestamp: 0.0,
2701 key_values: std::collections::HashMap::new(),
2702 }],
2703 },
2704 ];
2705 reporter.add_commits(&commits);
2706
2707 reporter.begin_section("test_section", "{{PLACEHOLDER}}");
2708
2709 let indexed_measurements = vec![
2711 (0, &commits[0].measurements[0]),
2712 (2, &commits[2].measurements[0]),
2713 ];
2714
2715 reporter.add_trace(indexed_measurements, "test_metric", &[]);
2716
2717 let bytes = reporter.as_bytes();
2718 let html = String::from_utf8_lossy(&bytes);
2719
2720 let json_str = extract_plotly_data_array(&html)
2722 .expect("Failed to extract Plotly config object from HTML");
2723
2724 let plotly_config: serde_json::Value =
2725 serde_json::from_str(&json_str).expect("Failed to parse Plotly JSON config");
2726
2727 let plotly_data = plotly_config["data"]
2729 .as_array()
2730 .expect("Config should have 'data' field as array");
2731
2732 let trace = plotly_data.first().expect("Should have at least one trace");
2734
2735 let x_array = trace["x"].as_array().expect("Trace should have x array");
2737 let y_array = trace["y"].as_array().expect("Trace should have y array");
2738
2739 let hover_array = trace
2741 .get("text")
2742 .or_else(|| trace.get("hovertext"))
2743 .and_then(|v| v.as_array())
2744 .expect("Trace should have text or hovertext array");
2745
2746 assert_eq!(x_array.len(), 2, "Should have 2 x values");
2748 assert_eq!(y_array.len(), 2, "Should have 2 y values");
2749 assert_eq!(hover_array.len(), 2, "Should have 2 hover texts");
2750
2751 for i in 0..x_array.len() {
2758 let x = x_array[i].as_u64().expect("x value should be a number") as usize;
2759 let y = y_array[i].as_f64().expect("y value should be a number");
2760 let hover = hover_array[i]
2761 .as_str()
2762 .expect("hover text should be a string");
2763
2764 if x == 0 {
2765 assert_eq!(y, 100.0, "x=0 should have y=100.0 (oldest commit value)");
2767 assert!(
2768 hover.contains("aaaaaaa"),
2769 "x=0 hover should contain oldest commit hash 'aaaaaaa', but got: {}",
2770 hover
2771 );
2772 assert!(
2773 hover.contains("Author A"),
2774 "x=0 hover should contain oldest commit author 'Author A', but got: {}",
2775 hover
2776 );
2777 assert!(
2778 hover.contains("first commit"),
2779 "x=0 hover should contain oldest commit title 'first commit', but got: {}",
2780 hover
2781 );
2782 } else if x == 2 {
2783 assert_eq!(y, 300.0, "x=2 should have y=300.0 (newest commit value)");
2785 assert!(
2786 hover.contains("ccccccc"),
2787 "x=2 hover should contain newest commit hash 'ccccccc', but got: {}",
2788 hover
2789 );
2790 assert!(
2791 hover.contains("Author C"),
2792 "x=2 hover should contain newest commit author 'Author C', but got: {}",
2793 hover
2794 );
2795 assert!(
2796 hover.contains("third commit"),
2797 "x=2 hover should contain newest commit title 'third commit', but got: {}",
2798 hover
2799 );
2800 } else {
2801 panic!("Unexpected x value: {}", x);
2802 }
2803 }
2804 }
2805
2806 fn extract_plotly_data_array(html: &str) -> Result<String, String> {
2812 let start_pattern = "Plotly.newPlot(";
2814 let start = html
2815 .find(start_pattern)
2816 .ok_or_else(|| "Could not find Plotly.newPlot call in HTML".to_string())?;
2817
2818 let after_start = start + start_pattern.len();
2820 let first_comma_offset = html[after_start..]
2821 .find(',')
2822 .ok_or_else(|| "Could not find first comma after Plotly.newPlot".to_string())?;
2823 let obj_start_pos = after_start + first_comma_offset + 1;
2824
2825 let remaining = &html[obj_start_pos..];
2827 let trimmed = remaining.trim_start();
2828 let brace_offset = remaining.len() - trimmed.len();
2829
2830 if !trimmed.starts_with('{') {
2831 return Err(format!(
2832 "Expected config object to start with '{{', but found: {}",
2833 &trimmed[..20.min(trimmed.len())]
2834 ));
2835 }
2836
2837 let obj_begin = obj_start_pos + brace_offset;
2838
2839 let mut depth = 0;
2841 let mut end = obj_begin;
2842
2843 for (i, ch) in html[obj_begin..].chars().enumerate() {
2844 match ch {
2845 '{' => depth += 1,
2846 '}' => {
2847 depth -= 1;
2848 if depth == 0 {
2849 end = obj_begin + i + 1;
2850 break;
2851 }
2852 }
2853 _ => {}
2854 }
2855 }
2856
2857 if depth != 0 {
2858 return Err("Unmatched braces in config object".to_string());
2859 }
2860
2861 Ok(html[obj_begin..end].to_string())
2862 }
2863
2864 #[test]
2865 fn test_default_template_has_no_sections() {
2866 let sections = parse_template_sections(DEFAULT_HTML_TEMPLATE)
2869 .expect("Failed to parse default template");
2870 assert!(sections.is_empty());
2871 }
2872
2873 #[test]
2874 fn test_wrap_patterns_for_regex_empty() {
2875 let patterns = vec![];
2877 let result = wrap_patterns_for_regex(&patterns);
2878 assert_eq!(result, None);
2879 }
2880
2881 #[test]
2882 fn test_wrap_patterns_for_regex_single() {
2883 let patterns = vec!["test.*".to_string()];
2885 let result = wrap_patterns_for_regex(&patterns);
2886 assert_eq!(result, Some("(?:test.*)".to_string()));
2887 }
2888
2889 #[test]
2890 fn test_wrap_patterns_for_regex_multiple() {
2891 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2893 let result = wrap_patterns_for_regex(&patterns);
2894 assert_eq!(result, Some("(?:test.*)|(?:bench.*)".to_string()));
2895 }
2896
2897 #[test]
2898 fn test_wrap_patterns_for_regex_complex() {
2899 let patterns = vec!["^test-[0-9]+$".to_string(), "bench-(foo|bar)".to_string()];
2901 let result = wrap_patterns_for_regex(&patterns);
2902 assert_eq!(
2903 result,
2904 Some("(?:^test-[0-9]+$)|(?:bench-(foo|bar))".to_string())
2905 );
2906 }
2907
2908 #[test]
2909 fn test_build_single_section_config_no_filters() {
2910 let section = build_single_section_config(&[], &[], vec![], None, false, false);
2912
2913 assert_eq!(section.id, "main");
2914 assert_eq!(section.placeholder, "{{PLOTLY_BODY}}");
2915 assert_eq!(section.measurement_filter, None);
2916 assert!(section.key_value_filter.is_empty());
2917 assert!(section.separate_by.is_empty());
2918 assert_eq!(section.aggregate_by, None);
2919 assert_eq!(section.depth, None);
2920 assert!(!section.show_epochs);
2921 assert!(!section.show_changes);
2922 }
2923
2924 #[test]
2925 fn test_build_single_section_config_with_patterns() {
2926 let patterns = vec!["test.*".to_string(), "bench.*".to_string()];
2928 let section = build_single_section_config(&patterns, &[], vec![], None, false, false);
2929
2930 assert_eq!(
2931 section.measurement_filter,
2932 Some("(?:test.*)|(?:bench.*)".to_string())
2933 );
2934 }
2935
2936 #[test]
2937 fn test_build_single_section_config_with_all_params() {
2938 let patterns = vec!["test.*".to_string()];
2940 let kv_filters = vec![
2941 ("os".to_string(), "linux".to_string()),
2942 ("arch".to_string(), "x64".to_string()),
2943 ];
2944 let separate = vec!["os".to_string(), "arch".to_string()];
2945
2946 let section = build_single_section_config(
2947 &patterns,
2948 &kv_filters,
2949 separate.clone(),
2950 Some(ReductionFunc::Median),
2951 true,
2952 true,
2953 );
2954
2955 assert_eq!(section.measurement_filter, Some("(?:test.*)".to_string()));
2956 assert_eq!(section.key_value_filter, kv_filters);
2957 assert_eq!(section.separate_by, separate);
2958 assert_eq!(section.aggregate_by, Some(ReductionFunc::Median));
2959 assert!(section.show_epochs);
2960 assert!(section.show_changes);
2961 }
2962
2963 #[test]
2964 fn test_merge_show_flags_both_false() {
2965 let sections = vec![SectionConfig {
2967 id: "test".to_string(),
2968 placeholder: "{{SECTION[test]}}".to_string(),
2969 measurement_filter: None,
2970 key_value_filter: vec![],
2971 separate_by: vec![],
2972 aggregate_by: None,
2973 depth: None,
2974 show_epochs: false,
2975 show_changes: false,
2976 }];
2977
2978 let merged = merge_show_flags(sections, false, false);
2979
2980 assert_eq!(merged.len(), 1);
2981 assert!(!merged[0].show_epochs);
2982 assert!(!merged[0].show_changes);
2983 }
2984
2985 #[test]
2986 fn test_merge_show_flags_section_true_global_false() {
2987 let sections = vec![SectionConfig {
2989 id: "test".to_string(),
2990 placeholder: "{{SECTION[test]}}".to_string(),
2991 measurement_filter: None,
2992 key_value_filter: vec![],
2993 separate_by: vec![],
2994 aggregate_by: None,
2995 depth: None,
2996 show_epochs: true,
2997 show_changes: true,
2998 }];
2999
3000 let merged = merge_show_flags(sections, false, false);
3001
3002 assert_eq!(merged.len(), 1);
3003 assert!(merged[0].show_epochs);
3004 assert!(merged[0].show_changes);
3005 }
3006
3007 #[test]
3008 fn test_merge_show_flags_section_false_global_true() {
3009 let sections = vec![SectionConfig {
3011 id: "test".to_string(),
3012 placeholder: "{{SECTION[test]}}".to_string(),
3013 measurement_filter: None,
3014 key_value_filter: vec![],
3015 separate_by: vec![],
3016 aggregate_by: None,
3017 depth: None,
3018 show_epochs: false,
3019 show_changes: false,
3020 }];
3021
3022 let merged = merge_show_flags(sections, true, true);
3023
3024 assert_eq!(merged.len(), 1);
3025 assert!(merged[0].show_epochs);
3026 assert!(merged[0].show_changes);
3027 }
3028
3029 #[test]
3030 fn test_merge_show_flags_both_true() {
3031 let sections = vec![SectionConfig {
3033 id: "test".to_string(),
3034 placeholder: "{{SECTION[test]}}".to_string(),
3035 measurement_filter: None,
3036 key_value_filter: vec![],
3037 separate_by: vec![],
3038 aggregate_by: None,
3039 depth: None,
3040 show_epochs: true,
3041 show_changes: true,
3042 }];
3043
3044 let merged = merge_show_flags(sections, true, true);
3045
3046 assert_eq!(merged.len(), 1);
3047 assert!(merged[0].show_epochs);
3048 assert!(merged[0].show_changes);
3049 }
3050
3051 #[test]
3052 fn test_merge_show_flags_mixed_flags() {
3053 let sections = vec![SectionConfig {
3055 id: "test".to_string(),
3056 placeholder: "{{SECTION[test]}}".to_string(),
3057 measurement_filter: None,
3058 key_value_filter: vec![],
3059 separate_by: vec![],
3060 aggregate_by: None,
3061 depth: None,
3062 show_epochs: true,
3063 show_changes: false,
3064 }];
3065
3066 let merged = merge_show_flags(sections, false, true);
3067
3068 assert_eq!(merged.len(), 1);
3069 assert!(merged[0].show_epochs); assert!(merged[0].show_changes); }
3072
3073 #[test]
3074 fn test_merge_show_flags_multiple_sections() {
3075 let sections = vec![
3077 SectionConfig {
3078 id: "section1".to_string(),
3079 placeholder: "{{SECTION[section1]}}".to_string(),
3080 measurement_filter: None,
3081 key_value_filter: vec![],
3082 separate_by: vec![],
3083 aggregate_by: None,
3084 depth: None,
3085 show_epochs: false,
3086 show_changes: false,
3087 },
3088 SectionConfig {
3089 id: "section2".to_string(),
3090 placeholder: "{{SECTION[section2]}}".to_string(),
3091 measurement_filter: None,
3092 key_value_filter: vec![],
3093 separate_by: vec![],
3094 aggregate_by: None,
3095 depth: None,
3096 show_epochs: true,
3097 show_changes: false,
3098 },
3099 ];
3100
3101 let merged = merge_show_flags(sections, true, true);
3102
3103 assert_eq!(merged.len(), 2);
3104 assert!(merged[0].show_epochs);
3106 assert!(merged[0].show_changes);
3107 assert!(merged[1].show_epochs);
3108 assert!(merged[1].show_changes);
3109 }
3110}