Skip to main content

rigtop_core/
config.rs

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