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