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