//! Run config: workload list + sweep params + sensor thresholds. Zero //! deps: simple `KEY = VALUE` lines, `#` comments, blank lines ignored. //! Unknown keys are ignored (forward compatibility). Loaded once at //! startup. //! //! Mirrors adlermon's config.rs shape (same parser, same conf locations) //! but the key set is adlerbench's own — no rails (adlerbench doesn't //! display the board rail map; adlermon does), and the workload list is //! first-class. use std::path::PathBuf; /// One entry in the sweep. `name` selects a Workload impl; `duration_secs` /// and `cores` are per-entry (different workloads may want different /// lengths and core counts). `cores` is a stress-ng `--taskset`-style /// range string ("0-11" = P-cores, "12-15" = E-cores, "0-15" = all); /// workloads that don't pin (7zip, minecraft-server) ignore it. #[derive(Clone, Debug, PartialEq, Eq)] pub struct WorkloadSpec { pub name: String, pub duration_secs: u64, pub cores: String, } #[derive(Clone, Debug)] pub struct Config { /// Workloads to cycle through, in order. One sweep run = each entry /// once, with `cool_secs` cooldown between. pub workloads: Vec, /// Sensor sample period, ms (drives the tick loop + TUI refresh). pub poll_ms: u64, /// Cooldown between workloads, seconds (idle baseline re-set). pub cool_secs: u64, /// Number of times to repeat the full sweep. 1 = single pass, 0 = /// infinite (loop forever until the user quits). The run loop /// resets + re-runs on SweepDone when loops remain. Used by the /// stability preset's 1-hour target + the custom editor's loop knob. pub loop_count: u32, /// Where to write JSON reports. Created on first run if missing. pub report_dir: PathBuf, /// Where external binaries live (y-cruncher). Defaults to ./bin. pub bin_dir: PathBuf, /// Path the config was loaded from (None = defaults in effect). pub loaded_path: Option, } impl Default for Config { fn default() -> Self { Config { workloads: default_workloads(), poll_ms: 250, cool_secs: 15, loop_count: 1, report_dir: PathBuf::from("runs"), bin_dir: PathBuf::from("bin"), loaded_path: None, } } } /// Default sweep: all five v1 workloads, 60 s each, all cores. Order is /// lowest-clock-rail first (AVX2) so if the chip is going to crash, it /// crashes early before the longer idle cooldowns accumulate. Actually — /// no: run integer first as a sanity baseline (if int crashes, the OC is /// unstable at the easy rail and the rest is moot), then SSE, then AVX2, /// then the perf/latency workloads. fn default_workloads() -> Vec { vec![ spec("stress-ng-cpu", 60, "0-15"), spec("y-cruncher-sse", 60, "0-15"), spec("y-cruncher-avx2", 60, "0-15"), spec("7zip-bench", 60, "0-15"), spec("c2c-latency", 30, "0-15"), spec("dram-latency", 30, "0-15"), ] } fn spec(name: &str, duration_secs: u64, cores: &str) -> WorkloadSpec { WorkloadSpec { name: name.to_string(), duration_secs, cores: cores.to_string(), } } /// Parse one config line over a Config (mutates in place). Shape matches /// adlermon's apply_line: strip `#` comments, trim, split on `=`, match /// the key. Unknown keys silently ignored. fn apply_line(cfg: &mut Config, line: &str) { let line = line.split('#').next().unwrap_or("").trim(); if line.is_empty() { return; } let Some((key, val)) = line.split_once('=') else { return; }; let key = key.trim(); let val = val.trim(); match key { "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50), "cool_secs" => cfg.cool_secs = val.parse().unwrap_or(cfg.cool_secs), "loop_count" => cfg.loop_count = val.parse().unwrap_or(cfg.loop_count), "report_dir" => cfg.report_dir = PathBuf::from(val), "bin_dir" => cfg.bin_dir = PathBuf::from(val), "workloads" => cfg.workloads = parse_workload_list(val), _ => {} } } /// Parse a workload list value. Two forms accepted: /// workloads = stress-ng-cpu, y-cruncher-sse, y-cruncher-avx2 /// (names only; duration + cores come from defaults) /// workload.N.name = stress-ng-cpu /// workload.N.duration_secs = 90 /// workload.N.cores = 0-11 /// (per-entry params; N is a 1-indexed ordinal) /// /// The comma form is the common case for quick edits; the indexed form /// is for per-workload tuning. If both appear, the indexed form wins /// (it's more specific). fn parse_workload_list(val: &str) -> Vec { val.split(',') .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|name| { // Pull defaults from default_workloads so a bare name in the // conf inherits the default duration + cores for that name. default_workloads() .into_iter() .find(|w| w.name == name) .unwrap_or(WorkloadSpec { name: name.to_string(), duration_secs: 60, cores: "0-15".to_string(), }) }) .collect() } pub fn load() -> Config { let mut cfg = Config::default(); let candidates = [ Some(PathBuf::from("adlerbench.conf")), dirs_home().map(|h| h.join(".config/adlerbench/adlerbench.conf")), ]; let mut indexed: Vec = Vec::new(); let mut saw_indexed = false; for path in candidates.into_iter().flatten() { if let Ok(text) = std::fs::read_to_string(&path) { for line in text.lines() { if let Some(rest) = line.split('#').next().unwrap_or("").trim().strip_prefix("workload.") { if apply_indexed(&mut indexed, rest) { saw_indexed = true; } continue; } apply_line(&mut cfg, line); } cfg.loaded_path = Some(path); break; } } if saw_indexed { cfg.workloads = indexed; } cfg } /// Apply a `workload.N.field = value` line to the indexed list. Grows /// the list to fit N (1-indexed). Returns true if the line was a /// recognized indexed key. fn apply_indexed(list: &mut Vec, rest: &str) -> bool { let Some((idx_str, field_val)) = rest.split_once('.') else { return false; }; let Ok(idx) = idx_str.parse::() else { return false; }; if idx == 0 { return false; } let Some((field, val)) = field_val.split_once('=') else { return false; }; let field = field.trim(); let val = val.trim(); while list.len() < idx { list.push(WorkloadSpec { name: String::new(), duration_secs: 60, cores: "0-15".to_string(), }); } let w = &mut list[idx - 1]; match field { "name" => w.name = val.to_string(), "duration_secs" => w.duration_secs = val.parse().unwrap_or(w.duration_secs), "cores" => w.cores = val.to_string(), _ => return false, } true } fn dirs_home() -> Option { std::env::var_os("HOME").map(PathBuf::from) } #[cfg(test)] mod tests { use super::*; #[test] fn defaults_have_six_workloads() { let cfg = Config::default(); assert_eq!(cfg.workloads.len(), 6); assert_eq!(cfg.workloads[0].name, "stress-ng-cpu"); assert_eq!(cfg.workloads[2].name, "y-cruncher-avx2"); } #[test] fn parses_globals() { let mut cfg = Config::default(); apply_line(&mut cfg, "poll_ms = 500"); apply_line(&mut cfg, "cool_secs = 30"); apply_line(&mut cfg, "loop_count = 3"); assert_eq!(cfg.poll_ms, 500); assert_eq!(cfg.cool_secs, 30); assert_eq!(cfg.loop_count, 3); } #[test] fn poll_ms_floors_at_50() { let mut cfg = Config::default(); apply_line(&mut cfg, "poll_ms = 1"); assert_eq!(cfg.poll_ms, 50); } #[test] fn parses_workload_list() { let mut cfg = Config::default(); apply_line(&mut cfg, "workloads = stress-ng-cpu, 7zip-bench"); assert_eq!(cfg.workloads.len(), 2); assert_eq!(cfg.workloads[0].name, "stress-ng-cpu"); assert_eq!(cfg.workloads[1].name, "7zip-bench"); // Bare names inherit defaults from default_workloads assert_eq!(cfg.workloads[0].duration_secs, 60); assert_eq!(cfg.workloads[0].cores, "0-15"); } #[test] fn parses_unknown_workload_name() { let mut cfg = Config::default(); apply_line(&mut cfg, "workloads = custom-future-workload"); assert_eq!(cfg.workloads.len(), 1); assert_eq!(cfg.workloads[0].name, "custom-future-workload"); assert_eq!(cfg.workloads[0].duration_secs, 60); // fallback default } #[test] fn tolerates_junk() { let mut cfg = Config::default(); apply_line(&mut cfg, "not a config line"); apply_line(&mut cfg, "unknown_key = 42"); apply_line(&mut cfg, "poll_ms = notanumber"); assert_eq!(cfg.poll_ms, 250); // unchanged } #[test] fn indexed_workload_overrides_list() { // Simulate the load() path: indexed lines collected separately let mut indexed: Vec = Vec::new(); assert!(apply_indexed(&mut indexed, "1.name = stress-ng-cpu")); assert!(apply_indexed(&mut indexed, "1.duration_secs = 90")); assert!(apply_indexed(&mut indexed, "1.cores = 0-11")); assert!(apply_indexed(&mut indexed, "2.name = y-cruncher-avx2")); assert!(!apply_indexed(&mut indexed, "2.unknown = x")); assert_eq!(indexed.len(), 2); assert_eq!(indexed[0].name, "stress-ng-cpu"); assert_eq!(indexed[0].duration_secs, 90); assert_eq!(indexed[0].cores, "0-11"); assert_eq!(indexed[1].name, "y-cruncher-avx2"); assert_eq!(indexed[1].duration_secs, 60); // default, not overridden } #[test] fn indexed_grows_list() { let mut indexed: Vec = Vec::new(); apply_indexed(&mut indexed, "3.name = 7zip-bench"); assert_eq!(indexed.len(), 3); // Gaps filled with placeholder specs (empty name) assert_eq!(indexed[0].name, ""); assert_eq!(indexed[2].name, "7zip-bench"); } #[test] fn indexed_zero_rejected() { let mut indexed: Vec = Vec::new(); assert!(!apply_indexed(&mut indexed, "0.name = bad")); assert!(indexed.is_empty()); } }