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