Skip to main content

rigtop_core/
config.rs

1//! User configuration (`config.toml`).
2//!
3//! The file lives at `<config dir>/rigtop/config.toml`
4//! (`~/.config/rigtop/config.toml` on Linux, `%APPDATA%\rigtop\config.toml`
5//! on Windows). A missing file means "all defaults".
6//!
7//! ```toml
8//! [sensors]
9//! # Names for sensor channels, keyed by the stable channel key shown in the UI.
10//! labels = { "nct6686:nct6683.2592/fan1" = "CPU fan", "nct6686:nct6683.2592/temp1" = "VRM" }
11//! # Channels to hide (e.g. unconnected thermistors reading -40 °C).
12//! hidden = ["nct6686:nct6683.2592/temp4"]
13//!
14//! [smart]
15//! # Directory with smartctl JSON files written by the root timer.
16//! dir = "/var/lib/rigtop/smart"
17//!
18//! [btrfs]
19//! # Directory with btrfs scrub status files copied by the root timer.
20//! scrub_dir = "/var/lib/rigtop/btrfs"
21//!
22//! [alerts]
23//! # When readings become alerts (0 turns one off).
24//! filesystem_warning_percent = 90
25//! filesystem_critical_percent = 97
26//! filesystem_full_warning_hours = 24  # full this soon at the last half hour's rate
27//! cpu_warning_celsius = 96    # hottest CPU reading
28//! gpu_warning_celsius = 85
29//! drive_warning_celsius = 70  # SMART or NVMe sensor
30//! memory_warning_percent = 90 # memory in use (without cache)
31//! gpu_memory_warning_percent = 0  # VRAM; off: frameworks reserve most of it
32//! memory_pressure_warning_percent = 10  # time tasks waited for memory (Linux)
33//! io_pressure_warning_percent = 30      # time all tasks waited for I/O (Linux)
34//! ignored_units = []          # failed systemd units not to warn about
35//!
36//! [notifications]
37//! # rigtop-gui: desktop notifications for new alerts ("critical", "warning" or "off").
38//! level = "critical"
39//!
40//! [updates]
41//! # Look for new releases on github.com (at startup and every interval:
42//! # "30m", "6h", "1d", "1h30m", ...; at least a minute).
43//! check = true
44//! interval = "1h"
45//! notify = true   # rigtop-gui: desktop notification for a new release
46//!
47//! [watch]
48//! # `rigtop --watch`: run a command when an alert appears or clears on any
49//! # host, with RIGTOP_HOST, RIGTOP_SEVERITY, RIGTOP_CHANGE, RIGTOP_ALERT and
50//! # RIGTOP_MESSAGE in its environment.
51//! command = 'curl -s -d "$RIGTOP_MESSAGE" https://ntfy.sh/my-topic'
52//! level = "critical"     # or "warning" ("off" runs nothing)
53//! cleared = true         # also when an alert goes away
54//! interval = "10s"       # time between samples
55//! offline_after = "3m"   # a host unreachable this long is a critical alert
56//! processes = ["train.py"]  # also tell when these exit, on any host
57//!
58//! [lhm]
59//! # Windows: LibreHardwareMonitor's web server ("" to disable).
60//! url = "http://127.0.0.1:8085/data.json"
61//!
62//! [tui]
63//! # Boxes of the host view not to draw (also toggled with `v`).
64//! hidden_panels = ["gpu"]
65//! show_widgets = true   # the widget column (`w`)
66//!
67//! [overview]
68//! # Sections of each host card, most important first.
69//! sections = ["cpu", "gpu", "memory", "processes", "network", "storage"]
70//! include_local = true
71//! processes = 10
72//! alerts_first = false  # true: hosts that need attention first
73//!
74//! [[hosts]]
75//! name = "zefat"
76//!
77//! [[hosts]]
78//! name = "nas"
79//! ssh = "admin@nas.local"
80//! command = "/usr/local/bin/rigtop --agent"
81//! sections = ["storage", "cpu", "network"]
82//! [hosts.alerts]       # this host's thresholds, over those of [alerts]
83//! filesystem_warning_percent = 95
84//!
85//! [[widgets]]
86//! name = "Clock"
87//! builtin = "clock"
88//!
89//! [[widgets]]
90//! name = "Rates"
91//! command = "~/bin/rates.sh"   # prints text or JSON rows, see `widget`
92//! interval_secs = 60
93//!
94//! ```
95//!
96//! Labels are applied by front-ends rather than by data sources, so that a
97//! client monitoring remote hosts uses its own local configuration.
98
99use std::collections::BTreeMap;
100use std::fs;
101use std::io;
102use std::path::{Path, PathBuf};
103use std::time::{Duration, Instant, SystemTime};
104
105use serde::{Deserialize, Serialize};
106
107use crate::snapshot::{Fan, Temperature};
108
109/// What a new configuration file starts with: every setting, commented out
110/// at its default, with short explanations.
111pub const CONFIG_TEMPLATE: &str = include_str!("config_template.toml");
112
113/// Default location of the SMART result files.
114pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
115/// Default location of the copied btrfs scrub status files.
116pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
117/// Default address of LibreHardwareMonitor's web server (Windows sensors).
118pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
119
120/// Errors while loading or saving the configuration.
121#[derive(Debug, thiserror::Error)]
122pub enum ConfigError {
123    /// The file could not be read or written.
124    #[error("cannot access {path}: {source}")]
125    Io {
126        /// File that failed.
127        path: PathBuf,
128        /// Underlying error.
129        source: io::Error,
130    },
131    /// The file is not valid TOML or has unexpected fields.
132    #[error("invalid config {path}: {source}")]
133    Parse {
134        /// File that failed.
135        path: PathBuf,
136        /// Underlying error.
137        source: toml::de::Error,
138    },
139    /// The configuration could not be serialized.
140    #[error("cannot serialize config: {0}")]
141    Serialize(#[from] toml::ser::Error),
142    /// A host's `alerts` table has a key `[alerts]` does not, or a value of
143    /// the wrong type.
144    #[error("invalid alerts of host {host} in {path}: {source}")]
145    HostAlerts {
146        /// File that failed.
147        path: PathBuf,
148        /// Name of the host.
149        host: String,
150        /// Underlying error (boxed: it would make every result large).
151        source: Box<toml::de::Error>,
152    },
153    /// The file is not valid TOML, so it cannot be edited in place.
154    #[error("cannot edit {path}: {source}")]
155    Edit {
156        /// File that failed.
157        path: PathBuf,
158        /// Underlying error.
159        source: toml_edit::TomlError,
160    },
161}
162
163/// Top-level configuration.
164#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct Config {
167    /// Sensor naming and visibility.
168    pub sensors: SensorConfig,
169    /// SMART result files.
170    pub smart: SmartConfig,
171    /// btrfs scrub status files.
172    pub btrfs: BtrfsConfig,
173    /// LibreHardwareMonitor's web server, the source of temperatures, fans
174    /// and CPU power on Windows.
175    pub lhm: LhmConfig,
176    /// The multi-host overview.
177    pub overview: OverviewConfig,
178    /// The terminal UI.
179    pub tui: TuiConfig,
180    /// GPU panels and overview cards.
181    pub gpu: GpuConfig,
182    /// When a reading becomes an alert.
183    pub alerts: AlertsConfig,
184    /// Desktop notifications for new alerts (GUI).
185    pub notifications: NotificationsConfig,
186    /// Looking for new rigtop releases.
187    pub updates: UpdatesConfig,
188    /// Alerts sent by `rigtop --watch`.
189    pub watch: WatchConfig,
190    /// Remote hosts monitored over SSH (`[[hosts]]` tables).
191    pub hosts: Vec<HostConfig>,
192    /// Extra panels beside the monitor (`[[widgets]]` tables).
193    pub widgets: Vec<WidgetConfig>,
194}
195
196/// Widgets drawn by rigtop itself.
197#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum BuiltinWidget {
200    /// The date and a large clock.
201    Clock,
202}
203
204/// One widget: a built-in one, or a command whose output is shown (see
205/// [`crate::widget`] for the output format).
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct WidgetConfig {
209    /// Title of the widget.
210    pub name: String,
211    /// Shell command printing the content.
212    pub command: Option<String>,
213    /// Or a built-in widget.
214    pub builtin: Option<BuiltinWidget>,
215    /// Seconds between runs of the command.
216    pub interval_secs: u64,
217    /// Seconds after which a run is killed.
218    pub timeout_secs: u64,
219}
220
221impl Default for WidgetConfig {
222    fn default() -> Self {
223        Self {
224            name: String::new(),
225            command: None,
226            builtin: None,
227            interval_secs: 5,
228            timeout_secs: 10,
229        }
230    }
231}
232
233/// A block of information on a host card in the overview.
234#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum OverviewSection {
237    Cpu,
238    Gpu,
239    Memory,
240    Processes,
241    Network,
242    Storage,
243}
244
245impl OverviewSection {
246    /// All sections in the default priority order.
247    pub const ALL: [OverviewSection; 6] = [
248        OverviewSection::Cpu,
249        OverviewSection::Gpu,
250        OverviewSection::Memory,
251        OverviewSection::Processes,
252        OverviewSection::Network,
253        OverviewSection::Storage,
254    ];
255
256    /// Name shown in menus.
257    pub fn label(self) -> &'static str {
258        match self {
259            OverviewSection::Cpu => "CPU",
260            OverviewSection::Gpu => "GPU",
261            OverviewSection::Memory => "Memory",
262            OverviewSection::Processes => "Processes",
263            OverviewSection::Network => "Network",
264            OverviewSection::Storage => "Storage",
265        }
266    }
267}
268
269/// Settings of the terminal UI.
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[serde(default, deny_unknown_fields)]
272pub struct TuiConfig {
273    /// Boxes of the host view not to draw (`v` toggles them and saves the
274    /// choice here). The CPU box is always drawn.
275    pub hidden_panels: Vec<OverviewSection>,
276    /// Whether the widget column is shown (`w`, or `v` then `w`).
277    pub show_widgets: bool,
278}
279
280impl Default for TuiConfig {
281    fn default() -> Self {
282        Self {
283            hidden_panels: Vec::new(),
284            show_widgets: true,
285        }
286    }
287}
288
289/// Settings of the multi-host overview.
290#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct OverviewConfig {
293    /// Sections shown on each host card, most important first. Sections are
294    /// dropped from the end when a card is too small; leaving one out of
295    /// the list hides it.
296    pub sections: Vec<OverviewSection>,
297    /// Whether this machine also gets a card (it always has its own tab).
298    pub include_local: bool,
299    /// Processes listed per card. Hosts not shown in detail only send this
300    /// many, to keep SSH traffic small.
301    pub processes: usize,
302    /// Put hosts that need attention first (unreachable or with critical
303    /// alerts, then warnings); the others keep their order. Off by default,
304    /// so that cards stay where they are.
305    pub alerts_first: bool,
306}
307
308impl Default for OverviewConfig {
309    fn default() -> Self {
310        Self {
311            sections: OverviewSection::ALL.to_vec(),
312            include_local: true,
313            processes: 10,
314            alerts_first: false,
315        }
316    }
317}
318
319/// A remote host to monitor.
320#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct HostConfig {
323    /// Name shown in tabs and on the overview card.
324    pub name: String,
325    /// SSH destination (a `~/.ssh/config` host, `user@host`, ...);
326    /// defaults to `name`.
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub ssh: Option<String>,
329    /// Command that starts the agent; defaults to `rigtop --agent`.
330    #[serde(default, skip_serializing_if = "Option::is_none")]
331    pub command: Option<String>,
332    /// Overview sections for this host, overriding `[overview] sections`
333    /// (e.g. storage first for a file server).
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub sections: Option<Vec<OverviewSection>>,
336    /// Alert thresholds of this host: the keys given replace those of
337    /// `[alerts]` (e.g. a higher CPU temperature for a laptop). Checked when
338    /// the file is loaded; see [`Config::alerts_for`].
339    #[serde(default, skip_serializing_if = "Option::is_none")]
340    pub alerts: Option<toml::Table>,
341}
342
343impl HostConfig {
344    /// A host given only by its SSH destination (e.g. on the command line).
345    pub fn from_destination(destination: &str) -> Self {
346        Self {
347            name: destination.to_owned(),
348            ssh: None,
349            command: None,
350            sections: None,
351            alerts: None,
352        }
353    }
354
355    /// The SSH destination.
356    pub fn destination(&self) -> &str {
357        self.ssh.as_deref().unwrap_or(&self.name)
358    }
359}
360
361/// Sensor naming and visibility.
362#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(default, deny_unknown_fields)]
364pub struct SensorConfig {
365    /// User-defined names keyed by channel key (`"<chip key>/fan1"`, `"<chip key>/temp3"`).
366    /// A `BTreeMap` keeps the saved file in a stable order.
367    pub labels: BTreeMap<String, String>,
368    /// Channel keys to hide.
369    pub hidden: Vec<String>,
370}
371
372/// Where to find SMART results.
373#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct SmartConfig {
376    /// Directory containing one smartctl JSON file per device.
377    pub dir: PathBuf,
378}
379
380impl Default for SmartConfig {
381    fn default() -> Self {
382        Self {
383            dir: PathBuf::from(DEFAULT_SMART_DIR),
384        }
385    }
386}
387
388/// Which new alerts the GUI announces with a desktop notification.
389#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "lowercase")]
391pub enum NotifyLevel {
392    /// No notifications.
393    Off,
394    /// Warnings and critical alerts.
395    Warning,
396    /// Critical alerts only (data at risk).
397    #[default]
398    Critical,
399}
400
401/// Thresholds that turn readings into alerts (overview cards, status dots
402/// and notifications). 0 turns an alert off. Applied by the front-end, so
403/// remote hosts are judged by the viewer's settings.
404#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(default, deny_unknown_fields)]
406pub struct AlertsConfig {
407    /// Filesystem usage (%) for a warning.
408    pub filesystem_warning_percent: f64,
409    /// Filesystem usage (%) for a critical alert.
410    pub filesystem_critical_percent: f64,
411    /// A filesystem that will be full within this many hours, at the growth
412    /// of the last half hour, is a warning.
413    pub filesystem_full_warning_hours: f64,
414    /// Hottest CPU reading (°C) for a warning. Desktop Ryzen CPUs run up to
415    /// 95 °C under full load by design.
416    pub cpu_warning_celsius: f32,
417    /// GPU temperature (°C) for a warning.
418    pub gpu_warning_celsius: f32,
419    /// Drive temperature (°C, from SMART or the NVMe sensor) for a warning.
420    pub drive_warning_celsius: f32,
421    /// Memory in use (%, page cache not counted) for a warning.
422    pub memory_warning_percent: f64,
423    /// GPU memory in use (%) for a warning. Off by default: PyTorch, JAX
424    /// and TensorFlow reserve most of the VRAM up front, so a busy GPU
425    /// always looks full.
426    pub gpu_memory_warning_percent: f64,
427    /// Share of the last minute (%) in which some task waited for memory
428    /// (Linux pressure stall information) for a warning: reclaim or swap
429    /// is slowing work down.
430    pub memory_pressure_warning_percent: f64,
431    /// Share of the last minute (%) in which every runnable task waited for
432    /// I/O for a warning. Not "some" task: that is near 100% during any
433    /// large copy.
434    pub io_pressure_warning_percent: f64,
435    /// systemd units whose failure is not worth a warning (e.g. a
436    /// `systemd-networkd-wait-online.service` that fails on every boot).
437    pub ignored_units: Vec<String>,
438}
439
440impl Default for AlertsConfig {
441    fn default() -> Self {
442        Self {
443            filesystem_warning_percent: 90.0,
444            filesystem_critical_percent: 97.0,
445            filesystem_full_warning_hours: 24.0,
446            cpu_warning_celsius: 96.0,
447            gpu_warning_celsius: 85.0,
448            drive_warning_celsius: 70.0,
449            memory_warning_percent: 90.0,
450            gpu_memory_warning_percent: 0.0,
451            memory_pressure_warning_percent: 10.0,
452            io_pressure_warning_percent: 30.0,
453            ignored_units: Vec::new(),
454        }
455    }
456}
457
458/// GPU panels and overview cards.
459#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
460#[serde(default, deny_unknown_fields)]
461pub struct GpuConfig {
462    /// How an integrated GPU is shown on a host that also has a discrete
463    /// one.
464    pub integrated: IntegratedGpu,
465}
466
467/// How an integrated GPU is shown next to a discrete GPU. A host with only
468/// an integrated GPU (a laptop, say) always shows it in full.
469#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
470#[serde(rename_all = "lowercase")]
471pub enum IntegratedGpu {
472    /// One line in the host view, none on overview cards.
473    #[default]
474    Compact,
475    /// Not shown at all (its alerts still are).
476    Hide,
477    /// Like a discrete GPU.
478    Full,
479}
480
481/// Desktop notification settings.
482#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
483#[serde(default, deny_unknown_fields)]
484pub struct NotificationsConfig {
485    pub level: NotifyLevel,
486}
487
488/// Looking for new rigtop releases on GitHub.
489#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490#[serde(default, deny_unknown_fields)]
491pub struct UpdatesConfig {
492    /// Look for new releases (contacts github.com); `false` never does.
493    pub check: bool,
494    /// Time between checks (also checked at startup), written like "1h",
495    /// "30m", "1d" or "1h30m".
496    #[serde(with = "duration_text")]
497    pub interval: Duration,
498    /// Announce a new release with a desktop notification (GUI), besides
499    /// the note in the title.
500    pub notify: bool,
501}
502
503impl Default for UpdatesConfig {
504    fn default() -> Self {
505        Self {
506            check: true,
507            interval: Duration::from_secs(3600),
508            notify: true,
509        }
510    }
511}
512
513impl UpdatesConfig {
514    /// Time between checks; at least a minute, whatever the file says.
515    pub fn check_interval(&self) -> Duration {
516        self.interval.max(Duration::from_secs(60))
517    }
518}
519
520/// `rigtop --watch`: watching every host without a screen and running a
521/// command when alerts appear or clear.
522#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523#[serde(default, deny_unknown_fields)]
524pub struct WatchConfig {
525    /// Shell command run for each change ("" runs nothing; changes are
526    /// still printed and kept in the alert history).
527    pub command: String,
528    /// The least severe alerts the command runs for.
529    pub level: NotifyLevel,
530    /// Also run the command when an alert clears.
531    pub cleared: bool,
532    /// Time between samples of each host.
533    #[serde(with = "duration_text")]
534    pub interval: Duration,
535    /// A host that sends nothing this long becomes a critical alert.
536    #[serde(with = "duration_text")]
537    pub offline_after: Duration,
538    /// Time a run of the command may take before it is killed.
539    #[serde(with = "duration_text")]
540    pub timeout: Duration,
541    /// Processes to announce the exit of, on every host, by name or by a
542    /// file in their command line (`"train.py"`, `"rsync"`); see
543    /// [`crate::view::process_matches`].
544    pub processes: Vec<String>,
545}
546
547impl Default for WatchConfig {
548    fn default() -> Self {
549        Self {
550            command: String::new(),
551            level: NotifyLevel::Critical,
552            cleared: true,
553            interval: Duration::from_secs(10),
554            offline_after: Duration::from_secs(180),
555            timeout: Duration::from_secs(30),
556            processes: Vec::new(),
557        }
558    }
559}
560
561/// Durations written as text: a number and a unit (`s`, `m`, `h` or `d`),
562/// possibly several, as in "1h30m".
563mod duration_text {
564    use std::time::Duration;
565
566    use serde::de::Error;
567    use serde::{Deserialize, Deserializer, Serializer};
568
569    const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
570
571    pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
572        serializer.serialize_str(&format(*duration))
573    }
574
575    pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
576        let text = String::deserialize(deserializer)?;
577        parse(&text).ok_or_else(|| {
578            D::Error::custom(format!(
579                "invalid duration {text:?}: write a number and s, m, h or d, \
580                 e.g. \"30m\", \"6h\" or \"1h30m\""
581            ))
582        })
583    }
584
585    /// "1h30m" -> 90 minutes; `None` for anything else.
586    pub fn parse(text: &str) -> Option<Duration> {
587        let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
588        if text.is_empty() {
589            return None;
590        }
591        let mut total = 0u64;
592        let mut number = String::new();
593        for c in text.chars() {
594            if c.is_ascii_digit() {
595                number.push(c);
596                continue;
597            }
598            let (_, secs) = UNITS
599                .iter()
600                .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
601            let value: u64 = number.parse().ok()?;
602            total = total.checked_add(value.checked_mul(*secs)?)?;
603            number.clear();
604        }
605        // A number without a unit at the end is ambiguous.
606        number.is_empty().then(|| Duration::from_secs(total))
607    }
608
609    /// 90 minutes -> "1h30m"; whole seconds only.
610    pub fn format(duration: Duration) -> String {
611        let mut secs = duration.as_secs();
612        if secs == 0 {
613            return "0s".to_owned();
614        }
615        let mut text = String::new();
616        for (unit, size) in UNITS {
617            if secs >= size {
618                text.push_str(&format!("{}{unit}", secs / size));
619                secs %= size;
620            }
621        }
622        text
623    }
624}
625
626/// Where to read Windows sensors from.
627#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
628#[serde(default, deny_unknown_fields)]
629pub struct LhmConfig {
630    /// `data.json` of LibreHardwareMonitor's "Remote Web Server" (plain
631    /// HTTP without authentication); empty to disable.
632    pub url: String,
633}
634
635impl Default for LhmConfig {
636    fn default() -> Self {
637        Self {
638            url: DEFAULT_LHM_URL.to_owned(),
639        }
640    }
641}
642
643/// Where to find btrfs scrub results.
644#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645#[serde(default, deny_unknown_fields)]
646pub struct BtrfsConfig {
647    /// Directory with world-readable copies of `scrub.status.<fsid>`.
648    pub scrub_dir: PathBuf,
649}
650
651impl Default for BtrfsConfig {
652    fn default() -> Self {
653        Self {
654            scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
655        }
656    }
657}
658
659/// Creates the configuration file at `path` from [`CONFIG_TEMPLATE`] if
660/// there is none yet (with its directory). Returns true when it did.
661pub fn create_if_missing(path: &Path) -> io::Result<bool> {
662    if path.exists() {
663        return Ok(false);
664    }
665    if let Some(dir) = path.parent() {
666        fs::create_dir_all(dir)?;
667    }
668    fs::write(path, CONFIG_TEMPLATE)?;
669    Ok(true)
670}
671
672impl Config {
673    /// Default path of the configuration file, if the platform has a config directory.
674    pub fn default_path() -> Option<PathBuf> {
675        dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
676    }
677
678    /// Loads the configuration from `path`. A missing file yields the defaults.
679    pub fn load(path: &Path) -> Result<Self, ConfigError> {
680        let text = match fs::read_to_string(path) {
681            Ok(text) => text,
682            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
683            Err(source) => {
684                return Err(ConfigError::Io {
685                    path: path.to_owned(),
686                    source,
687                });
688            }
689        };
690        let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
691            path: path.to_owned(),
692            source,
693        })?;
694        // A typo in a host's thresholds fails now, like one in [alerts].
695        for host in &config.hosts {
696            config
697                .alerts_for(host)
698                .map_err(|source| ConfigError::HostAlerts {
699                    path: path.to_owned(),
700                    host: host.name.clone(),
701                    source: Box::new(source),
702                })?;
703        }
704        Ok(config)
705    }
706
707    /// The alert thresholds of `host`: `[alerts]` with the keys of the
708    /// host's own `alerts` table in place of its own.
709    pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
710        let Some(overrides) = &host.alerts else {
711            return Ok(self.alerts.clone());
712        };
713        // Serializing plain numbers and strings cannot fail.
714        let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
715        merged.extend(overrides.clone());
716        merged.try_into()
717    }
718
719    /// Loads from [`Config::default_path`], or returns the defaults when the
720    /// platform has no config directory.
721    pub fn load_default() -> Result<Self, ConfigError> {
722        match Self::default_path() {
723            Some(path) => Self::load(&path),
724            None => Ok(Self::default()),
725        }
726    }
727
728    /// Writes the configuration to `path`, creating parent directories.
729    ///
730    /// The file is written to a temporary sibling and renamed into place, so
731    /// a crash never leaves a truncated config behind.
732    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
733        let text = toml::to_string_pretty(self)?;
734        let io_err = |source| ConfigError::Io {
735            path: path.to_owned(),
736            source,
737        };
738        if let Some(parent) = path.parent() {
739            fs::create_dir_all(parent).map_err(io_err)?;
740        }
741        let tmp = path.with_extension("toml.tmp");
742        fs::write(&tmp, text).map_err(io_err)?;
743        fs::rename(&tmp, path).map_err(io_err)
744    }
745
746    /// Replaces the `[[hosts]]` of the configuration file at `path` with
747    /// `hosts`, keeping everything else in the file (other settings,
748    /// comments, formatting) untouched. Creates the file if needed.
749    ///
750    /// Used by the GUI's host editor. The edited file is checked to load as
751    /// a valid configuration before it replaces the old one.
752    pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
753        edit_file(path, |document| {
754            if hosts.is_empty() {
755                document.remove("hosts");
756                return Ok(());
757            }
758            let mut tables = toml_edit::ArrayOfTables::new();
759            for host in hosts {
760                let mut table = toml_edit::Table::new();
761                table["name"] = toml_edit::value(host.name.as_str());
762                if let Some(ssh) = &host.ssh {
763                    table["ssh"] = toml_edit::value(ssh.as_str());
764                }
765                if let Some(command) = &host.command {
766                    table["command"] = toml_edit::value(command.as_str());
767                }
768                if let Some(sections) = &host.sections {
769                    let mut array = toml_edit::Array::new();
770                    for section in sections {
771                        // Serialized names match the serde representation.
772                        let name = toml::Value::try_from(section)?;
773                        if let Some(name) = name.as_str() {
774                            array.push(name);
775                        }
776                    }
777                    table["sections"] = toml_edit::value(array);
778                }
779                if let Some(alerts) = &host.alerts {
780                    // Written as [hosts.alerts] under the host's keys.
781                    let mut sub = toml_edit::Table::new();
782                    for (key, value) in alerts {
783                        if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
784                            sub[key.as_str()] = toml_edit::value(value);
785                        }
786                    }
787                    table["alerts"] = toml_edit::Item::Table(sub);
788                }
789                tables.push(table);
790            }
791            document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
792            Ok(())
793        })
794    }
795
796    /// Replaces the sensor names and hidden sensors (`[sensors]`) of the
797    /// configuration file at `path`, keeping the rest of the file as it is,
798    /// like [`Config::save_hosts`]. Names stay an inline table if they were
799    /// one.
800    ///
801    /// Used by the GUI's sensor editor.
802    /// Writes `[tui]` (keys left at their defaults are removed), leaving
803    /// the rest of the file as it is.
804    pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
805        edit_file(path, |document| {
806            if !document.contains_key("tui") {
807                if *tui == TuiConfig::default() {
808                    return Ok(());
809                }
810                document["tui"] = toml_edit::table();
811            }
812            let Some(table) = document["tui"].as_table_like_mut() else {
813                // Not a table: loading would have failed already.
814                return Ok(());
815            };
816            if tui.hidden_panels.is_empty() {
817                table.remove("hidden_panels");
818            } else {
819                let names: toml_edit::Array = tui
820                    .hidden_panels
821                    .iter()
822                    .filter_map(|section| {
823                        serde_json::to_value(section)
824                            .ok()
825                            .and_then(|v| v.as_str().map(str::to_owned))
826                    })
827                    .collect();
828                table.insert("hidden_panels", toml_edit::value(names));
829            }
830            if tui.show_widgets {
831                table.remove("show_widgets");
832            } else {
833                table.insert("show_widgets", toml_edit::value(false));
834            }
835            Ok(())
836        })
837    }
838
839    pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
840        edit_file(path, |document| {
841            if !document.contains_key("sensors") {
842                if sensors.labels.is_empty() && sensors.hidden.is_empty() {
843                    return Ok(());
844                }
845                document["sensors"] = toml_edit::table();
846            }
847            let Some(table) = document["sensors"].as_table_like_mut() else {
848                // Not a table: loading would have failed already.
849                return Ok(());
850            };
851            if sensors.hidden.is_empty() {
852                table.remove("hidden");
853            } else {
854                let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
855                table.insert("hidden", toml_edit::value(hidden));
856            }
857            let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
858            if sensors.labels.is_empty() {
859                table.remove("labels");
860            } else if inline {
861                let labels: toml_edit::InlineTable = sensors
862                    .labels
863                    .iter()
864                    .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
865                    .collect();
866                table.insert("labels", toml_edit::value(labels));
867            } else {
868                let mut labels = toml_edit::Table::new();
869                for (key, name) in &sensors.labels {
870                    labels[key.as_str()] = toml_edit::value(name.as_str());
871                }
872                table.insert("labels", toml_edit::Item::Table(labels));
873            }
874            if table.is_empty() {
875                document.remove("sensors");
876            }
877            Ok(())
878        })
879    }
880
881    /// Display name of a fan: the user-defined label, or the driver/generic label.
882    pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
883        self.label_for(&fan.key).unwrap_or(&fan.label)
884    }
885
886    /// Display name of a temperature channel.
887    pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
888        self.label_for(&temperature.key)
889            .unwrap_or(&temperature.label)
890    }
891
892    /// User-defined label for a channel key, if any.
893    pub fn label_for(&self, key: &str) -> Option<&str> {
894        self.sensors.labels.get(key).map(String::as_str)
895    }
896
897    /// Sets (or, with an empty name, removes) the label of a channel.
898    pub fn set_label(&mut self, key: &str, name: &str) {
899        let name = name.trim();
900        if name.is_empty() {
901            self.sensors.labels.remove(key);
902        } else {
903            self.sensors.labels.insert(key.to_owned(), name.to_owned());
904        }
905    }
906
907    /// Whether the user chose to hide a channel.
908    pub fn is_hidden(&self, key: &str) -> bool {
909        self.sensors.hidden.iter().any(|k| k == key)
910    }
911}
912
913/// Reads the configuration file at `path` (or an empty one), lets `edit`
914/// change it with comments and formatting kept, and writes it back if it
915/// still loads as a configuration (through a temporary file, so a crash
916/// never leaves a truncated file behind).
917fn edit_file(
918    path: &Path,
919    edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
920) -> Result<(), ConfigError> {
921    let io_err = |source| ConfigError::Io {
922        path: path.to_owned(),
923        source,
924    };
925    let text = match fs::read_to_string(path) {
926        Ok(text) => text,
927        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
928        Err(err) => return Err(io_err(err)),
929    };
930    let mut document: toml_edit::DocumentMut =
931        text.parse().map_err(|source| ConfigError::Edit {
932            path: path.to_owned(),
933            source,
934        })?;
935    edit(&mut document)?;
936    let edited = document.to_string();
937    // Never write a file that rigtop itself could not load afterwards.
938    toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
939        path: path.to_owned(),
940        source,
941    })?;
942    if let Some(parent) = path.parent() {
943        fs::create_dir_all(parent).map_err(io_err)?;
944    }
945    let tmp = path.with_extension("toml.tmp");
946    fs::write(&tmp, edited).map_err(io_err)?;
947    fs::rename(&tmp, path).map_err(io_err)
948}
949
950/// How often [`ConfigWatcher::poll`] looks at the file.
951const WATCH_INTERVAL: Duration = Duration::from_secs(1);
952
953/// Notices when the configuration file changes, so a running rigtop can
954/// apply the edits without a restart.
955pub struct ConfigWatcher {
956    path: PathBuf,
957    /// Modification time and size when last seen (`None`: no file).
958    stamp: Option<(Option<SystemTime>, u64)>,
959    last_check: Instant,
960    interval: Duration,
961}
962
963impl ConfigWatcher {
964    /// Watches `path`, taking its current state as already loaded.
965    pub fn new(path: PathBuf) -> Self {
966        Self {
967            stamp: file_stamp(&path),
968            path,
969            last_check: Instant::now(),
970            interval: WATCH_INTERVAL,
971        }
972    }
973
974    /// The file being watched.
975    pub fn path(&self) -> &Path {
976        &self.path
977    }
978
979    /// Takes the file as it is now as already loaded, e.g. after rigtop
980    /// itself wrote to it.
981    pub fn mark_seen(&mut self) {
982        self.stamp = file_stamp(&self.path);
983    }
984
985    /// When the file changed since the previous call, its new contents (or
986    /// why they cannot be used, e.g. a file saved half-way). `None` when
987    /// unchanged, checked less than a second ago, or removed: a missing
988    /// file keeps the configuration in use rather than resetting it.
989    pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
990        if self.last_check.elapsed() < self.interval {
991            return None;
992        }
993        self.last_check = Instant::now();
994        let stamp = file_stamp(&self.path);
995        if stamp == self.stamp {
996            return None;
997        }
998        self.stamp = stamp;
999        stamp?;
1000        Some(Config::load(&self.path))
1001    }
1002}
1003
1004/// Modification time and size of `path`, if it exists.
1005fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
1006    let metadata = fs::metadata(path).ok()?;
1007    Some((metadata.modified().ok(), metadata.len()))
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012    use super::*;
1013
1014    #[test]
1015    fn template_parses_with_every_setting_uncommented() {
1016        // Settings are commented as "#key", explanations as "# text".
1017        let settings: String = CONFIG_TEMPLATE
1018            .lines()
1019            .filter_map(|line| line.strip_prefix('#'))
1020            .filter(|line| !line.starts_with(' ') && !line.is_empty())
1021            .map(|line| format!("{line}\n"))
1022            .collect();
1023        let config: Config = toml::from_str(&settings).unwrap();
1024        assert_eq!(config.hosts[0].name, "nas");
1025        // The defaults it shows are the real ones.
1026        assert_eq!(config.alerts, AlertsConfig::default());
1027        assert_eq!(config.watch, WatchConfig::default());
1028        assert_eq!(config.overview, OverviewConfig::default());
1029        // As written, everything is commented out.
1030        let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1031        assert_eq!(empty, Config::default());
1032
1033        let tmp = tempfile::tempdir().unwrap();
1034        let path = tmp.path().join("rigtop/config.toml");
1035        assert!(create_if_missing(&path).unwrap());
1036        assert!(!create_if_missing(&path).unwrap(), "left as it is");
1037        assert_eq!(Config::load(&path).unwrap(), Config::default());
1038    }
1039
1040    #[test]
1041    fn hidden_panels_are_saved_and_loaded() {
1042        let tmp = tempfile::tempdir().unwrap();
1043        let path = tmp.path().join("config.toml");
1044        fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1045        let tui = TuiConfig {
1046            hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1047            show_widgets: false,
1048        };
1049        Config::save_tui(&path, &tui).unwrap();
1050        let text = fs::read_to_string(&path).unwrap();
1051        assert!(text.starts_with("# mine"), "{text}");
1052        let config = Config::load(&path).unwrap();
1053        assert_eq!(
1054            config.tui.hidden_panels,
1055            vec![OverviewSection::Gpu, OverviewSection::Network]
1056        );
1057        assert!(!config.tui.show_widgets);
1058        assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1059        Config::save_tui(&path, &TuiConfig::default()).unwrap();
1060        let config = Config::load(&path).unwrap();
1061        assert_eq!(config.tui, TuiConfig::default());
1062        assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1063    }
1064
1065    #[test]
1066    fn watch_settings() {
1067        let config: Config = toml::from_str(
1068            "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1069        )
1070        .unwrap();
1071        assert_eq!(config.watch.command, "notify-send x");
1072        assert_eq!(config.watch.level, NotifyLevel::Warning);
1073        assert!(config.watch.cleared);
1074        assert_eq!(config.watch.interval, Duration::from_secs(30));
1075        assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1076        assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1077    }
1078
1079    #[test]
1080    fn update_checks_can_be_tuned_or_turned_off() {
1081        let config: Config =
1082            toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1083        assert!(config.updates.check);
1084        assert_eq!(
1085            config.updates.check_interval(),
1086            Duration::from_secs(6 * 3600)
1087        );
1088        assert!(!config.updates.notify);
1089        let config: Config =
1090            toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1091        assert!(!config.updates.check);
1092        assert_eq!(
1093            config.updates.check_interval(),
1094            Duration::from_secs(60),
1095            "at least a minute"
1096        );
1097        let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1098        assert!(error.to_string().contains("invalid duration"), "{error}");
1099    }
1100
1101    #[test]
1102    fn durations_read_and_write_as_text() {
1103        let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1104        assert_eq!(duration_text::parse("30m"), minutes(30));
1105        assert_eq!(duration_text::parse("1h30m"), minutes(90));
1106        assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1107        assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1108        assert_eq!(duration_text::parse("2H"), minutes(120));
1109        for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1110            assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1111        }
1112        assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1113        assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1114        // The defaults survive saving and loading.
1115        let text = toml::to_string(&Config::default()).unwrap();
1116        assert!(text.contains("interval = \"1h\""), "{text}");
1117        assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1118    }
1119
1120    #[test]
1121    fn watcher_reports_edits_once() {
1122        let tmp = tempfile::tempdir().unwrap();
1123        let path = tmp.path().join("config.toml");
1124        fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1125        let mut watcher = ConfigWatcher::new(path.clone());
1126        watcher.interval = Duration::ZERO;
1127        assert!(
1128            watcher.poll().is_none(),
1129            "the initial file counts as loaded"
1130        );
1131
1132        // A different size, so the change shows even where file times are coarse.
1133        fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1134        let config = watcher.poll().unwrap().unwrap();
1135        assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1136        assert!(watcher.poll().is_none(), "reported once");
1137
1138        fs::write(&path, "[alerts\n").unwrap();
1139        assert!(
1140            watcher.poll().unwrap().is_err(),
1141            "a broken file is an error"
1142        );
1143        fs::remove_file(&path).unwrap();
1144        assert!(
1145            watcher.poll().is_none(),
1146            "removing the file changes nothing"
1147        );
1148    }
1149
1150    #[test]
1151    fn missing_file_gives_defaults() {
1152        let tmp = tempfile::tempdir().unwrap();
1153        let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1154        assert_eq!(config, Config::default());
1155        assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1156    }
1157
1158    #[test]
1159    fn parses_labels_hidden_and_smart_dir() {
1160        let tmp = tempfile::tempdir().unwrap();
1161        let path = tmp.path().join("config.toml");
1162        fs::write(
1163            &path,
1164            r#"
1165[sensors]
1166labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1167hidden = ["nct6686:nct6683.2592/temp4"]
1168
1169[smart]
1170dir = "/tmp/smart"
1171
1172[lhm]
1173url = ""
1174
1175[notifications]
1176level = "warning"
1177
1178[alerts]
1179drive_warning_celsius = 65
1180cpu_warning_celsius = 0
1181gpu_memory_warning_percent = 98
1182"#,
1183        )
1184        .unwrap();
1185        let config = Config::load(&path).unwrap();
1186        let fan = Fan {
1187            key: "nct6686:nct6683.2592/fan1".to_owned(),
1188            label: "fan1".to_owned(),
1189            rpm: 900,
1190        };
1191        let other = Fan {
1192            key: "nct6686:nct6683.2592/fan2".to_owned(),
1193            label: "fan2".to_owned(),
1194            rpm: 0,
1195        };
1196        assert_eq!(config.fan_label(&fan), "CPU fan");
1197        // Unnamed channels fall back to the generic label.
1198        assert_eq!(config.fan_label(&other), "fan2");
1199        assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1200        assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1201        assert_eq!(config.lhm.url, "");
1202        assert_eq!(config.notifications.level, NotifyLevel::Warning);
1203        assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1204        assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1205        assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1206        assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1207        // Unset thresholds keep their defaults.
1208        assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1209        assert_eq!(config.alerts.memory_warning_percent, 90.0);
1210        assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1211    }
1212
1213    #[test]
1214    fn parses_hosts_and_overview() {
1215        let config: Config = toml::from_str(
1216            r#"
1217[overview]
1218sections = ["storage", "cpu"]
1219include_local = false
1220
1221[[hosts]]
1222name = "zefat"
1223
1224[[hosts]]
1225name = "nas"
1226ssh = "admin@nas.local"
1227sections = ["storage"]
1228
1229[[widgets]]
1230name = "Clock"
1231builtin = "clock"
1232
1233[[widgets]]
1234name = "Rates"
1235command = "rates.sh"
1236interval_secs = 60
1237"#,
1238        )
1239        .unwrap();
1240        assert_eq!(
1241            config.overview.sections,
1242            vec![OverviewSection::Storage, OverviewSection::Cpu]
1243        );
1244        assert!(!config.overview.include_local);
1245        assert_eq!(config.overview.processes, 10, "default kept");
1246        assert_eq!(config.hosts.len(), 2);
1247        assert_eq!(config.hosts[0].destination(), "zefat");
1248        assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1249        assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1250        assert_eq!(config.widgets[1].interval_secs, 60);
1251        assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1252        assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1253        assert_eq!(
1254            config.hosts[1].sections,
1255            Some(vec![OverviewSection::Storage])
1256        );
1257        // Defaults: every section in priority order, local included.
1258        let defaults = Config::default();
1259        assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1260        assert!(defaults.overview.include_local);
1261    }
1262
1263    #[test]
1264    fn save_hosts_replaces_only_the_hosts() {
1265        let tmp = tempfile::tempdir().unwrap();
1266        let path = tmp.path().join("config.toml");
1267        fs::write(
1268            &path,
1269            "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1270        )
1271        .unwrap();
1272        let hosts = vec![
1273            HostConfig::from_destination("zefat"),
1274            HostConfig {
1275                name: "nas".to_owned(),
1276                ssh: Some("admin@nas.local".to_owned()),
1277                command: Some("/opt/rigtop --agent".to_owned()),
1278                sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1279                alerts: Some(
1280                    toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1281                        .unwrap(),
1282                ),
1283            },
1284        ];
1285        Config::save_hosts(&path, &hosts).unwrap();
1286
1287        let text = fs::read_to_string(&path).unwrap();
1288        assert!(
1289            text.contains("# my settings") && text.contains("# keep me"),
1290            "{text}"
1291        );
1292        assert!(!text.contains("\"old\""), "{text}");
1293        let config = Config::load(&path).unwrap();
1294        assert_eq!(config.hosts, hosts);
1295        assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1296
1297        // Removing every host drops the [[hosts]] tables.
1298        Config::save_hosts(&path, &[]).unwrap();
1299        assert!(Config::load(&path).unwrap().hosts.is_empty());
1300        assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1301    }
1302
1303    #[test]
1304    fn host_alerts_replace_the_keys_they_give() {
1305        let tmp = tempfile::tempdir().unwrap();
1306        let path = tmp.path().join("config.toml");
1307        fs::write(
1308            &path,
1309            "[alerts]\nmemory_warning_percent = 80\n\n\
1310             [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1311             [[hosts]]\nname = \"nas\"\n",
1312        )
1313        .unwrap();
1314        let config = Config::load(&path).unwrap();
1315        let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1316        assert_eq!(laptop.cpu_warning_celsius, 100.0);
1317        // The others come from [alerts], set or default.
1318        assert_eq!(laptop.memory_warning_percent, 80.0);
1319        assert_eq!(laptop.gpu_warning_celsius, 85.0);
1320        assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1321
1322        // A typo fails the load, naming the host.
1323        fs::write(
1324            &path,
1325            "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1326        )
1327        .unwrap();
1328        let err = Config::load(&path).unwrap_err().to_string();
1329        assert!(
1330            err.contains("laptop") && err.contains("cpu_warning_celsus"),
1331            "{err}"
1332        );
1333    }
1334
1335    #[test]
1336    fn save_sensors_replaces_only_names_and_hidden_sensors() {
1337        let tmp = tempfile::tempdir().unwrap();
1338        let path = tmp.path().join("config.toml");
1339        fs::write(
1340            &path,
1341            "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1342        )
1343        .unwrap();
1344        let mut sensors = SensorConfig::default();
1345        sensors
1346            .labels
1347            .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1348        sensors
1349            .labels
1350            .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1351        sensors.hidden.push("b:c.1/temp4".to_owned());
1352        Config::save_sensors(&path, &sensors).unwrap();
1353
1354        let text = fs::read_to_string(&path).unwrap();
1355        assert!(text.contains("# my settings"), "{text}");
1356        // Inline names stay inline.
1357        assert!(text.contains("labels = {"), "{text}");
1358        let config = Config::load(&path).unwrap();
1359        assert_eq!(config.sensors, sensors);
1360        assert_eq!(config.hosts[0].name, "zefat");
1361
1362        // Nothing left: the [sensors] table goes away.
1363        Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1364        let text = fs::read_to_string(&path).unwrap();
1365        assert!(!text.contains("[sensors]"), "{text}");
1366        assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1367
1368        // A new file gets a [sensors.labels] table.
1369        let fresh = tmp.path().join("new.toml");
1370        Config::save_sensors(&fresh, &sensors).unwrap();
1371        let text = fs::read_to_string(&fresh).unwrap();
1372        assert!(text.contains("[sensors.labels]"), "{text}");
1373        assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1374    }
1375
1376    #[test]
1377    fn save_hosts_creates_a_missing_file() {
1378        let tmp = tempfile::tempdir().unwrap();
1379        let path = tmp.path().join("new/rigtop/config.toml");
1380        Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1381        assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1382    }
1383
1384    #[test]
1385    fn save_hosts_refuses_to_touch_a_broken_file() {
1386        let tmp = tempfile::tempdir().unwrap();
1387        let path = tmp.path().join("config.toml");
1388        fs::write(&path, "[sensors\n").unwrap();
1389        let err = Config::save_hosts(&path, &[]).unwrap_err();
1390        assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1391        assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1392    }
1393
1394    #[test]
1395    fn unknown_fields_are_rejected() {
1396        let tmp = tempfile::tempdir().unwrap();
1397        let path = tmp.path().join("config.toml");
1398        // A typo such as "label" instead of "labels" must not be silently ignored.
1399        fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1400        assert!(matches!(
1401            Config::load(&path),
1402            Err(ConfigError::Parse { .. })
1403        ));
1404    }
1405
1406    #[test]
1407    fn save_and_reload_round_trip() {
1408        let tmp = tempfile::tempdir().unwrap();
1409        let path = tmp.path().join("nested/rigtop/config.toml");
1410        let mut config = Config::default();
1411        config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1412        config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1413        config.set_label("nct6686:nct6683.2592/fan1", "");
1414        config.save(&path).unwrap();
1415
1416        let back = Config::load(&path).unwrap();
1417        assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1418        // An empty name removes the label.
1419        assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1420        assert!(!path.with_extension("toml.tmp").exists());
1421    }
1422}