git_perf/
reporting_config.rs1use anyhow::{anyhow, bail, Result};
2use regex::Regex;
3use std::{path::PathBuf, sync::OnceLock};
4
5use crate::stats::ReductionFunc;
6
7static SECTION_PLACEHOLDER_REGEX: OnceLock<Regex> = OnceLock::new();
9
10static SECTION_FINDER_REGEX: OnceLock<Regex> = OnceLock::new();
12
13fn 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
21fn 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#[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#[derive(Debug, Clone)]
39pub struct SectionConfig {
40 pub id: String,
42 pub placeholder: String,
44 pub measurement_filter: Option<String>,
46 pub key_value_filter: Vec<(String, String)>,
48 pub separate_by: Vec<String>,
50 pub aggregate_by: Option<ReductionFunc>,
52 pub depth: Option<usize>,
54 pub show_epochs: bool,
56 pub show_changes: bool,
58}
59
60impl SectionConfig {
61 pub fn parse(placeholder: &str) -> Result<Self> {
64 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 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 for line in params_str.lines() {
95 let line = line.trim();
96 if line.is_empty() {
97 continue;
98 }
99
100 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 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
150fn 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
173fn 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
182fn 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
194fn parse_depth(value: &str) -> Result<usize> {
196 value
197 .parse::<usize>()
198 .map_err(|_| anyhow!("Invalid depth value: {}", value))
199}
200
201fn 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
215pub fn parse_template_sections(template: &str) -> Result<Vec<SectionConfig>> {
217 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 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}