git_perf/
reporting.rs

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
27// Re-export for backwards compatibility with CLI
28pub use crate::reporting_config::ReportTemplateConfig;
29
30/// Metadata for rendering report templates
31#[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
75/// Parameters for change point and epoch detection
76struct 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
87/// Extract Plotly JavaScript dependencies and plot content
88///
89/// Uses Plotly's native API to generate proper HTML components:
90/// - `plotly_head`: Script tags for Plotly.js library (from CDN by default)
91/// - `plotly_body`: Inline div + script for the actual plot content
92///
93/// This approach is more robust than HTML string parsing and leverages
94/// Plotly's to_inline_html() method which generates embeddable content
95/// assuming Plotly.js is already available on the page.
96fn extract_plotly_parts(plot: &Plot) -> (String, String) {
97    // Get the Plotly.js library script tags from CDN
98    // This returns script tags that load plotly.min.js from CDN
99    let plotly_head = Plot::online_cdn_js();
100
101    // Get the inline plot HTML (div + script) without full HTML document
102    // This assumes plotly.js is already loaded (which we handle via plotly_head)
103    // Pass None to auto-generate a unique div ID
104    let plotly_body = plot.to_inline_html(None);
105
106    (plotly_head, plotly_body)
107}
108
109/// Load template from file or return default
110fn 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
126/// Load custom CSS content from file
127fn 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            // Try config
132            if let Some(config_path) = config::report_custom_css_path() {
133                config_path
134            } else {
135                // No custom CSS
136                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
154/// Default number of characters to display from commit SHA in report x-axis.
155///
156/// This value is used when displaying commit hashes on the x-axis of plots,
157/// optimized for display space and readability in interactive visualizations.
158const DEFAULT_COMMIT_HASH_DISPLAY_LENGTH: usize = 6;
159
160// Color constants for change point visualization
161/// RGBA color for performance regressions (increases in metrics like execution time).
162/// Red color with 80% opacity.
163const REGRESSION_COLOR: &str = "rgba(220, 53, 69, 0.8)";
164
165/// RGBA color for performance improvements (decreases in metrics like execution time).
166/// Green color with 80% opacity.
167const IMPROVEMENT_COLOR: &str = "rgba(40, 167, 69, 0.8)";
168
169/// Color for epoch markers in the plot.
170const EPOCH_MARKER_COLOR: &str = "gray";
171
172// Line width constants for plot styling
173/// Line width for epoch markers (vertical dashed lines).
174const EPOCH_MARKER_LINE_WIDTH: f64 = 2.0;
175
176/// Default HTML template used when no custom template is provided.
177/// Replicates the behavior of plotly.rs's to_html() method while maintaining
178/// consistency with the template-based approach.
179const 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
192/// Write bytes to file or stdout, handling BrokenPipe error.
193///
194/// If `output_path` is "-", writes to stdout. Otherwise, creates/overwrites the file.
195/// BrokenPipe errors are suppressed to allow piping to commands like `head` or `less`.
196fn write_output(output_path: &Path, bytes: &[u8]) -> Result<()> {
197    if output_path == Path::new("-") {
198        // Write to stdout
199        match io::stdout().write_all(bytes) {
200            Err(e) if e.kind() == ErrorKind::BrokenPipe => Ok(()),
201            res => res,
202        }
203    } else {
204        // Write to file
205        File::create(output_path)?.write_all(bytes)
206    }?;
207    Ok(())
208}
209
210/// Formats a measurement name with its configured unit, if available.
211/// Returns "measurement_name (unit)" if unit is configured, otherwise just "measurement_name".
212fn 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/// Output format for reports
220#[derive(Debug, Clone, Copy, PartialEq, Eq)]
221enum OutputFormat {
222    Html,
223    Csv,
224}
225
226impl OutputFormat {
227    /// Determine output format from file path
228    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
243/// CSV row representation of a measurement with unit column.
244/// Metadata is stored separately and concatenated during serialization.
245struct 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    /// Create a CSV row from MeasurementData
257    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    /// Create a CSV row from MeasurementSummary
271    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    /// Format as a tab-delimited CSV line
294    /// Float values are formatted to always include at least one decimal place
295    fn to_csv_line(&self) -> String {
296        // Format floats with appropriate precision
297        // If value is a whole number, format as X.0, otherwise use default precision
298        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        // Add metadata key-value pairs
316        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
327/// Output from processing a single section.
328///
329/// For HTML reports, contains the plot div + script for template replacement.
330/// For CSV reports, this is typically empty (CSV accumulates all data internally).
331struct SectionOutput {
332    #[allow(dead_code)]
333    section_id: String,
334    placeholder: String, // For HTML template replacement (e.g., "{{SECTION[id]}}")
335    content: Vec<u8>,    // Section-specific content (plot HTML or empty for CSV)
336}
337
338trait Reporter<'a> {
339    fn add_commits(&mut self, hashes: &'a [Commit]);
340
341    // Section lifecycle methods
342    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    // Data addition methods (section-scoped)
351    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    // Deprecated: Use finalize() instead
382    #[allow(dead_code)]
383    fn as_bytes(&self) -> Vec<u8>;
384}
385
386struct PlotlyReporter {
387    // Global state (set once via add_commits or with_template)
388    all_commits: Vec<Commit>,
389    // Manual axis data reversal implementation: plotly-rs does not support autorange="reversed"
390    // The autorange parameter only accepts boolean values (as of v0.13.5), requiring manual
391    // index reversal to achieve reversed axis display (newest commits on right, oldest on left)
392    // See: https://github.com/kaihowl/git-perf/issues/339
393    size: usize,
394    template: Option<String>,
395    #[allow(dead_code)]
396    metadata: Option<ReportMetadata>,
397
398    // Per-section state (reset on begin_section)
399    current_section_id: Option<String>,
400    current_placeholder: Option<String>,
401    current_plot: Plot,
402    // Track units for all measurements to determine if we should add unit to Y-axis label
403    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    /// Returns the Y-axis with unit label if all measurements share the same unit.
448    fn compute_y_axis(&self) -> Option<Axis> {
449        // Check if all measurements have the same unit (and at least one unit exists)
450        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                // All measurements share the same unit - add it to Y-axis label
463                return Some(Axis::new().title(Title::from(format!("Value ({})", unit))));
464            }
465        }
466        None
467    }
468
469    /// Helper function to add a vertical line segment to coordinate vectors.
470    ///
471    /// Adds two points (bottom and top of the line) plus a separator (None).
472    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        // Bottom point
482        x_coords.push(Some(x_pos));
483        y_coords.push(Some(y_min));
484        hover_texts.push(hover_text.clone());
485
486        // Top point
487        x_coords.push(Some(x_pos));
488        y_coords.push(Some(y_max));
489        hover_texts.push(hover_text);
490
491        // Separator (breaks the line for next segment)
492        x_coords.push(None);
493        y_coords.push(None);
494        hover_texts.push(String::new());
495    }
496
497    /// Helper function to configure trace legend based on group values.
498    ///
499    /// If group_values is non-empty, uses group label with legend grouping.
500    /// Otherwise, uses measurement display name directly.
501    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    /// Helper to process a vertical marker (epoch or change point) and add its coordinates.
529    ///
530    /// Returns Ok(x_pos) if successful, Err if index out of bounds.
531    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    /// Add epoch boundary traces to the plot.
554    ///
555    /// These are vertical dashed gray lines where measurement epochs change.
556    /// Hidden by default (legendonly), user clicks legend to toggle visibility.
557    /// Uses actual commit indices to properly map epoch transitions when measurements
558    /// don't exist for all commits.
559    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    /// Add change point traces with explicit commit index mapping.
627    ///
628    /// This version uses the actual commit indices to properly map change points
629    /// when measurements don't exist for all commits.
630    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        // Collect all change points into a single trace with markers
645        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            // Get the actual y value from the measurement data
662            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            // Add single point at the actual measurement value
687            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    /// Prepare hover text for data points based on commit indices
716    ///
717    /// For each commit index in the provided indexed data, creates a hover text string
718    /// containing the short commit hash, author name, and commit title.
719    ///
720    /// # Arguments
721    /// * `indices` - Iterator of original commit indices (before reversal by convert_to_x_y)
722    ///
723    /// # Returns
724    /// Vector of hover text strings in HTML format, one per index in the same order
725    ///
726    /// # Note
727    /// The hover text array order matches the input data order. The indices are used
728    /// directly to look up commits in all_commits (they are NOT reversed here - that
729    /// only happens to the x-axis values in convert_to_x_y).
730    fn prepare_hover_text(&self, indices: impl Iterator<Item = usize>) -> Vec<String> {
731        indices
732            .map(|idx| {
733                // Use idx directly to look up the commit
734                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                    // Fallback if commit not found
743                    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        // Store commits for later use in begin_section
753        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        // Create new plot for this section
762        let config = Configuration::default().responsive(true).fill_frame(false);
763        let mut plot = Plot::new();
764        plot.set_configuration(config);
765
766        // Set up layout with commit axis (from stored commits)
767        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        // Finalize current plot (add y-axis if all units match)
807        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        // Extract plotly body (just the div + script, no <html> wrapper)
818        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 template is provided, use template replacement
833        if let Some(template) = self.template {
834            let mut output = template;
835
836            // Replace section placeholders
837            for section in &sections {
838                output = output.replace(
839                    &section.placeholder,
840                    &String::from_utf8_lossy(&section.content),
841                );
842            }
843
844            // Replace global placeholders
845            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            // No template - single section output (for backward compatibility)
858            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        // Extract indices for hover text before consuming indexed_measurements
872        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        // Track unit for this measurement
882        self.measurement_units
883            .push(config::measurement_unit(measurement_name));
884
885        let measurement_display = format_measurement_with_unit(measurement_name);
886
887        // Prepare hover text with commit metadata
888        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            // Join group values with "/" for display (only at display time)
894            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        // Extract indices for hover text before consuming indexed_measurements
914        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        // Track unit for this measurement
924        self.measurement_units
925            .push(config::measurement_unit(measurement_name));
926
927        let measurement_display = format_measurement_with_unit(measurement_name);
928
929        // Prepare hover text with commit metadata
930        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            // Join group values with "/" for display (only at display time)
939            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        // Get the final plot (with or without custom y-axis)
990        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        // Always use template approach for consistency
1001        // If no custom template is provided, use the default template
1002        let template = self.template.as_deref().unwrap_or(DEFAULT_HTML_TEMPLATE);
1003
1004        // Use metadata if available, otherwise create a minimal default
1005        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        // Apply template with placeholder substitution
1015        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}}", ""); // Future enhancement
1025
1026        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        // CSV doesn't care about section boundaries - no-op
1053    }
1054
1055    fn end_section(&mut self) -> Result<SectionOutput> {
1056        // CSV returns empty SectionOutput - actual data stays in reporter
1057        // All data will be emitted in finalize()
1058        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        // Ignore sections parameter - CSV is flat
1071        // Generate single TSV output from accumulated data
1072        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        // Add raw measurements
1080        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        // Add summarized measurements
1087        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        // Add header
1121        lines.push("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit".to_string());
1122
1123        // Add raw measurements
1124        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        // Add summarized measurements
1131        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        // Store summarized data to be serialized in as_bytes
1154        // Join group values for CSV output (flat format)
1155        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        // CSV reporter does not support epoch boundary visualization
1181    }
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        // CSV reporter does not support change point visualization
1192    }
1193}
1194
1195/// Compute group value combinations for splitting measurements by metadata keys.
1196///
1197/// Returns a vector of group values where each inner vector contains the values
1198/// for the split keys. If no splits are specified, returns a single empty group.
1199///
1200/// # Errors
1201/// Returns error if separate_by is non-empty but no measurements have all required keys
1202fn compute_group_values_to_process<'a>(
1203    filtered_measurements: impl Iterator<Item = &'a MeasurementData> + Clone,
1204    separate_by: &[String],
1205    context_id: &str, // For error messages (e.g., "Section 'test-overview'" or "measurement X")
1206) -> 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
1238/// Filter measurements by regex patterns and key-value pairs.
1239///
1240/// This helper consolidates the filtering logic used by both HTML and CSV report paths.
1241/// Returns a nested vector where each inner vector contains filtered measurements for one commit.
1242///
1243/// # Arguments
1244/// * `commits` - The commits to filter measurements from
1245/// * `filters` - Compiled regex filters for measurement names (empty = no regex filtering)
1246/// * `key_values` - Key-value pairs that measurements must match
1247fn 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                    // Apply regex filter if specified
1260                    if !filters.is_empty() && !crate::filter::matches_any_filter(&m.name, filters) {
1261                        return false;
1262                    }
1263                    // Apply key-value filters
1264                    m.key_values_is_superset_of(key_values)
1265                })
1266                .collect()
1267        })
1268        .collect()
1269}
1270
1271/// Collect and aggregate measurement data for change point detection.
1272///
1273/// Returns tuple of (commit_indices, values, epochs, commit_shas).
1274/// Each vector has one entry per commit with measurements.
1275fn 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
1301/// Add a trace (line) to the plot for a measurement group.
1302///
1303/// If aggregate_by is Some, adds a summarized trace with aggregated values.
1304/// If aggregate_by is None, adds a raw trace with all individual measurements.
1305fn 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
1333/// Prepared data for change point and epoch detection visualization
1334struct PreparedDetectionData {
1335    /// Reversed commit indices (newest on right)
1336    indices: Vec<usize>,
1337    /// Reversed measurement values
1338    values: Vec<f64>,
1339    /// Reversed epoch numbers
1340    epochs: Vec<u32>,
1341    /// Reversed commit SHAs
1342    commit_shas: Vec<String>,
1343    /// Y-axis minimum with padding
1344    y_min: f64,
1345    /// Y-axis maximum with padding
1346    y_max: f64,
1347}
1348
1349/// Prepares common data needed for both epoch and change point detection
1350///
1351/// This helper extracts the shared data preparation logic:
1352/// - Validates that values are not empty
1353/// - Calculates y-axis bounds with 10% padding
1354/// - Reverses all data for display (newest commits on right)
1355///
1356/// Returns None if values are empty, otherwise Some(PreparedDetectionData)
1357fn 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    // Calculate y-axis bounds for vertical lines (10% padding)
1371    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    // Reverse data for display
1380    // Reversal is needed because plotly doesn't support reversed axes natively,
1381    // so we manually reverse the data to display newest commits on the right.
1382    // This ensures change point direction matches visual interpretation.
1383    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
1393/// Adds epoch boundary traces to the report
1394///
1395/// Detects transitions between epochs and adds vertical lines to mark boundaries.
1396fn 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
1417/// Adds change point detection traces to the report
1418///
1419/// Runs PELT algorithm to detect performance regime changes and adds
1420/// annotations to the report.
1421fn 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
1455/// Orchestrates epoch and change point trace addition based on configuration
1456///
1457/// This is the main entry point that:
1458/// 1. Prepares common data once
1459/// 2. Delegates to specialized functions based on show_epochs and show_changes flags
1460///
1461/// # Arguments
1462/// * `reporter` - Mutable reference to any Reporter implementation
1463/// * `params` - Detection parameters (indices, values, epochs, etc.)
1464fn add_change_point_and_epoch_traces(
1465    reporter: &mut dyn Reporter,
1466    params: ChangePointDetectionParams,
1467) {
1468    let Some(prepared) = prepare_detection_data(&params) else {
1469        return;
1470    };
1471
1472    if params.show_epochs {
1473        add_epoch_traces(reporter, &params, &prepared);
1474    }
1475
1476    if params.show_changes {
1477        add_change_point_traces(reporter, &params, &prepared);
1478    }
1479}
1480
1481/// Helper to add change point and epoch detection traces for a measurement group
1482///
1483/// This function encapsulates the common pattern of:
1484/// 1. Checking if change detection is requested
1485/// 2. Collecting measurement data for change detection
1486/// 3. Creating detection parameters
1487/// 4. Adding traces to the reporter
1488///
1489/// # Arguments
1490/// * `reporter` - Mutable reference to any Reporter implementation
1491/// * `group_measurements` - Iterator over measurements for this group
1492/// * `commits` - All commits (needed for change detection data collection)
1493/// * `measurement_name` - Name of the measurement being processed
1494/// * `group_value` - Group values for this specific group
1495/// * `aggregate_by` - Aggregation function to use
1496/// * `show_epochs` - Whether to show epoch annotations
1497/// * `show_changes` - Whether to detect and show change points
1498#[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
1532/// Wraps measurement filter patterns in non-capturing groups and joins them with |
1533/// This ensures correct precedence when combining multiple regex patterns
1534fn 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
1548/// Builds a single-section config from CLI arguments
1549/// Used when template has no SECTION blocks (single-section mode)
1550fn 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
1571/// Merges global show flags with section-level flags using OR logic
1572/// Global flags override section flags (if global is true, result is true)
1573fn 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/// Prepare sections, template, and metadata based on output format and configuration.
1589///
1590/// For HTML: Loads template, parses sections, creates metadata.
1591/// For CSV: Creates synthetic single section, minimal metadata.
1592///
1593/// Returns (sections, template_str, metadata).
1594#[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            // Load template (custom or default)
1609            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            // Parse or synthesize sections
1620            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            // Build metadata
1644            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            // Warn if template provided
1652            if template_config.template_path.is_some() {
1653                log::warn!("Template argument is ignored for CSV output format");
1654            }
1655
1656            // Create synthetic single section for CSV
1657            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            // CSV doesn't use metadata, but provide default for API consistency
1667            let metadata = ReportMetadata::new(None, String::new(), commits);
1668
1669            Ok((vec![section], None, metadata))
1670        }
1671    }
1672}
1673
1674/// Process a single section using the Reporter trait.
1675///
1676/// Calls reporter.begin_section(), filters measurements, processes groups,
1677/// and returns the section output via reporter.end_section().
1678fn process_section<'a>(
1679    reporter: &mut dyn Reporter<'a>,
1680    commits: &'a [Commit],
1681    section: &SectionConfig,
1682) -> Result<SectionOutput> {
1683    reporter.begin_section(&section.id, &section.placeholder);
1684
1685    // Determine section-specific commits (depth override)
1686    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    // Filter measurements
1703    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, &section.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 empty section output without generating plot content
1721        return Ok(SectionOutput {
1722            section_id: section.id.clone(),
1723            placeholder: section.placeholder.clone(),
1724            content: Vec::new(),
1725        });
1726    }
1727
1728    // Process measurement groups (same logic as current generate_single_section_report)
1729    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            &section.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
1767            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
1776            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    // Compile combined regex patterns (measurements as exact matches + filter patterns)
1806    // early to fail fast on invalid patterns
1807    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    // Determine output format
1819    let output_format = OutputFormat::from_path(&output)
1820        .ok_or_else(|| anyhow!("Could not determine output format from file extension"))?;
1821
1822    // Parse or synthesize sections and metadata
1823    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    // Create appropriate reporter
1836    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    // UNIFIED PATH: Process all sections using Reporter trait
1845    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    // Check if any section found measurements (before finalize consumes section_outputs)
1853    // For HTML: check if any section has non-empty content
1854    // For CSV: section outputs are always empty, will check report_bytes after finalize
1855    let has_measurements_from_sections = section_outputs.iter().any(|s| !s.content.is_empty());
1856
1857    // Finalize report
1858    let report_bytes = reporter.finalize(section_outputs, &metadata);
1859
1860    // Determine if any measurements were found
1861    // For CSV, check if finalized report is non-empty (CSV returns empty Vec if no measurements)
1862    let has_measurements = match output_format {
1863        OutputFormat::Html => has_measurements_from_sections,
1864        OutputFormat::Csv => !report_bytes.is_empty(),
1865    };
1866
1867    // Check if any measurements were found
1868    // For multi-section templates (>1 section), allow empty sections (just log warnings)
1869    // For single-section reports, bail if no measurements found
1870    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
1876    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        // HTML output should contain plotly-related content
1926        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        // Verify default template structure is present
1937        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        // Verify plotly content is embedded
1946        assert!(html.contains("plotly") || html.contains("Plotly"));
1947    }
1948
1949    #[test]
1950    fn test_format_measurement_with_unit_no_unit() {
1951        // Test measurement without unit configured
1952        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        // Create a simple plot
1959        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        // Head should contain script tags for plotly.js from CDN
1966        assert!(head.contains("<script"));
1967        assert!(head.contains("plotly"));
1968
1969        // Body should contain the plot div and script
1970        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        // Verify the structure of extracted parts
1978        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        // Head should be CDN script tags only (no full HTML structure)
1985        assert!(!head.contains("<html>"));
1986        assert!(!head.contains("<head>"));
1987        assert!(!head.contains("<body>"));
1988
1989        // Body should be inline content (div + script), not full HTML
1990        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        // Add commits
2104        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        // Add trace with a measurement (simulate tracking units)
2121        reporter.measurement_units.push(Some("ms".to_string()));
2122
2123        // Get HTML output
2124        let bytes = reporter.as_bytes();
2125        let html = String::from_utf8_lossy(&bytes);
2126
2127        // The HTML should be generated
2128        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        // Simulate multiple measurements with same unit
2137        reporter.measurement_units.push(Some("ms".to_string()));
2138        reporter.measurement_units.push(Some("ms".to_string()));
2139
2140        // Get HTML output - should include Y-axis with unit
2141        let bytes = reporter.as_bytes();
2142        let html = String::from_utf8_lossy(&bytes);
2143
2144        // The HTML should contain the Y-axis label with unit
2145        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        // Simulate measurements with different units
2153        reporter.measurement_units.push(Some("ms".to_string()));
2154        reporter.measurement_units.push(Some("bytes".to_string()));
2155
2156        // Get HTML output - should NOT include Y-axis with unit
2157        let bytes = reporter.as_bytes();
2158        let html = String::from_utf8_lossy(&bytes);
2159
2160        // The HTML should not contain a Y-axis label with a specific unit
2161        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        // Empty reporter should produce empty bytes
2170        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        // Add commits
2181        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        // Add a measurement
2190        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        // Get CSV output
2200        let bytes = reporter.as_bytes();
2201        let csv = String::from_utf8_lossy(&bytes);
2202
2203        // Should contain header row with unit column
2204        assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2205
2206        // Should contain data row with commit and measurement data
2207        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        // Check that header and base fields are correct
2275        assert!(csv.starts_with("commit\tepoch\tmeasurement\ttimestamp\tvalue\tunit\n"));
2276        assert!(csv.contains("commit123\t1\ttest\t1000.0\t3.5\t"));
2277        // Check that metadata is present (order may vary due to HashMap)
2278        assert!(csv.contains("os=linux"));
2279        assert!(csv.contains("arch=x64"));
2280        // Check trailing newline
2281        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        // Whole numbers should be formatted with .0
2362        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        // Summarized measurements have timestamp 0.0
2389        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        // Check that trace is set to legendonly (hidden by default)
2443        assert!(html.contains("legendonly"));
2444        // Check that the trace name includes "Epochs"
2445        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        // Should not crash and plot should still be valid
2468        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]; // Measurement values
2504        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        // Check for change point trace (single trace for all change points)
2516        assert!(html.contains("build_time (Change Points)"));
2517        // Verify markers mode is used
2518        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]; // Measurement values
2556        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        // Should have single change points trace containing both directions
2568        assert!(html.contains("metric (Change Points)"));
2569        // Verify both regression and improvement symbols are present in hover text
2570        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        // Should not crash and plot should still be valid
2591        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]; // Measurement values
2627        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        // Hover text should contain percentage and short SHA
2639        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        // Create commits with distinct identifiable data
2650        // Order passed add_commits is in walk_commits_from order:
2651        // The first commit is the youngest commit.
2652        // Later commits are older commits.
2653        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        // Add measurements at indices 0 (oldest) and 2 (newest)
2696        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        // Extract and parse Plotly JSON data to verify positional alignment
2707        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        // Access the "data" field which contains the array of traces
2714        let plotly_data = plotly_config["data"]
2715            .as_array()
2716            .expect("Config should have 'data' field as array");
2717
2718        // Get the first trace (should be the box plot trace)
2719        let trace = plotly_data.first().expect("Should have at least one trace");
2720
2721        // Extract x, y, and hover text arrays
2722        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        // Try both "text" and "hovertext" field names
2726        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        // Verify we have 2 data points
2733        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        // Verify positional alignment: for each data point, check that
2738        // the hover text at position i corresponds to the correct commit for x-coordinate x[i]
2739        //
2740        // Expected alignment:
2741        // - x=0 (oldest, rightmost): y=100.0, hover contains aaaaaaa/Author A/first commit
2742        // - x=2 (oldest, leftmost): y=300.0, hover contains ccccccc/Author C/third commit
2743        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                // Leftmost position - should show oldest commit (index 0)
2752                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                // Rightmost position - should show newest commit (index 2)
2770                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    /// Helper function to extract Plotly data array from HTML
2793    ///
2794    /// Finds the `Plotly.newPlot(...)` call and extracts the data array.
2795    /// The call format is: Plotly.newPlot("id", {"data":[...]})
2796    /// This function extracts the entire config object (second parameter).
2797    fn extract_plotly_data_array(html: &str) -> Result<String, String> {
2798        // Find "Plotly.newPlot("
2799        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        // Skip past the div id argument (first comma)
2805        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        // Find the opening brace of the config object
2812        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        // Count braces to find matching close brace
2826        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        // The default template should not have sections
2853        // It should be a single-section template
2854        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        // Test with empty patterns - should return None
2862        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        // Test with single pattern - should wrap in non-capturing group
2870        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        // Test with multiple patterns - should wrap each and join with |
2878        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        // Test with complex regex patterns
2886        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        // Test building section config with no filters
2897        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        // Test building section config with measurement patterns
2913        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        // Test building section config with all parameters
2925        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        // When both section and global flags are false, result should be false
2952        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        // When section flag is true and global is false, result should be true (OR logic)
2974        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        // When global flag is true and section is false, result should be true (OR logic)
2996        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        // When both section and global flags are true, result should be true
3018        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        // Test with mixed flag combinations
3040        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); // section true OR global false = true
3056        assert!(merged[0].show_changes); // section false OR global true = true
3057    }
3058
3059    #[test]
3060    fn test_merge_show_flags_multiple_sections() {
3061        // Test merging flags for multiple sections
3062        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        // Both sections should have both flags true due to global flags
3091        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}