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//! cpu_warning_celsius = 96    # hottest CPU reading
27//! gpu_warning_celsius = 85
28//! drive_warning_celsius = 70  # SMART or NVMe sensor
29//!
30//! [notifications]
31//! # rigtop-gui: desktop notifications for new alerts ("critical", "warning" or "off").
32//! level = "critical"
33//!
34//! [lhm]
35//! # Windows: LibreHardwareMonitor's web server ("" to disable).
36//! url = "http://127.0.0.1:8085/data.json"
37//!
38//! [overview]
39//! # Sections of each host card, most important first.
40//! sections = ["cpu", "gpu", "memory", "processes", "network", "storage"]
41//! include_local = true
42//! processes = 10
43//!
44//! [[hosts]]
45//! name = "zefat"
46//!
47//! [[hosts]]
48//! name = "nas"
49//! ssh = "admin@nas.local"
50//! command = "/usr/local/bin/rigtop --agent"
51//! sections = ["storage", "cpu", "network"]
52//!
53//! [[widgets]]
54//! name = "Clock"
55//! builtin = "clock"
56//!
57//! [[widgets]]
58//! name = "Rates"
59//! command = "~/bin/rates.sh"   # prints text or JSON rows, see `widget`
60//! interval_secs = 60
61//!
62//! ```
63//!
64//! Labels are applied by front-ends rather than by data sources, so that a
65//! client monitoring remote hosts uses its own local configuration.
66
67use std::collections::BTreeMap;
68use std::fs;
69use std::io;
70use std::path::{Path, PathBuf};
71
72use serde::{Deserialize, Serialize};
73
74use crate::snapshot::{Fan, Temperature};
75
76/// Default location of the SMART result files.
77pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
78/// Default location of the copied btrfs scrub status files.
79pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
80/// Default address of LibreHardwareMonitor's web server (Windows sensors).
81pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
82
83/// Errors while loading or saving the configuration.
84#[derive(Debug, thiserror::Error)]
85pub enum ConfigError {
86    /// The file could not be read or written.
87    #[error("cannot access {path}: {source}")]
88    Io {
89        /// File that failed.
90        path: PathBuf,
91        /// Underlying error.
92        source: io::Error,
93    },
94    /// The file is not valid TOML or has unexpected fields.
95    #[error("invalid config {path}: {source}")]
96    Parse {
97        /// File that failed.
98        path: PathBuf,
99        /// Underlying error.
100        source: toml::de::Error,
101    },
102    /// The configuration could not be serialized.
103    #[error("cannot serialize config: {0}")]
104    Serialize(#[from] toml::ser::Error),
105    /// The file is not valid TOML, so it cannot be edited in place.
106    #[error("cannot edit {path}: {source}")]
107    Edit {
108        /// File that failed.
109        path: PathBuf,
110        /// Underlying error.
111        source: toml_edit::TomlError,
112    },
113}
114
115/// Top-level configuration.
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
117#[serde(default, deny_unknown_fields)]
118pub struct Config {
119    /// Sensor naming and visibility.
120    pub sensors: SensorConfig,
121    /// SMART result files.
122    pub smart: SmartConfig,
123    /// btrfs scrub status files.
124    pub btrfs: BtrfsConfig,
125    /// LibreHardwareMonitor's web server, the source of temperatures, fans
126    /// and CPU power on Windows.
127    pub lhm: LhmConfig,
128    /// The multi-host overview.
129    pub overview: OverviewConfig,
130    /// When a reading becomes an alert.
131    pub alerts: AlertsConfig,
132    /// Desktop notifications for new alerts (GUI).
133    pub notifications: NotificationsConfig,
134    /// Remote hosts monitored over SSH (`[[hosts]]` tables).
135    pub hosts: Vec<HostConfig>,
136    /// Extra panels beside the monitor (`[[widgets]]` tables).
137    pub widgets: Vec<WidgetConfig>,
138}
139
140/// Widgets drawn by rigtop itself.
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum BuiltinWidget {
144    /// The date and a large clock.
145    Clock,
146}
147
148/// One widget: a built-in one, or a command whose output is shown (see
149/// [`crate::widget`] for the output format).
150#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct WidgetConfig {
153    /// Title of the widget.
154    pub name: String,
155    /// Shell command printing the content.
156    pub command: Option<String>,
157    /// Or a built-in widget.
158    pub builtin: Option<BuiltinWidget>,
159    /// Seconds between runs of the command.
160    pub interval_secs: u64,
161    /// Seconds after which a run is killed.
162    pub timeout_secs: u64,
163}
164
165impl Default for WidgetConfig {
166    fn default() -> Self {
167        Self {
168            name: String::new(),
169            command: None,
170            builtin: None,
171            interval_secs: 5,
172            timeout_secs: 10,
173        }
174    }
175}
176
177/// A block of information on a host card in the overview.
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum OverviewSection {
181    Cpu,
182    Gpu,
183    Memory,
184    Processes,
185    Network,
186    Storage,
187}
188
189impl OverviewSection {
190    /// All sections in the default priority order.
191    pub const ALL: [OverviewSection; 6] = [
192        OverviewSection::Cpu,
193        OverviewSection::Gpu,
194        OverviewSection::Memory,
195        OverviewSection::Processes,
196        OverviewSection::Network,
197        OverviewSection::Storage,
198    ];
199
200    /// Name shown in menus.
201    pub fn label(self) -> &'static str {
202        match self {
203            OverviewSection::Cpu => "CPU",
204            OverviewSection::Gpu => "GPU",
205            OverviewSection::Memory => "Memory",
206            OverviewSection::Processes => "Processes",
207            OverviewSection::Network => "Network",
208            OverviewSection::Storage => "Storage",
209        }
210    }
211}
212
213/// Settings of the multi-host overview.
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(default, deny_unknown_fields)]
216pub struct OverviewConfig {
217    /// Sections shown on each host card, most important first. Sections are
218    /// dropped from the end when a card is too small; leaving one out of
219    /// the list hides it.
220    pub sections: Vec<OverviewSection>,
221    /// Whether this machine also gets a card (it always has its own tab).
222    pub include_local: bool,
223    /// Processes listed per card. Hosts not shown in detail only send this
224    /// many, to keep SSH traffic small.
225    pub processes: usize,
226}
227
228impl Default for OverviewConfig {
229    fn default() -> Self {
230        Self {
231            sections: OverviewSection::ALL.to_vec(),
232            include_local: true,
233            processes: 10,
234        }
235    }
236}
237
238/// A remote host to monitor.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct HostConfig {
242    /// Name shown in tabs and on the overview card.
243    pub name: String,
244    /// SSH destination (a `~/.ssh/config` host, `user@host`, ...);
245    /// defaults to `name`.
246    #[serde(default, skip_serializing_if = "Option::is_none")]
247    pub ssh: Option<String>,
248    /// Command that starts the agent; defaults to `rigtop --agent`.
249    #[serde(default, skip_serializing_if = "Option::is_none")]
250    pub command: Option<String>,
251    /// Overview sections for this host, overriding `[overview] sections`
252    /// (e.g. storage first for a file server).
253    #[serde(default, skip_serializing_if = "Option::is_none")]
254    pub sections: Option<Vec<OverviewSection>>,
255}
256
257impl HostConfig {
258    /// A host given only by its SSH destination (e.g. on the command line).
259    pub fn from_destination(destination: &str) -> Self {
260        Self {
261            name: destination.to_owned(),
262            ssh: None,
263            command: None,
264            sections: None,
265        }
266    }
267
268    /// The SSH destination.
269    pub fn destination(&self) -> &str {
270        self.ssh.as_deref().unwrap_or(&self.name)
271    }
272}
273
274/// Sensor naming and visibility.
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[serde(default, deny_unknown_fields)]
277pub struct SensorConfig {
278    /// User-defined names keyed by channel key (`"<chip key>/fan1"`, `"<chip key>/temp3"`).
279    /// A `BTreeMap` keeps the saved file in a stable order.
280    pub labels: BTreeMap<String, String>,
281    /// Channel keys to hide.
282    pub hidden: Vec<String>,
283}
284
285/// Where to find SMART results.
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(default, deny_unknown_fields)]
288pub struct SmartConfig {
289    /// Directory containing one smartctl JSON file per device.
290    pub dir: PathBuf,
291}
292
293impl Default for SmartConfig {
294    fn default() -> Self {
295        Self {
296            dir: PathBuf::from(DEFAULT_SMART_DIR),
297        }
298    }
299}
300
301/// Which new alerts the GUI announces with a desktop notification.
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "lowercase")]
304pub enum NotifyLevel {
305    /// No notifications.
306    Off,
307    /// Warnings and critical alerts.
308    Warning,
309    /// Critical alerts only (data at risk).
310    #[default]
311    Critical,
312}
313
314/// Thresholds that turn readings into alerts (overview cards, status dots
315/// and notifications). 0 turns an alert off. Applied by the front-end, so
316/// remote hosts are judged by the viewer's settings.
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(default, deny_unknown_fields)]
319pub struct AlertsConfig {
320    /// Filesystem usage (%) for a warning.
321    pub filesystem_warning_percent: f64,
322    /// Filesystem usage (%) for a critical alert.
323    pub filesystem_critical_percent: f64,
324    /// Hottest CPU reading (°C) for a warning. Desktop Ryzen CPUs run up to
325    /// 95 °C under full load by design.
326    pub cpu_warning_celsius: f32,
327    /// GPU temperature (°C) for a warning.
328    pub gpu_warning_celsius: f32,
329    /// Drive temperature (°C, from SMART or the NVMe sensor) for a warning.
330    pub drive_warning_celsius: f32,
331}
332
333impl Default for AlertsConfig {
334    fn default() -> Self {
335        Self {
336            filesystem_warning_percent: 90.0,
337            filesystem_critical_percent: 97.0,
338            cpu_warning_celsius: 96.0,
339            gpu_warning_celsius: 85.0,
340            drive_warning_celsius: 70.0,
341        }
342    }
343}
344
345/// Desktop notification settings.
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
347#[serde(default, deny_unknown_fields)]
348pub struct NotificationsConfig {
349    pub level: NotifyLevel,
350}
351
352/// Where to read Windows sensors from.
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354#[serde(default, deny_unknown_fields)]
355pub struct LhmConfig {
356    /// `data.json` of LibreHardwareMonitor's "Remote Web Server" (plain
357    /// HTTP without authentication); empty to disable.
358    pub url: String,
359}
360
361impl Default for LhmConfig {
362    fn default() -> Self {
363        Self {
364            url: DEFAULT_LHM_URL.to_owned(),
365        }
366    }
367}
368
369/// Where to find btrfs scrub results.
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(default, deny_unknown_fields)]
372pub struct BtrfsConfig {
373    /// Directory with world-readable copies of `scrub.status.<fsid>`.
374    pub scrub_dir: PathBuf,
375}
376
377impl Default for BtrfsConfig {
378    fn default() -> Self {
379        Self {
380            scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
381        }
382    }
383}
384
385impl Config {
386    /// Default path of the configuration file, if the platform has a config directory.
387    pub fn default_path() -> Option<PathBuf> {
388        dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
389    }
390
391    /// Loads the configuration from `path`. A missing file yields the defaults.
392    pub fn load(path: &Path) -> Result<Self, ConfigError> {
393        let text = match fs::read_to_string(path) {
394            Ok(text) => text,
395            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
396            Err(source) => {
397                return Err(ConfigError::Io {
398                    path: path.to_owned(),
399                    source,
400                });
401            }
402        };
403        toml::from_str(&text).map_err(|source| ConfigError::Parse {
404            path: path.to_owned(),
405            source,
406        })
407    }
408
409    /// Loads from [`Config::default_path`], or returns the defaults when the
410    /// platform has no config directory.
411    pub fn load_default() -> Result<Self, ConfigError> {
412        match Self::default_path() {
413            Some(path) => Self::load(&path),
414            None => Ok(Self::default()),
415        }
416    }
417
418    /// Writes the configuration to `path`, creating parent directories.
419    ///
420    /// The file is written to a temporary sibling and renamed into place, so
421    /// a crash never leaves a truncated config behind.
422    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
423        let text = toml::to_string_pretty(self)?;
424        let io_err = |source| ConfigError::Io {
425            path: path.to_owned(),
426            source,
427        };
428        if let Some(parent) = path.parent() {
429            fs::create_dir_all(parent).map_err(io_err)?;
430        }
431        let tmp = path.with_extension("toml.tmp");
432        fs::write(&tmp, text).map_err(io_err)?;
433        fs::rename(&tmp, path).map_err(io_err)
434    }
435
436    /// Replaces the `[[hosts]]` of the configuration file at `path` with
437    /// `hosts`, keeping everything else in the file (other settings,
438    /// comments, formatting) untouched. Creates the file if needed.
439    ///
440    /// Used by the GUI's host editor. The edited file is checked to load as
441    /// a valid configuration before it replaces the old one.
442    pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
443        edit_file(path, |document| {
444            if hosts.is_empty() {
445                document.remove("hosts");
446                return Ok(());
447            }
448            let mut tables = toml_edit::ArrayOfTables::new();
449            for host in hosts {
450                let mut table = toml_edit::Table::new();
451                table["name"] = toml_edit::value(host.name.as_str());
452                if let Some(ssh) = &host.ssh {
453                    table["ssh"] = toml_edit::value(ssh.as_str());
454                }
455                if let Some(command) = &host.command {
456                    table["command"] = toml_edit::value(command.as_str());
457                }
458                if let Some(sections) = &host.sections {
459                    let mut array = toml_edit::Array::new();
460                    for section in sections {
461                        // Serialized names match the serde representation.
462                        let name = toml::Value::try_from(section)?;
463                        if let Some(name) = name.as_str() {
464                            array.push(name);
465                        }
466                    }
467                    table["sections"] = toml_edit::value(array);
468                }
469                tables.push(table);
470            }
471            document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
472            Ok(())
473        })
474    }
475
476    /// Replaces the sensor names and hidden sensors (`[sensors]`) of the
477    /// configuration file at `path`, keeping the rest of the file as it is,
478    /// like [`Config::save_hosts`]. Names stay an inline table if they were
479    /// one.
480    ///
481    /// Used by the GUI's sensor editor.
482    pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
483        edit_file(path, |document| {
484            if !document.contains_key("sensors") {
485                if sensors.labels.is_empty() && sensors.hidden.is_empty() {
486                    return Ok(());
487                }
488                document["sensors"] = toml_edit::table();
489            }
490            let Some(table) = document["sensors"].as_table_like_mut() else {
491                // Not a table: loading would have failed already.
492                return Ok(());
493            };
494            if sensors.hidden.is_empty() {
495                table.remove("hidden");
496            } else {
497                let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
498                table.insert("hidden", toml_edit::value(hidden));
499            }
500            let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
501            if sensors.labels.is_empty() {
502                table.remove("labels");
503            } else if inline {
504                let labels: toml_edit::InlineTable = sensors
505                    .labels
506                    .iter()
507                    .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
508                    .collect();
509                table.insert("labels", toml_edit::value(labels));
510            } else {
511                let mut labels = toml_edit::Table::new();
512                for (key, name) in &sensors.labels {
513                    labels[key.as_str()] = toml_edit::value(name.as_str());
514                }
515                table.insert("labels", toml_edit::Item::Table(labels));
516            }
517            if table.is_empty() {
518                document.remove("sensors");
519            }
520            Ok(())
521        })
522    }
523
524    /// Display name of a fan: the user-defined label, or the driver/generic label.
525    pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
526        self.label_for(&fan.key).unwrap_or(&fan.label)
527    }
528
529    /// Display name of a temperature channel.
530    pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
531        self.label_for(&temperature.key)
532            .unwrap_or(&temperature.label)
533    }
534
535    /// User-defined label for a channel key, if any.
536    pub fn label_for(&self, key: &str) -> Option<&str> {
537        self.sensors.labels.get(key).map(String::as_str)
538    }
539
540    /// Sets (or, with an empty name, removes) the label of a channel.
541    pub fn set_label(&mut self, key: &str, name: &str) {
542        let name = name.trim();
543        if name.is_empty() {
544            self.sensors.labels.remove(key);
545        } else {
546            self.sensors.labels.insert(key.to_owned(), name.to_owned());
547        }
548    }
549
550    /// Whether the user chose to hide a channel.
551    pub fn is_hidden(&self, key: &str) -> bool {
552        self.sensors.hidden.iter().any(|k| k == key)
553    }
554}
555
556/// Reads the configuration file at `path` (or an empty one), lets `edit`
557/// change it with comments and formatting kept, and writes it back if it
558/// still loads as a configuration (through a temporary file, so a crash
559/// never leaves a truncated file behind).
560fn edit_file(
561    path: &Path,
562    edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
563) -> Result<(), ConfigError> {
564    let io_err = |source| ConfigError::Io {
565        path: path.to_owned(),
566        source,
567    };
568    let text = match fs::read_to_string(path) {
569        Ok(text) => text,
570        Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
571        Err(err) => return Err(io_err(err)),
572    };
573    let mut document: toml_edit::DocumentMut =
574        text.parse().map_err(|source| ConfigError::Edit {
575            path: path.to_owned(),
576            source,
577        })?;
578    edit(&mut document)?;
579    let edited = document.to_string();
580    // Never write a file that rigtop itself could not load afterwards.
581    toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
582        path: path.to_owned(),
583        source,
584    })?;
585    if let Some(parent) = path.parent() {
586        fs::create_dir_all(parent).map_err(io_err)?;
587    }
588    let tmp = path.with_extension("toml.tmp");
589    fs::write(&tmp, edited).map_err(io_err)?;
590    fs::rename(&tmp, path).map_err(io_err)
591}
592
593#[cfg(test)]
594mod tests {
595    use super::*;
596
597    #[test]
598    fn missing_file_gives_defaults() {
599        let tmp = tempfile::tempdir().unwrap();
600        let config = Config::load(&tmp.path().join("none.toml")).unwrap();
601        assert_eq!(config, Config::default());
602        assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
603    }
604
605    #[test]
606    fn parses_labels_hidden_and_smart_dir() {
607        let tmp = tempfile::tempdir().unwrap();
608        let path = tmp.path().join("config.toml");
609        fs::write(
610            &path,
611            r#"
612[sensors]
613labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
614hidden = ["nct6686:nct6683.2592/temp4"]
615
616[smart]
617dir = "/tmp/smart"
618
619[lhm]
620url = ""
621
622[notifications]
623level = "warning"
624
625[alerts]
626drive_warning_celsius = 65
627cpu_warning_celsius = 0
628"#,
629        )
630        .unwrap();
631        let config = Config::load(&path).unwrap();
632        let fan = Fan {
633            key: "nct6686:nct6683.2592/fan1".to_owned(),
634            label: "fan1".to_owned(),
635            rpm: 900,
636        };
637        let other = Fan {
638            key: "nct6686:nct6683.2592/fan2".to_owned(),
639            label: "fan2".to_owned(),
640            rpm: 0,
641        };
642        assert_eq!(config.fan_label(&fan), "CPU fan");
643        // Unnamed channels fall back to the generic label.
644        assert_eq!(config.fan_label(&other), "fan2");
645        assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
646        assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
647        assert_eq!(config.lhm.url, "");
648        assert_eq!(config.notifications.level, NotifyLevel::Warning);
649        assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
650        assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
651        assert_eq!(config.alerts.drive_warning_celsius, 65.0);
652        assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
653        // Unset thresholds keep their defaults.
654        assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
655    }
656
657    #[test]
658    fn parses_hosts_and_overview() {
659        let config: Config = toml::from_str(
660            r#"
661[overview]
662sections = ["storage", "cpu"]
663include_local = false
664
665[[hosts]]
666name = "zefat"
667
668[[hosts]]
669name = "nas"
670ssh = "admin@nas.local"
671sections = ["storage"]
672
673[[widgets]]
674name = "Clock"
675builtin = "clock"
676
677[[widgets]]
678name = "Rates"
679command = "rates.sh"
680interval_secs = 60
681"#,
682        )
683        .unwrap();
684        assert_eq!(
685            config.overview.sections,
686            vec![OverviewSection::Storage, OverviewSection::Cpu]
687        );
688        assert!(!config.overview.include_local);
689        assert_eq!(config.overview.processes, 10, "default kept");
690        assert_eq!(config.hosts.len(), 2);
691        assert_eq!(config.hosts[0].destination(), "zefat");
692        assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
693        assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
694        assert_eq!(config.widgets[1].interval_secs, 60);
695        assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
696        assert_eq!(config.hosts[1].destination(), "admin@nas.local");
697        assert_eq!(
698            config.hosts[1].sections,
699            Some(vec![OverviewSection::Storage])
700        );
701        // Defaults: every section in priority order, local included.
702        let defaults = Config::default();
703        assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
704        assert!(defaults.overview.include_local);
705    }
706
707    #[test]
708    fn save_hosts_replaces_only_the_hosts() {
709        let tmp = tempfile::tempdir().unwrap();
710        let path = tmp.path().join("config.toml");
711        fs::write(
712            &path,
713            "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
714        )
715        .unwrap();
716        let hosts = vec![
717            HostConfig::from_destination("zefat"),
718            HostConfig {
719                name: "nas".to_owned(),
720                ssh: Some("admin@nas.local".to_owned()),
721                command: Some("/opt/rigtop --agent".to_owned()),
722                sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
723            },
724        ];
725        Config::save_hosts(&path, &hosts).unwrap();
726
727        let text = fs::read_to_string(&path).unwrap();
728        assert!(
729            text.contains("# my settings") && text.contains("# keep me"),
730            "{text}"
731        );
732        assert!(!text.contains("\"old\""), "{text}");
733        let config = Config::load(&path).unwrap();
734        assert_eq!(config.hosts, hosts);
735        assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
736
737        // Removing every host drops the [[hosts]] tables.
738        Config::save_hosts(&path, &[]).unwrap();
739        assert!(Config::load(&path).unwrap().hosts.is_empty());
740        assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
741    }
742
743    #[test]
744    fn save_sensors_replaces_only_names_and_hidden_sensors() {
745        let tmp = tempfile::tempdir().unwrap();
746        let path = tmp.path().join("config.toml");
747        fs::write(
748            &path,
749            "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
750        )
751        .unwrap();
752        let mut sensors = SensorConfig::default();
753        sensors
754            .labels
755            .insert("a/fan1".to_owned(), "CPU fan".to_owned());
756        sensors
757            .labels
758            .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
759        sensors.hidden.push("b:c.1/temp4".to_owned());
760        Config::save_sensors(&path, &sensors).unwrap();
761
762        let text = fs::read_to_string(&path).unwrap();
763        assert!(text.contains("# my settings"), "{text}");
764        // Inline names stay inline.
765        assert!(text.contains("labels = {"), "{text}");
766        let config = Config::load(&path).unwrap();
767        assert_eq!(config.sensors, sensors);
768        assert_eq!(config.hosts[0].name, "zefat");
769
770        // Nothing left: the [sensors] table goes away.
771        Config::save_sensors(&path, &SensorConfig::default()).unwrap();
772        let text = fs::read_to_string(&path).unwrap();
773        assert!(!text.contains("[sensors]"), "{text}");
774        assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
775
776        // A new file gets a [sensors.labels] table.
777        let fresh = tmp.path().join("new.toml");
778        Config::save_sensors(&fresh, &sensors).unwrap();
779        let text = fs::read_to_string(&fresh).unwrap();
780        assert!(text.contains("[sensors.labels]"), "{text}");
781        assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
782    }
783
784    #[test]
785    fn save_hosts_creates_a_missing_file() {
786        let tmp = tempfile::tempdir().unwrap();
787        let path = tmp.path().join("new/rigtop/config.toml");
788        Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
789        assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
790    }
791
792    #[test]
793    fn save_hosts_refuses_to_touch_a_broken_file() {
794        let tmp = tempfile::tempdir().unwrap();
795        let path = tmp.path().join("config.toml");
796        fs::write(&path, "[sensors\n").unwrap();
797        let err = Config::save_hosts(&path, &[]).unwrap_err();
798        assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
799        assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
800    }
801
802    #[test]
803    fn unknown_fields_are_rejected() {
804        let tmp = tempfile::tempdir().unwrap();
805        let path = tmp.path().join("config.toml");
806        // A typo such as "label" instead of "labels" must not be silently ignored.
807        fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
808        assert!(matches!(
809            Config::load(&path),
810            Err(ConfigError::Parse { .. })
811        ));
812    }
813
814    #[test]
815    fn save_and_reload_round_trip() {
816        let tmp = tempfile::tempdir().unwrap();
817        let path = tmp.path().join("nested/rigtop/config.toml");
818        let mut config = Config::default();
819        config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
820        config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
821        config.set_label("nct6686:nct6683.2592/fan1", "");
822        config.save(&path).unwrap();
823
824        let back = Config::load(&path).unwrap();
825        assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
826        // An empty name removes the label.
827        assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
828        assert!(!path.with_extension("toml.tmp").exists());
829    }
830}