//! 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, } #[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 { match val.parse::() { 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 = Vec::new(); let mut seen: Vec = 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::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")); } }