1use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct FluxConfig {
12 #[serde(default)]
14 pub runner: RunnerConfig,
15 #[serde(default)]
17 pub visuals: VisualsConfig,
18 #[serde(default)]
20 pub allocator: AllocatorConfig,
21 #[serde(default)]
23 pub output: OutputConfig,
24 #[serde(default)]
26 pub ci: CiConfig,
27}
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "kebab-case")]
32pub enum IsolationMode {
33 #[default]
35 Process,
36 InProcess,
38 Thread,
40}
41
42impl IsolationMode {
43 pub fn is_isolated(self) -> bool {
45 matches!(self, IsolationMode::Process)
46 }
47}
48
49#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RunnerConfig {
52 #[serde(default = "default_timeout")]
54 pub timeout: String,
55 #[serde(default)]
57 pub isolation: IsolationMode,
58 #[serde(default = "default_warmup")]
60 pub warmup_time: String,
61 #[serde(default = "default_measurement")]
63 pub measurement_time: String,
64 #[serde(default)]
66 pub samples: Option<u64>,
67 #[serde(default)]
69 pub min_iterations: Option<u64>,
70 #[serde(default)]
72 pub max_iterations: Option<u64>,
73 #[serde(default = "default_bootstrap_iterations")]
75 pub bootstrap_iterations: usize,
76 #[serde(default = "default_confidence_level")]
78 pub confidence_level: f64,
79 #[serde(default)]
81 pub jobs: Option<usize>,
82}
83
84impl Default for RunnerConfig {
85 fn default() -> Self {
86 Self {
87 timeout: default_timeout(),
88 isolation: IsolationMode::default(),
89 warmup_time: default_warmup(),
90 measurement_time: default_measurement(),
91 samples: None,
92 min_iterations: None,
93 max_iterations: None,
94 bootstrap_iterations: default_bootstrap_iterations(),
95 confidence_level: default_confidence_level(),
96 jobs: None,
97 }
98 }
99}
100
101fn default_timeout() -> String {
102 "60s".to_string()
103}
104fn default_warmup() -> String {
105 "3s".to_string()
106}
107fn default_measurement() -> String {
108 "5s".to_string()
109}
110fn default_bootstrap_iterations() -> usize {
111 10_000
112}
113fn default_confidence_level() -> f64 {
114 0.95
115}
116
117#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct VisualsConfig {
120 #[serde(default = "default_theme")]
122 pub theme: String,
123 #[serde(default = "default_width")]
125 pub width: u32,
126 #[serde(default = "default_height")]
128 pub height: u32,
129}
130
131impl Default for VisualsConfig {
132 fn default() -> Self {
133 Self {
134 theme: default_theme(),
135 width: default_width(),
136 height: default_height(),
137 }
138 }
139}
140
141fn default_theme() -> String {
142 "light".to_string()
143}
144fn default_width() -> u32 {
145 1280
146}
147fn default_height() -> u32 {
148 720
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AllocatorConfig {
154 #[serde(default = "default_track")]
156 pub track: bool,
157 #[serde(default)]
159 pub fail_on_allocation: bool,
160 #[serde(default)]
162 pub max_bytes_per_iter: Option<u64>,
163}
164
165impl Default for AllocatorConfig {
166 fn default() -> Self {
167 Self {
168 track: default_track(),
169 fail_on_allocation: false,
170 max_bytes_per_iter: None,
171 }
172 }
173}
174
175fn default_track() -> bool {
176 true
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct OutputConfig {
182 #[serde(default = "default_format")]
184 pub format: String,
185 #[serde(default = "default_output_dir")]
187 pub directory: String,
188 #[serde(default)]
190 pub save_baseline: bool,
191 #[serde(default)]
193 pub baseline_path: Option<String>,
194}
195
196impl Default for OutputConfig {
197 fn default() -> Self {
198 Self {
199 format: default_format(),
200 directory: default_output_dir(),
201 save_baseline: false,
202 baseline_path: None,
203 }
204 }
205}
206
207fn default_format() -> String {
208 "human".to_string()
209}
210fn default_output_dir() -> String {
211 "target/fluxbench".to_string()
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CiConfig {
217 #[serde(default = "default_threshold")]
219 pub regression_threshold: f64,
220 #[serde(default)]
222 pub github_annotations: bool,
223 #[serde(default = "default_fail_on_critical")]
225 pub fail_on_critical: bool,
226}
227
228impl Default for CiConfig {
229 fn default() -> Self {
230 Self {
231 regression_threshold: default_threshold(),
232 github_annotations: false,
233 fail_on_critical: default_fail_on_critical(),
234 }
235 }
236}
237
238fn default_threshold() -> f64 {
239 5.0
240}
241fn default_fail_on_critical() -> bool {
242 true
243}
244
245impl FluxConfig {
246 pub fn load(path: impl AsRef<Path>) -> anyhow::Result<Self> {
248 let content = std::fs::read_to_string(path.as_ref())?;
249 let config: Self = toml::from_str(&content)?;
250 Ok(config)
251 }
252
253 pub fn discover() -> Option<Self> {
255 let mut dir = std::env::current_dir().ok()?;
256 loop {
257 let config_path = dir.join("flux.toml");
258 if config_path.exists() {
259 return Self::load(&config_path).ok();
260 }
261 if !dir.pop() {
262 break;
263 }
264 }
265 None
266 }
267
268 pub fn default_toml() -> String {
270 r#"# FluxBench Configuration
271# https://github.com/ml-rust/fluxbench
272
273[runner]
274# Warmup duration before measurement
275warmup_time = "3s"
276# Measurement duration
277measurement_time = "5s"
278# Timeout for a single benchmark
279timeout = "60s"
280# Isolation mode: "process" or "thread"
281isolation = "process" # "process", "in-process", or "thread"
282# Fixed sample count: skip warmup, run exactly N iterations (uncomment to enable)
283# samples = 5
284# Minimum iterations (uncomment to enable)
285# min_iterations = 100
286# Maximum iterations (uncomment to enable)
287# max_iterations = 1000000
288# Number of parallel isolated workers (uncomment to enable)
289# jobs = 4
290# Bootstrap iterations for confidence intervals
291bootstrap_iterations = 10000
292# Confidence level (0.0 to 1.0)
293confidence_level = 0.95
294
295[allocator]
296# Track memory allocations during benchmarks
297track = true
298# Fail if any allocation occurs during measurement
299fail_on_allocation = false
300# Maximum bytes per iteration (uncomment to enable)
301# max_bytes_per_iter = 1024
302
303[output]
304# Default output format: human, json, github, html, csv
305format = "human"
306# Output directory for reports
307directory = "target/fluxbench"
308# Save JSON baseline after each run
309save_baseline = false
310# Baseline file for comparison (uncomment to enable)
311# baseline_path = "baseline.json"
312
313[visuals]
314# Color theme: light or dark
315theme = "light"
316# Chart dimensions
317width = 1280
318height = 720
319
320[ci]
321# Regression threshold percentage (fail CI if exceeded)
322regression_threshold = 5.0
323# Enable GitHub Actions annotations
324github_annotations = false
325# Fail on critical verification failures
326fail_on_critical = true
327"#
328 .to_string()
329 }
330
331 pub fn parse_duration(s: &str) -> anyhow::Result<u64> {
333 let s = s.trim();
334 if s.is_empty() {
335 return Err(anyhow::anyhow!("Empty duration string"));
336 }
337
338 let (num_part, unit_part) = s
340 .char_indices()
341 .find(|(_, c)| c.is_alphabetic())
342 .map(|(i, _)| s.split_at(i))
343 .unwrap_or((s, "s"));
344
345 let value: f64 = num_part
346 .parse()
347 .map_err(|_| anyhow::anyhow!("Invalid duration number: {}", num_part))?;
348
349 let multiplier: u64 = match unit_part.to_lowercase().as_str() {
350 "ns" => 1,
351 "us" | "µs" => 1_000,
352 "ms" => 1_000_000,
353 "s" | "" => 1_000_000_000,
354 "m" | "min" => 60_000_000_000,
355 _ => return Err(anyhow::anyhow!("Unknown duration unit: {}", unit_part)),
356 };
357
358 Ok((value * multiplier as f64) as u64)
359 }
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 #[test]
367 fn test_default_config() {
368 let config = FluxConfig::default();
369 assert_eq!(config.runner.warmup_time, "3s");
370 assert_eq!(config.runner.measurement_time, "5s");
371 assert!(config.allocator.track);
372 assert!(!config.allocator.fail_on_allocation);
373 }
374
375 #[test]
376 fn test_parse_duration() {
377 assert_eq!(FluxConfig::parse_duration("3s").unwrap(), 3_000_000_000);
378 assert_eq!(FluxConfig::parse_duration("500ms").unwrap(), 500_000_000);
379 assert_eq!(FluxConfig::parse_duration("100us").unwrap(), 100_000);
380 assert_eq!(FluxConfig::parse_duration("1000ns").unwrap(), 1000);
381 assert_eq!(FluxConfig::parse_duration("2m").unwrap(), 120_000_000_000);
382 assert_eq!(FluxConfig::parse_duration("1.5s").unwrap(), 1_500_000_000);
383 }
384
385 #[test]
386 fn test_parse_toml() {
387 let toml_str = r#"
388 [runner]
389 warmup_time = "1s"
390 measurement_time = "2s"
391
392 [allocator]
393 track = false
394 "#;
395
396 let config: FluxConfig = toml::from_str(toml_str).unwrap();
397 assert_eq!(config.runner.warmup_time, "1s");
398 assert_eq!(config.runner.measurement_time, "2s");
399 assert!(!config.allocator.track);
400 assert_eq!(config.output.format, "human");
402 }
403
404 #[test]
405 fn test_default_toml_parses() {
406 let default_toml = FluxConfig::default_toml();
407 let config: FluxConfig = toml::from_str(&default_toml).unwrap();
408 assert_eq!(config.runner.warmup_time, "3s");
409 }
410}