josie / alder-tools

//! 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,
}

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 sensor baseline re-set).
    pub cool_secs: u64,
    /// vCore safety limit, volts — shown as a marker on the hero panel
    /// and flagged in the report if exceeded during a run.
    pub vcore_limit: f64,
    /// Critical package temp, °C — flagged in the report if exceeded.
    pub temp_crit: f64,
    /// 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,
            vcore_limit: 1.403,
            temp_crit: 95.0,
            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),
        "vcore_limit" => cfg.vcore_limit = val.parse().unwrap_or(cfg.vcore_limit),
        "temp_crit" => cfg.temp_crit = val.parse().unwrap_or(cfg.temp_crit),
        "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, "vcore_limit = 1.35");
        apply_line(&mut cfg, "temp_crit = 90.0");
        assert_eq!(cfg.poll_ms, 500);
        assert_eq!(cfg.cool_secs, 30);
        assert_eq!(cfg.vcore_limit, 1.35);
        assert_eq!(cfg.temp_crit, 90.0);
    }

    #[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, "vcore_limit = notanumber");
        assert_eq!(cfg.vcore_limit, 1.403); // 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());
    }
}