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