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