josie / alder-tools

//! Display limits + graph scales. Zero deps: simple `KEY = VALUE` lines,
//! `#` comments, blank lines ignored. Unknown keys are ignored (forward
//! compatibility). Loaded once at startup.

pub struct Config {
    pub vcore_limit: f64,
    pub temp_warn: f64,
    pub temp_crit: f64,
    pub vcore_bar_max: f64,
    pub power_bar_max: f64,
    /// Peak-clock plot scale, kHz (P-core turbo ceiling, 4.5 GHz).
    pub clock_bar_max: f64,
    /// Temp graph scale, °C (shared by the graph scale and meter max).
    pub temp_max: f64,
    /// Per-graph scale floors (y-min; 0.0 = bottom of the box).
    pub vcore_min: f64,
    pub clock_min: f64,
    pub power_min: f64,
    pub temp_min: f64,
    /// Core-column arrangement: auto (2 cols if wide), single, or dual.
    pub layout: Layout,
    /// Show nvtop-style line graphs in the package zone.
    pub graphs: bool,
    /// Tick / sample period, ms.
    pub poll_ms: u64,
    /// Graph window (seconds of history shown). History kept is capped by
    /// the ring size (see plot::Ring::new).
    pub graph_secs: u64,
    /// Graph vertical scale: auto (window peak + margin) or fixed (the
    /// bar_max limits below — no rescaling mid-benchmark).
    pub graph_scale_fixed: bool,
    /// Path the config was loaded from (save-back target; None = never
    /// loaded, defaults in effect).
    pub loaded_path: Option<std::path::PathBuf>,
}

#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Layout {
    Auto,
    Single,
    Dual,
}

impl Default for Config {
    fn default() -> Self {
        Config {
            vcore_limit: 1.403,
            temp_warn: 80.0,
            temp_crit: 95.0,
            vcore_bar_max: 1.50,
            power_bar_max: 200.0,
            clock_bar_max: 4_500_000.0,
            temp_max: 100.0,
            vcore_min: 0.0,
            clock_min: 0.0,
            power_min: 0.0,
            temp_min: 0.0,
            layout: Layout::Auto,
            graphs: true,
            poll_ms: 250,
            graph_secs: 300,
            graph_scale_fixed: false,
            loaded_path: None,
        }
    }
}

/// Parse a numeric value; out-of-range or junk keeps the current value.
/// Ranges mirror the settings-pane validation so a hand-edited conf can't
/// produce degenerate scales (empty ranges, zero-width bars).
fn parse_in(val: &str, cur: f64, range: std::ops::RangeInclusive<f64>) -> f64 {
    match val.parse::<f64>() {
        Ok(v) if range.contains(&v) => v,
        _ => cur,
    }
}

/// Parse one `KEY = VALUE` line over a default Config. Lines:
///   vcore_limit = 1.35
///   poll_ms = 250
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 {
        "vcore_limit" => cfg.vcore_limit = parse_in(val, cfg.vcore_limit, 0.1..=5.0),
        "temp_warn" => cfg.temp_warn = parse_in(val, cfg.temp_warn, 0.0..=150.0),
        "temp_crit" => cfg.temp_crit = parse_in(val, cfg.temp_crit, 0.0..=150.0),
        "vcore_bar_max" => cfg.vcore_bar_max = parse_in(val, cfg.vcore_bar_max, 0.1..=5.0),
        "power_bar_max" => cfg.power_bar_max = parse_in(val, cfg.power_bar_max, 1.0..=500.0),
        "clock_bar_max" => {
            cfg.clock_bar_max = parse_in(val, cfg.clock_bar_max, 500_000.0..=10_000_000.0)
        }
        "temp_max" => cfg.temp_max = parse_in(val, cfg.temp_max, 10.0..=150.0),
        "vcore_min" => cfg.vcore_min = parse_in(val, cfg.vcore_min, 0.0..=5.0),
        "clock_min" => cfg.clock_min = parse_in(val, cfg.clock_min, 0.0..=10_000_000.0),
        "power_min" => cfg.power_min = parse_in(val, cfg.power_min, 0.0..=500.0),
        "temp_min" => cfg.temp_min = parse_in(val, cfg.temp_min, 0.0..=150.0),
        "graphs" => cfg.graphs = val == "true",
        "poll_ms" => cfg.poll_ms = val.parse().unwrap_or(cfg.poll_ms).max(50),
        "graph_secs" => cfg.graph_secs = val.parse().unwrap_or(cfg.graph_secs).max(5),
        "graph_scale_fixed" => cfg.graph_scale_fixed = val == "true",
        "layout" => {
            cfg.layout = match val {
                "single" => Layout::Single,
                "dual" => Layout::Dual,
                _ => Layout::Auto,
            }
        }
        _ => {}
    }
}

/// Search order: ./aldermon.conf (dev tree) → ~/.config/aldermon/
/// (user override) → /etc/aldermon/ (system default, installed).
/// First readable file wins; no merging.
pub fn load() -> Config {
    let mut cfg = Config::default();
    let candidates = [
        Some(std::path::PathBuf::from("aldermon.conf")),
        dirs_home().map(|h| h.join(".config/aldermon/aldermon.conf")),
        Some(std::path::PathBuf::from("/etc/aldermon/aldermon.conf")),
    ];
    for path in candidates.into_iter().flatten() {
        if let Ok(text) = std::fs::read_to_string(&path) {
            for line in text.lines() {
                apply_line(&mut cfg, line);
            }
            cfg.loaded_path = Some(path);
            break;
        }
    }
    cfg
}

/// Serialize the settings editable in the settings pane. Written back to
/// the loaded conf path (F10); rail map and limits stay as-is on disk.
pub fn settings_text(cfg: &Config) -> String {
    format!(
        "poll_ms = {}\ngraph_secs = {}\ngraph_scale_fixed = {}\ngraphs = {}\nlayout = {}\nvcore_limit = {:.3}\ntemp_warn = {:.1}\ntemp_crit = {:.1}\nvcore_min = {:.3}\nvcore_bar_max = {:.3}\nclock_min = {:.0}\nclock_bar_max = {:.0}\npower_min = {:.1}\npower_bar_max = {:.1}\ntemp_min = {:.0}\ntemp_max = {:.0}\n",
        cfg.poll_ms,
        cfg.graph_secs,
        cfg.graph_scale_fixed,
        cfg.graphs,
        match cfg.layout {
            Layout::Auto => "auto",
            Layout::Single => "single",
            Layout::Dual => "dual",
        },
        cfg.vcore_limit,
        cfg.temp_warn,
        cfg.temp_crit,
        cfg.vcore_min,
        cfg.vcore_bar_max,
        cfg.clock_min,
        cfg.clock_bar_max,
        cfg.power_min,
        cfg.power_bar_max,
        cfg.temp_min,
        cfg.temp_max,
    )
}

/// Replace the values of the settings-pane keys in conf text (append the
/// block if a key is missing). Returns the new file content.
pub fn update_conf_text(text: &str, cfg: &Config) -> String {
    let wanted = settings_text(cfg);
    let mut out_lines: Vec<String> = Vec::new();
    let mut seen: Vec<String> = Vec::new();
    for line in text.lines() {
        let key = line.split('#').next().unwrap_or("").trim();
        let key = key.split_once('=').map(|(k, _)| k.trim().to_string());
        if let Some(k) = key {
            if wanted.lines().any(|w| {
                w.split_once('=')
                    .map(|(wk, _)| wk.trim() == k)
                    .unwrap_or(false)
            }) && !seen.contains(&k)
            {
                // First occurrence of a managed key: emit the fresh block
                // and mark every block key seen (the block covers them all).
                for w in wanted.lines() {
                    out_lines.push(w.to_string());
                    if let Some((wk, _)) = w.split_once('=') {
                        seen.push(wk.trim().to_string());
                    }
                }
                continue;
            }
            if seen.contains(&k) {
                continue; // subsequent dup of a managed key
            }
        }
        out_lines.push(line.to_string());
    }
    let mut out = out_lines.join("\n");
    for w in wanted.lines() {
        let wk = w.split_once('=').unwrap().0.trim();
        if !seen.iter().any(|s| s == wk) {
            out.push('\n');
            out.push_str(w);
        }
    }
    out.push('\n');
    out
}

fn dirs_home() -> Option<std::path::PathBuf> {
    std::env::var_os("HOME").map(std::path::PathBuf::from)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[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);
    }

    #[test]
    fn rejects_out_of_range() {
        let mut cfg = Config::default();
        apply_line(&mut cfg, "vcore_limit = 99"); // above the 5 V sanity cap
        assert_eq!(cfg.vcore_limit, 1.403);
        apply_line(&mut cfg, "clock_bar_max = 0"); // degenerate scale
        assert_eq!(cfg.clock_bar_max, 4_500_000.0);
        apply_line(&mut cfg, "vcore_limit = 1.35");
        assert_eq!(cfg.vcore_limit, 1.35);
    }

    #[test]
    fn parses_layout_and_graphs() {
        let mut cfg = Config::default();
        apply_line(&mut cfg, "layout = dual");
        apply_line(&mut cfg, "graphs = false");
        assert_eq!(cfg.layout, Layout::Dual);
        assert!(!cfg.graphs);
        apply_line(&mut cfg, "layout = nonsense");
        assert_eq!(cfg.layout, Layout::Auto);
    }

    #[test]
    fn parses_poll_and_graph_window() {
        let mut cfg = Config::default();
        apply_line(&mut cfg, "poll_ms = 500");
        apply_line(&mut cfg, "graph_secs = 300");
        assert_eq!(cfg.poll_ms, 500);
        assert_eq!(cfg.graph_secs, 300);
        apply_line(&mut cfg, "poll_ms = 1"); // floor 50
        assert_eq!(cfg.poll_ms, 50);
    }

    #[test]
    fn updates_conf_in_place() {
        let text = "# comment\npoll_ms = 250\ngraph_secs = 120\nrail.in0.label = CPU Vcore\ngraphs = true\n";
        let cfg = Config {
            poll_ms: 500,
            graph_secs: 300,
            graphs: false,
            ..Default::default()
        };
        let out = update_conf_text(text, &cfg);
        assert!(out.contains("poll_ms = 500"));
        assert!(out.contains("graph_secs = 300"));
        assert!(out.contains("graphs = false"));
        assert!(out.contains("rail.in0.label = CPU Vcore")); // untouched
        assert!(out.starts_with("# comment")); // untouched
                                               // Each managed key appears exactly once
        assert_eq!(out.matches("poll_ms").count(), 1);
        assert_eq!(out.matches("graphs").count(), 1);
    }

    #[test]
    fn parses_scale_fixed() {
        let mut cfg = Config::default();
        apply_line(&mut cfg, "graph_scale_fixed = true");
        assert!(cfg.graph_scale_fixed);
    }

    #[test]
    fn settings_text_includes_scale() {
        let cfg = Config::default();
        assert!(settings_text(&cfg).contains("graph_scale_fixed = false"));
    }
}