Skip to main content

fluxbench_cli/
config.rs

1//! Configuration loading from flux.toml
2//!
3//! FluxBench configuration can be specified in a `flux.toml` file in the project root.
4//! The configuration is automatically discovered by walking up from the current directory.
5
6use serde::{Deserialize, Serialize};
7use std::path::Path;
8
9/// FluxBench configuration
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
11pub struct FluxConfig {
12    /// Runner configuration
13    #[serde(default)]
14    pub runner: RunnerConfig,
15    /// Visualization configuration
16    #[serde(default)]
17    pub visuals: VisualsConfig,
18    /// Allocator tracking configuration
19    #[serde(default)]
20    pub allocator: AllocatorConfig,
21    /// Output configuration
22    #[serde(default)]
23    pub output: OutputConfig,
24    /// CI/CD configuration
25    #[serde(default)]
26    pub ci: CiConfig,
27}
28
29/// Isolation mode for benchmark execution
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
31#[serde(rename_all = "kebab-case")]
32pub enum IsolationMode {
33    /// Run each benchmark in a separate worker process (default)
34    #[default]
35    Process,
36    /// Run benchmarks in-process (no isolation, useful for debugging)
37    InProcess,
38    /// Run benchmarks in threads (no isolation)
39    Thread,
40}
41
42impl IsolationMode {
43    /// Whether this mode provides process isolation
44    pub fn is_isolated(self) -> bool {
45        matches!(self, IsolationMode::Process)
46    }
47}
48
49/// Runner configuration for benchmark execution
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct RunnerConfig {
52    /// Timeout for a single benchmark (e.g., "60s", "5m")
53    #[serde(default = "default_timeout")]
54    pub timeout: String,
55    /// Isolation mode: "process", "in-process", or "thread"
56    #[serde(default)]
57    pub isolation: IsolationMode,
58    /// Warmup duration before measurement (e.g., "3s")
59    #[serde(default = "default_warmup")]
60    pub warmup_time: String,
61    /// Measurement duration (e.g., "5s")
62    #[serde(default = "default_measurement")]
63    pub measurement_time: String,
64    /// Fixed sample count: skip warmup, run exactly N iterations (each = one sample)
65    #[serde(default)]
66    pub samples: Option<u64>,
67    /// Minimum number of iterations
68    #[serde(default)]
69    pub min_iterations: Option<u64>,
70    /// Maximum number of iterations
71    #[serde(default)]
72    pub max_iterations: Option<u64>,
73    /// Number of bootstrap iterations for statistics
74    #[serde(default = "default_bootstrap_iterations")]
75    pub bootstrap_iterations: usize,
76    /// Confidence level (e.g., 0.95 for 95%)
77    #[serde(default = "default_confidence_level")]
78    pub confidence_level: f64,
79    /// Number of parallel isolated workers
80    #[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/// Visualization configuration
118#[derive(Debug, Clone, Serialize, Deserialize)]
119pub struct VisualsConfig {
120    /// Color theme: "light" or "dark"
121    #[serde(default = "default_theme")]
122    pub theme: String,
123    /// Chart width in pixels
124    #[serde(default = "default_width")]
125    pub width: u32,
126    /// Chart height in pixels
127    #[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/// Allocator tracking configuration
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct AllocatorConfig {
154    /// Enable allocation tracking
155    #[serde(default = "default_track")]
156    pub track: bool,
157    /// Fail if any allocation occurs during measurement
158    #[serde(default)]
159    pub fail_on_allocation: bool,
160    /// Maximum bytes allowed per iteration (None = unlimited)
161    #[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/// Output configuration
180#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct OutputConfig {
182    /// Default output format: "human", "json", "github", "html", "csv"
183    #[serde(default = "default_format")]
184    pub format: String,
185    /// Output directory for reports
186    #[serde(default = "default_output_dir")]
187    pub directory: String,
188    /// Save JSON baseline after each run
189    #[serde(default)]
190    pub save_baseline: bool,
191    /// Baseline file path
192    #[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/// CI/CD configuration
215#[derive(Debug, Clone, Serialize, Deserialize)]
216pub struct CiConfig {
217    /// Regression threshold percentage (fail if exceeded)
218    #[serde(default = "default_threshold")]
219    pub regression_threshold: f64,
220    /// Enable GitHub Actions annotations
221    #[serde(default)]
222    pub github_annotations: bool,
223    /// Fail on any critical verification failure
224    #[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    /// Load configuration from a TOML file
247    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    /// Try to discover and load configuration by walking up from current directory
254    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    /// Generate a default configuration as TOML string
269    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    /// Parse duration string (e.g., "3s", "500ms", "2m") to nanoseconds
332    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        // Find where the number ends and unit begins
339        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        // Defaults should still apply
401        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}