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