1use 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
35pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
37pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
39
40#[derive(Debug, thiserror::Error)]
42pub enum ConfigError {
43 #[error("cannot access {path}: {source}")]
45 Io {
46 path: PathBuf,
48 source: io::Error,
50 },
51 #[error("invalid config {path}: {source}")]
53 Parse {
54 path: PathBuf,
56 source: toml::de::Error,
58 },
59 #[error("cannot serialize config: {0}")]
61 Serialize(#[from] toml::ser::Error),
62}
63
64#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
66#[serde(default, deny_unknown_fields)]
67pub struct Config {
68 pub sensors: SensorConfig,
70 pub smart: SmartConfig,
72 pub btrfs: BtrfsConfig,
74}
75
76#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
78#[serde(default, deny_unknown_fields)]
79pub struct SensorConfig {
80 pub labels: BTreeMap<String, String>,
83 pub hidden: Vec<String>,
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct SmartConfig {
91 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#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105#[serde(default, deny_unknown_fields)]
106pub struct BtrfsConfig {
107 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 pub fn default_path() -> Option<PathBuf> {
122 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
123 }
124
125 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 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 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 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 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 pub fn label_for(&self, key: &str) -> Option<&str> {
183 self.sensors.labels.get(key).map(String::as_str)
184 }
185
186 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 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 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 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 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
274 assert!(!path.with_extension("toml.tmp").exists());
275 }
276}