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