Skip to main content

rigtop_core/
config.rs

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