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