git_perf/
reporting_config.rs

1use anyhow::{anyhow, bail, Result};
2use regex::Regex;
3use std::{path::PathBuf, sync::OnceLock};
4
5use crate::stats::ReductionFunc;
6
7/// Cached regex for parsing section placeholders (compiled once)
8static SECTION_PLACEHOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
9
10/// Cached regex for finding all section blocks in a template (compiled once)
11static SECTION_FINDER_REGEX: OnceLock<Regex> = OnceLock::new();
12
13/// Get or compile the section placeholder parsing regex
14fn section_placeholder_regex() -> &'static Regex {
15    SECTION_PLACEHOLDER_REGEX.get_or_init(|| {
16        Regex::new(r"(?s)\{\{SECTION\[([^\]]+)\](.*?)\}\}")
17            .expect("Invalid section placeholder regex pattern")
18    })
19}
20
21/// Get or compile the section finder regex
22fn section_finder_regex() -> &'static Regex {
23    SECTION_FINDER_REGEX.get_or_init(|| {
24        Regex::new(r"(?s)\{\{SECTION\[[^\]]+\].*?\}\}")
25            .expect("Invalid section finder regex pattern")
26    })
27}
28
29/// Configuration for report templates
30#[derive(Debug, Clone)]
31pub struct ReportTemplateConfig {
32    pub template_path: Option<PathBuf>,
33    pub custom_css_path: Option<PathBuf>,
34    pub title: Option<String>,
35}
36
37/// Configuration for a single report section in a multi-section template
38#[derive(Debug, Clone)]
39pub struct SectionConfig {
40    /// Section identifier (e.g., "test-overview", "bench-median")
41    pub id: String,
42    /// Original placeholder text to replace (e.g., "{{SECTION\[id\] param: value }}")
43    pub placeholder: String,
44    /// Regex pattern for selecting measurements
45    pub measurement_filter: Option<String>,
46    /// Key-value pairs to match (e.g., os=linux,arch=x64)
47    pub key_value_filter: Vec<(String, String)>,
48    /// Metadata keys to split traces by (e.g., ["os", "arch"])
49    pub separate_by: Vec<String>,
50    /// Aggregation function (none means raw data)
51    pub aggregate_by: Option<ReductionFunc>,
52    /// Number of commits (overrides global depth)
53    pub depth: Option<usize>,
54    /// Show epoch boundaries for this section
55    pub show_epochs: bool,
56    /// Detect and show change points for this section
57    pub show_changes: bool,
58}
59
60impl SectionConfig {
61    /// Parse a single section placeholder into a SectionConfig
62    /// Format: {{SECTION\[id\] param: value, param2: value2 }}
63    pub fn parse(placeholder: &str) -> Result<Self> {
64        // Use cached regex to extract section ID and parameters
65        let section_regex = section_placeholder_regex();
66
67        let captures = section_regex
68            .captures(placeholder)
69            .ok_or_else(|| anyhow!("Invalid section placeholder format: {}", placeholder))?;
70
71        let id = captures
72            .get(1)
73            .expect("Regex capture group 1 (section ID) must exist")
74            .as_str()
75            .trim()
76            .to_string();
77        let params_str = captures
78            .get(2)
79            .expect("Regex capture group 2 (parameters) must exist")
80            .as_str()
81            .trim();
82
83        // Parse parameters
84        let mut measurement_filter = None;
85        let mut key_value_filter = Vec::new();
86        let mut separate_by = Vec::new();
87        let mut aggregate_by = None;
88        let mut depth = None;
89        let mut show_epochs = false;
90        let mut show_changes = false;
91
92        if !params_str.is_empty() {
93            // Split by newlines and trim each line
94            for line in params_str.lines() {
95                let line = line.trim();
96                if line.is_empty() {
97                    continue;
98                }
99
100                // Parse "param: value" format
101                if let Some((key, value)) = line.split_once(':') {
102                    let key = key.trim();
103                    let value = value.trim();
104
105                    match key {
106                        "measurement-filter" => {
107                            measurement_filter = Some(value.to_string());
108                        }
109                        "key-value-filter" => {
110                            key_value_filter = parse_key_value_filter(value)?;
111                        }
112                        "separate-by" => {
113                            separate_by = parse_comma_separated_list(value);
114                        }
115                        "aggregate-by" => {
116                            aggregate_by = parse_aggregate_by(value)?;
117                        }
118                        "depth" => {
119                            depth = Some(parse_depth(value)?);
120                        }
121                        "show-epochs" => {
122                            show_epochs = parse_boolean(value, "show-epochs")?;
123                        }
124                        "show-changes" => {
125                            show_changes = parse_boolean(value, "show-changes")?;
126                        }
127                        _ => {
128                            // Unknown parameter - warn but don't fail
129                            log::warn!("Unknown section parameter: {}", key);
130                        }
131                    }
132                }
133            }
134        }
135
136        Ok(SectionConfig {
137            id,
138            placeholder: placeholder.to_string(),
139            measurement_filter,
140            key_value_filter,
141            separate_by,
142            aggregate_by,
143            depth,
144            show_epochs,
145            show_changes,
146        })
147    }
148}
149
150/// Parse key-value filter pairs from a comma-separated string
151/// Format: "key1=value1,key2=value2"
152fn parse_key_value_filter(value: &str) -> Result<Vec<(String, String)>> {
153    value
154        .split(',')
155        .map(|pair| {
156            let pair = pair.trim();
157            let (k, v) = pair
158                .split_once('=')
159                .ok_or_else(|| anyhow!("Invalid key-value-filter format: {}", pair))?;
160            let k = k.trim();
161            let v = v.trim();
162            if k.is_empty() {
163                bail!("Empty key in key-value-filter: '{}'", pair);
164            }
165            if v.is_empty() {
166                bail!("Empty value in key-value-filter: '{}'", pair);
167            }
168            Ok((k.to_string(), v.to_string()))
169        })
170        .collect()
171}
172
173/// Parse a comma-separated list into a vector of strings
174fn parse_comma_separated_list(value: &str) -> Vec<String> {
175    value
176        .split(',')
177        .map(|s| s.trim().to_string())
178        .filter(|s| !s.is_empty())
179        .collect()
180}
181
182/// Parse an aggregate-by value into a ReductionFunc
183fn parse_aggregate_by(value: &str) -> Result<Option<ReductionFunc>> {
184    match value {
185        "none" => Ok(None),
186        "min" => Ok(Some(ReductionFunc::Min)),
187        "max" => Ok(Some(ReductionFunc::Max)),
188        "median" => Ok(Some(ReductionFunc::Median)),
189        "mean" => Ok(Some(ReductionFunc::Mean)),
190        _ => bail!("Invalid aggregate-by value: {}", value),
191    }
192}
193
194/// Parse a depth value into usize
195fn parse_depth(value: &str) -> Result<usize> {
196    value
197        .parse::<usize>()
198        .map_err(|_| anyhow!("Invalid depth value: {}", value))
199}
200
201/// Parse a boolean value from various string representations
202fn parse_boolean(value: &str, param_name: &str) -> Result<bool> {
203    if value.eq_ignore_ascii_case("true") || value.eq_ignore_ascii_case("yes") || value == "1" {
204        Ok(true)
205    } else if value.eq_ignore_ascii_case("false")
206        || value.eq_ignore_ascii_case("no")
207        || value == "0"
208    {
209        Ok(false)
210    } else {
211        bail!("Invalid {} value: {} (use true/false)", param_name, value)
212    }
213}
214
215/// Parse all section placeholders from a template
216pub fn parse_template_sections(template: &str) -> Result<Vec<SectionConfig>> {
217    // Use cached regex to find all {{SECTION[...] ...}} blocks
218    let section_regex = section_finder_regex();
219
220    let mut sections = Vec::new();
221    let mut seen_ids = std::collections::HashSet::new();
222
223    for captures in section_regex.find_iter(template) {
224        let placeholder = captures.as_str();
225        let section = SectionConfig::parse(placeholder)?;
226
227        // Check for duplicate section IDs
228        if !seen_ids.insert(section.id.clone()) {
229            bail!("Duplicate section ID found: {}", section.id);
230        }
231
232        sections.push(section);
233    }
234
235    Ok(sections)
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    #[test]
243    fn test_parse_key_value_filter_valid() {
244        let result = parse_key_value_filter("os=linux,arch=x64").unwrap();
245        assert_eq!(result.len(), 2);
246        assert_eq!(result[0], ("os".to_string(), "linux".to_string()));
247        assert_eq!(result[1], ("arch".to_string(), "x64".to_string()));
248    }
249
250    #[test]
251    fn test_parse_key_value_filter_invalid() {
252        assert!(parse_key_value_filter("invalid").is_err());
253        assert!(parse_key_value_filter("=value").is_err());
254        assert!(parse_key_value_filter("key=").is_err());
255    }
256
257    #[test]
258    fn test_parse_comma_separated_list() {
259        let result = parse_comma_separated_list("os, arch, version");
260        assert_eq!(result, vec!["os", "arch", "version"]);
261    }
262
263    #[test]
264    fn test_parse_aggregate_by() {
265        assert_eq!(parse_aggregate_by("none").unwrap(), None);
266        assert_eq!(parse_aggregate_by("min").unwrap(), Some(ReductionFunc::Min));
267        assert!(parse_aggregate_by("invalid").is_err());
268    }
269
270    #[test]
271    fn test_parse_boolean() {
272        assert!(parse_boolean("true", "test").unwrap());
273        assert!(parse_boolean("True", "test").unwrap());
274        assert!(parse_boolean("yes", "test").unwrap());
275        assert!(parse_boolean("1", "test").unwrap());
276        assert!(!parse_boolean("false", "test").unwrap());
277        assert!(!parse_boolean("False", "test").unwrap());
278        assert!(!parse_boolean("no", "test").unwrap());
279        assert!(!parse_boolean("0", "test").unwrap());
280        assert!(parse_boolean("invalid", "test").is_err());
281    }
282
283    #[test]
284    fn test_parse_depth() {
285        assert_eq!(parse_depth("100").unwrap(), 100);
286        assert!(parse_depth("invalid").is_err());
287        assert!(parse_depth("-5").is_err());
288    }
289}