1use std::collections::BTreeMap;
42use std::fs;
43use std::io;
44use std::path::{Path, PathBuf};
45
46use serde::{Deserialize, Serialize};
47
48use crate::snapshot::{Fan, Temperature};
49
50pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
52pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
54
55#[derive(Debug, thiserror::Error)]
57pub enum ConfigError {
58 #[error("cannot access {path}: {source}")]
60 Io {
61 path: PathBuf,
63 source: io::Error,
65 },
66 #[error("invalid config {path}: {source}")]
68 Parse {
69 path: PathBuf,
71 source: toml::de::Error,
73 },
74 #[error("cannot serialize config: {0}")]
76 Serialize(#[from] toml::ser::Error),
77 #[error("cannot edit {path}: {source}")]
79 Edit {
80 path: PathBuf,
82 source: toml_edit::TomlError,
84 },
85}
86
87#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
89#[serde(default, deny_unknown_fields)]
90pub struct Config {
91 pub sensors: SensorConfig,
93 pub smart: SmartConfig,
95 pub btrfs: BtrfsConfig,
97 pub overview: OverviewConfig,
99 pub hosts: Vec<HostConfig>,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
105#[serde(rename_all = "lowercase")]
106pub enum OverviewSection {
107 Cpu,
108 Gpu,
109 Memory,
110 Processes,
111 Network,
112 Storage,
113}
114
115impl OverviewSection {
116 pub const ALL: [OverviewSection; 6] = [
118 OverviewSection::Cpu,
119 OverviewSection::Gpu,
120 OverviewSection::Memory,
121 OverviewSection::Processes,
122 OverviewSection::Network,
123 OverviewSection::Storage,
124 ];
125
126 pub fn label(self) -> &'static str {
128 match self {
129 OverviewSection::Cpu => "CPU",
130 OverviewSection::Gpu => "GPU",
131 OverviewSection::Memory => "Memory",
132 OverviewSection::Processes => "Processes",
133 OverviewSection::Network => "Network",
134 OverviewSection::Storage => "Storage",
135 }
136 }
137}
138
139#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(default, deny_unknown_fields)]
142pub struct OverviewConfig {
143 pub sections: Vec<OverviewSection>,
147 pub include_local: bool,
149 pub processes: usize,
152}
153
154impl Default for OverviewConfig {
155 fn default() -> Self {
156 Self {
157 sections: OverviewSection::ALL.to_vec(),
158 include_local: true,
159 processes: 10,
160 }
161 }
162}
163
164#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
166#[serde(deny_unknown_fields)]
167pub struct HostConfig {
168 pub name: String,
170 #[serde(default, skip_serializing_if = "Option::is_none")]
173 pub ssh: Option<String>,
174 #[serde(default, skip_serializing_if = "Option::is_none")]
176 pub command: Option<String>,
177 #[serde(default, skip_serializing_if = "Option::is_none")]
180 pub sections: Option<Vec<OverviewSection>>,
181}
182
183impl HostConfig {
184 pub fn from_destination(destination: &str) -> Self {
186 Self {
187 name: destination.to_owned(),
188 ssh: None,
189 command: None,
190 sections: None,
191 }
192 }
193
194 pub fn destination(&self) -> &str {
196 self.ssh.as_deref().unwrap_or(&self.name)
197 }
198}
199
200#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
202#[serde(default, deny_unknown_fields)]
203pub struct SensorConfig {
204 pub labels: BTreeMap<String, String>,
207 pub hidden: Vec<String>,
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
213#[serde(default, deny_unknown_fields)]
214pub struct SmartConfig {
215 pub dir: PathBuf,
217}
218
219impl Default for SmartConfig {
220 fn default() -> Self {
221 Self {
222 dir: PathBuf::from(DEFAULT_SMART_DIR),
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
229#[serde(default, deny_unknown_fields)]
230pub struct BtrfsConfig {
231 pub scrub_dir: PathBuf,
233}
234
235impl Default for BtrfsConfig {
236 fn default() -> Self {
237 Self {
238 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
239 }
240 }
241}
242
243impl Config {
244 pub fn default_path() -> Option<PathBuf> {
246 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
247 }
248
249 pub fn load(path: &Path) -> Result<Self, ConfigError> {
251 let text = match fs::read_to_string(path) {
252 Ok(text) => text,
253 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
254 Err(source) => {
255 return Err(ConfigError::Io {
256 path: path.to_owned(),
257 source,
258 });
259 }
260 };
261 toml::from_str(&text).map_err(|source| ConfigError::Parse {
262 path: path.to_owned(),
263 source,
264 })
265 }
266
267 pub fn load_default() -> Result<Self, ConfigError> {
270 match Self::default_path() {
271 Some(path) => Self::load(&path),
272 None => Ok(Self::default()),
273 }
274 }
275
276 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
281 let text = toml::to_string_pretty(self)?;
282 let io_err = |source| ConfigError::Io {
283 path: path.to_owned(),
284 source,
285 };
286 if let Some(parent) = path.parent() {
287 fs::create_dir_all(parent).map_err(io_err)?;
288 }
289 let tmp = path.with_extension("toml.tmp");
290 fs::write(&tmp, text).map_err(io_err)?;
291 fs::rename(&tmp, path).map_err(io_err)
292 }
293
294 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
301 let io_err = |source| ConfigError::Io {
302 path: path.to_owned(),
303 source,
304 };
305 let text = match fs::read_to_string(path) {
306 Ok(text) => text,
307 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
308 Err(err) => return Err(io_err(err)),
309 };
310 let mut document: toml_edit::DocumentMut =
311 text.parse().map_err(|source| ConfigError::Edit {
312 path: path.to_owned(),
313 source,
314 })?;
315 if hosts.is_empty() {
316 document.remove("hosts");
317 } else {
318 let mut tables = toml_edit::ArrayOfTables::new();
319 for host in hosts {
320 let mut table = toml_edit::Table::new();
321 table["name"] = toml_edit::value(host.name.as_str());
322 if let Some(ssh) = &host.ssh {
323 table["ssh"] = toml_edit::value(ssh.as_str());
324 }
325 if let Some(command) = &host.command {
326 table["command"] = toml_edit::value(command.as_str());
327 }
328 if let Some(sections) = &host.sections {
329 let mut array = toml_edit::Array::new();
330 for section in sections {
331 let name = toml::Value::try_from(section)?;
333 if let Some(name) = name.as_str() {
334 array.push(name);
335 }
336 }
337 table["sections"] = toml_edit::value(array);
338 }
339 tables.push(table);
340 }
341 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
342 }
343 let edited = document.to_string();
344 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
346 path: path.to_owned(),
347 source,
348 })?;
349 if let Some(parent) = path.parent() {
350 fs::create_dir_all(parent).map_err(io_err)?;
351 }
352 let tmp = path.with_extension("toml.tmp");
353 fs::write(&tmp, edited).map_err(io_err)?;
354 fs::rename(&tmp, path).map_err(io_err)
355 }
356
357 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
359 self.label_for(&fan.key).unwrap_or(&fan.label)
360 }
361
362 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
364 self.label_for(&temperature.key)
365 .unwrap_or(&temperature.label)
366 }
367
368 pub fn label_for(&self, key: &str) -> Option<&str> {
370 self.sensors.labels.get(key).map(String::as_str)
371 }
372
373 pub fn set_label(&mut self, key: &str, name: &str) {
375 let name = name.trim();
376 if name.is_empty() {
377 self.sensors.labels.remove(key);
378 } else {
379 self.sensors.labels.insert(key.to_owned(), name.to_owned());
380 }
381 }
382
383 pub fn is_hidden(&self, key: &str) -> bool {
385 self.sensors.hidden.iter().any(|k| k == key)
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 use super::*;
392
393 #[test]
394 fn missing_file_gives_defaults() {
395 let tmp = tempfile::tempdir().unwrap();
396 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
397 assert_eq!(config, Config::default());
398 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
399 }
400
401 #[test]
402 fn parses_labels_hidden_and_smart_dir() {
403 let tmp = tempfile::tempdir().unwrap();
404 let path = tmp.path().join("config.toml");
405 fs::write(
406 &path,
407 r#"
408[sensors]
409labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
410hidden = ["nct6686:nct6683.2592/temp4"]
411
412[smart]
413dir = "/tmp/smart"
414"#,
415 )
416 .unwrap();
417 let config = Config::load(&path).unwrap();
418 let fan = Fan {
419 key: "nct6686:nct6683.2592/fan1".to_owned(),
420 label: "fan1".to_owned(),
421 rpm: 900,
422 };
423 let other = Fan {
424 key: "nct6686:nct6683.2592/fan2".to_owned(),
425 label: "fan2".to_owned(),
426 rpm: 0,
427 };
428 assert_eq!(config.fan_label(&fan), "CPU fan");
429 assert_eq!(config.fan_label(&other), "fan2");
431 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
432 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
433 }
434
435 #[test]
436 fn parses_hosts_and_overview() {
437 let config: Config = toml::from_str(
438 r#"
439[overview]
440sections = ["storage", "cpu"]
441include_local = false
442
443[[hosts]]
444name = "zefat"
445
446[[hosts]]
447name = "nas"
448ssh = "admin@nas.local"
449sections = ["storage"]
450"#,
451 )
452 .unwrap();
453 assert_eq!(
454 config.overview.sections,
455 vec![OverviewSection::Storage, OverviewSection::Cpu]
456 );
457 assert!(!config.overview.include_local);
458 assert_eq!(config.overview.processes, 10, "default kept");
459 assert_eq!(config.hosts.len(), 2);
460 assert_eq!(config.hosts[0].destination(), "zefat");
461 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
462 assert_eq!(
463 config.hosts[1].sections,
464 Some(vec![OverviewSection::Storage])
465 );
466 let defaults = Config::default();
468 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
469 assert!(defaults.overview.include_local);
470 }
471
472 #[test]
473 fn save_hosts_replaces_only_the_hosts() {
474 let tmp = tempfile::tempdir().unwrap();
475 let path = tmp.path().join("config.toml");
476 fs::write(
477 &path,
478 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
479 )
480 .unwrap();
481 let hosts = vec![
482 HostConfig::from_destination("zefat"),
483 HostConfig {
484 name: "nas".to_owned(),
485 ssh: Some("admin@nas.local".to_owned()),
486 command: Some("/opt/rigtop --agent".to_owned()),
487 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
488 },
489 ];
490 Config::save_hosts(&path, &hosts).unwrap();
491
492 let text = fs::read_to_string(&path).unwrap();
493 assert!(
494 text.contains("# my settings") && text.contains("# keep me"),
495 "{text}"
496 );
497 assert!(!text.contains("\"old\""), "{text}");
498 let config = Config::load(&path).unwrap();
499 assert_eq!(config.hosts, hosts);
500 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
501
502 Config::save_hosts(&path, &[]).unwrap();
504 assert!(Config::load(&path).unwrap().hosts.is_empty());
505 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
506 }
507
508 #[test]
509 fn save_hosts_creates_a_missing_file() {
510 let tmp = tempfile::tempdir().unwrap();
511 let path = tmp.path().join("new/rigtop/config.toml");
512 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
513 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
514 }
515
516 #[test]
517 fn save_hosts_refuses_to_touch_a_broken_file() {
518 let tmp = tempfile::tempdir().unwrap();
519 let path = tmp.path().join("config.toml");
520 fs::write(&path, "[sensors\n").unwrap();
521 let err = Config::save_hosts(&path, &[]).unwrap_err();
522 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
523 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
524 }
525
526 #[test]
527 fn unknown_fields_are_rejected() {
528 let tmp = tempfile::tempdir().unwrap();
529 let path = tmp.path().join("config.toml");
530 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
532 assert!(matches!(
533 Config::load(&path),
534 Err(ConfigError::Parse { .. })
535 ));
536 }
537
538 #[test]
539 fn save_and_reload_round_trip() {
540 let tmp = tempfile::tempdir().unwrap();
541 let path = tmp.path().join("nested/rigtop/config.toml");
542 let mut config = Config::default();
543 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
544 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
545 config.set_label("nct6686:nct6683.2592/fan1", "");
546 config.save(&path).unwrap();
547
548 let back = Config::load(&path).unwrap();
549 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
550 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
552 assert!(!path.with_extension("toml.tmp").exists());
553 }
554}