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