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//!
23//! Labels are applied by front-ends rather than by data sources, so that a
24//! client monitoring remote hosts uses its own local configuration.
25
26use std::collections::BTreeMap;
27use std::fs;
28use std::io;
29use std::path::{Path, PathBuf};
30
31use serde::{Deserialize, Serialize};
32
33use crate::snapshot::{Fan, Temperature};
34
35/// Default location of the SMART result files.
36pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
37/// Default location of the copied btrfs scrub status files.
38pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
39
40/// Errors while loading or saving the configuration.
41#[derive(Debug, thiserror::Error)]
42pub enum ConfigError {
43    /// The file could not be read or written.
44    #[error("cannot access {path}: {source}")]
45    Io {
46        /// File that failed.
47        path: PathBuf,
48        /// Underlying error.
49        source: io::Error,
50    },
51    /// The file is not valid TOML or has unexpected fields.
52    #[error("invalid config {path}: {source}")]
53    Parse {
54        /// File that failed.
55        path: PathBuf,
56        /// Underlying error.
57        source: toml::de::Error,
58    },
59    /// The configuration could not be serialized.
60    #[error("cannot serialize config: {0}")]
61    Serialize(#[from] toml::ser::Error),
62}
63
64/// Top-level configuration.
65#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct Config {
68    /// Sensor naming and visibility.
69    pub sensors: SensorConfig,
70    /// SMART result files.
71    pub smart: SmartConfig,
72    /// btrfs scrub status files.
73    pub btrfs: BtrfsConfig,
74}
75
76/// Sensor naming and visibility.
77#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
78#[serde(default, deny_unknown_fields)]
79pub struct SensorConfig {
80    /// User-defined names keyed by channel key (`"<chip key>/fan1"`, `"<chip key>/temp3"`).
81    /// A `BTreeMap` keeps the saved file in a stable order.
82    pub labels: BTreeMap<String, String>,
83    /// Channel keys to hide.
84    pub hidden: Vec<String>,
85}
86
87/// Where to find SMART results.
88#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct SmartConfig {
91    /// Directory containing one smartctl JSON file per device.
92    pub dir: PathBuf,
93}
94
95impl Default for SmartConfig {
96    fn default() -> Self {
97        Self {
98            dir: PathBuf::from(DEFAULT_SMART_DIR),
99        }
100    }
101}
102
103/// Where to find btrfs scrub results.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct BtrfsConfig {
107    /// Directory with world-readable copies of `scrub.status.<fsid>`.
108    pub scrub_dir: PathBuf,
109}
110
111impl Default for BtrfsConfig {
112    fn default() -> Self {
113        Self {
114            scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
115        }
116    }
117}
118
119impl Config {
120    /// Default path of the configuration file, if the platform has a config directory.
121    pub fn default_path() -> Option<PathBuf> {
122        dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
123    }
124
125    /// Loads the configuration from `path`. A missing file yields the defaults.
126    pub fn load(path: &Path) -> Result<Self, ConfigError> {
127        let text = match fs::read_to_string(path) {
128            Ok(text) => text,
129            Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
130            Err(source) => {
131                return Err(ConfigError::Io {
132                    path: path.to_owned(),
133                    source,
134                });
135            }
136        };
137        toml::from_str(&text).map_err(|source| ConfigError::Parse {
138            path: path.to_owned(),
139            source,
140        })
141    }
142
143    /// Loads from [`Config::default_path`], or returns the defaults when the
144    /// platform has no config directory.
145    pub fn load_default() -> Result<Self, ConfigError> {
146        match Self::default_path() {
147            Some(path) => Self::load(&path),
148            None => Ok(Self::default()),
149        }
150    }
151
152    /// Writes the configuration to `path`, creating parent directories.
153    ///
154    /// The file is written to a temporary sibling and renamed into place, so
155    /// a crash never leaves a truncated config behind.
156    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
157        let text = toml::to_string_pretty(self)?;
158        let io_err = |source| ConfigError::Io {
159            path: path.to_owned(),
160            source,
161        };
162        if let Some(parent) = path.parent() {
163            fs::create_dir_all(parent).map_err(io_err)?;
164        }
165        let tmp = path.with_extension("toml.tmp");
166        fs::write(&tmp, text).map_err(io_err)?;
167        fs::rename(&tmp, path).map_err(io_err)
168    }
169
170    /// Display name of a fan: the user-defined label, or the driver/generic label.
171    pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
172        self.label_for(&fan.key).unwrap_or(&fan.label)
173    }
174
175    /// Display name of a temperature channel.
176    pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
177        self.label_for(&temperature.key)
178            .unwrap_or(&temperature.label)
179    }
180
181    /// User-defined label for a channel key, if any.
182    pub fn label_for(&self, key: &str) -> Option<&str> {
183        self.sensors.labels.get(key).map(String::as_str)
184    }
185
186    /// Sets (or, with an empty name, removes) the label of a channel.
187    pub fn set_label(&mut self, key: &str, name: &str) {
188        let name = name.trim();
189        if name.is_empty() {
190            self.sensors.labels.remove(key);
191        } else {
192            self.sensors.labels.insert(key.to_owned(), name.to_owned());
193        }
194    }
195
196    /// Whether the user chose to hide a channel.
197    pub fn is_hidden(&self, key: &str) -> bool {
198        self.sensors.hidden.iter().any(|k| k == key)
199    }
200}
201
202#[cfg(test)]
203mod tests {
204    use super::*;
205
206    #[test]
207    fn missing_file_gives_defaults() {
208        let tmp = tempfile::tempdir().unwrap();
209        let config = Config::load(&tmp.path().join("none.toml")).unwrap();
210        assert_eq!(config, Config::default());
211        assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
212    }
213
214    #[test]
215    fn parses_labels_hidden_and_smart_dir() {
216        let tmp = tempfile::tempdir().unwrap();
217        let path = tmp.path().join("config.toml");
218        fs::write(
219            &path,
220            r#"
221[sensors]
222labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
223hidden = ["nct6686:nct6683.2592/temp4"]
224
225[smart]
226dir = "/tmp/smart"
227"#,
228        )
229        .unwrap();
230        let config = Config::load(&path).unwrap();
231        let fan = Fan {
232            key: "nct6686:nct6683.2592/fan1".to_owned(),
233            label: "fan1".to_owned(),
234            rpm: 900,
235        };
236        let other = Fan {
237            key: "nct6686:nct6683.2592/fan2".to_owned(),
238            label: "fan2".to_owned(),
239            rpm: 0,
240        };
241        assert_eq!(config.fan_label(&fan), "CPU fan");
242        // Unnamed channels fall back to the generic label.
243        assert_eq!(config.fan_label(&other), "fan2");
244        assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
245        assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
246    }
247
248    #[test]
249    fn unknown_fields_are_rejected() {
250        let tmp = tempfile::tempdir().unwrap();
251        let path = tmp.path().join("config.toml");
252        // A typo such as "label" instead of "labels" must not be silently ignored.
253        fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
254        assert!(matches!(
255            Config::load(&path),
256            Err(ConfigError::Parse { .. })
257        ));
258    }
259
260    #[test]
261    fn save_and_reload_round_trip() {
262        let tmp = tempfile::tempdir().unwrap();
263        let path = tmp.path().join("nested/rigtop/config.toml");
264        let mut config = Config::default();
265        config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
266        config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
267        config.set_label("nct6686:nct6683.2592/fan1", "");
268        config.save(&path).unwrap();
269
270        let back = Config::load(&path).unwrap();
271        assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
272        // An empty name removes the label.
273        assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
274        assert!(!path.with_extension("toml.tmp").exists());
275    }
276}