//! 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<WorkloadSpec>,
/// 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<PathBuf>,
}
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<WorkloadSpec> {
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<WorkloadSpec> {
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<WorkloadSpec> = 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<WorkloadSpec>, rest: &str) -> bool {
let Some((idx_str, field_val)) = rest.split_once('.') else {
return false;
};
let Ok(idx) = idx_str.parse::<usize>() 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<PathBuf> {
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<WorkloadSpec> = 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<WorkloadSpec> = 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<WorkloadSpec> = Vec::new();
assert!(!apply_indexed(&mut indexed, "0.name = bad"));
assert!(indexed.is_empty());
}
}