Skip to main content

rigtop_core/
config.rs

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