Skip to main content

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