1use std::collections::BTreeMap;
50use std::fs;
51use std::io;
52use std::path::{Path, PathBuf};
53
54use serde::{Deserialize, Serialize};
55
56use crate::snapshot::{Fan, Temperature};
57
58pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
60pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
62pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
64
65#[derive(Debug, thiserror::Error)]
67pub enum ConfigError {
68 #[error("cannot access {path}: {source}")]
70 Io {
71 path: PathBuf,
73 source: io::Error,
75 },
76 #[error("invalid config {path}: {source}")]
78 Parse {
79 path: PathBuf,
81 source: toml::de::Error,
83 },
84 #[error("cannot serialize config: {0}")]
86 Serialize(#[from] toml::ser::Error),
87 #[error("cannot edit {path}: {source}")]
89 Edit {
90 path: PathBuf,
92 source: toml_edit::TomlError,
94 },
95}
96
97#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
99#[serde(default, deny_unknown_fields)]
100pub struct Config {
101 pub sensors: SensorConfig,
103 pub smart: SmartConfig,
105 pub btrfs: BtrfsConfig,
107 pub lhm: LhmConfig,
110 pub overview: OverviewConfig,
112 pub notifications: NotificationsConfig,
114 pub hosts: Vec<HostConfig>,
116}
117
118#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
120#[serde(rename_all = "lowercase")]
121pub enum OverviewSection {
122 Cpu,
123 Gpu,
124 Memory,
125 Processes,
126 Network,
127 Storage,
128}
129
130impl OverviewSection {
131 pub const ALL: [OverviewSection; 6] = [
133 OverviewSection::Cpu,
134 OverviewSection::Gpu,
135 OverviewSection::Memory,
136 OverviewSection::Processes,
137 OverviewSection::Network,
138 OverviewSection::Storage,
139 ];
140
141 pub fn label(self) -> &'static str {
143 match self {
144 OverviewSection::Cpu => "CPU",
145 OverviewSection::Gpu => "GPU",
146 OverviewSection::Memory => "Memory",
147 OverviewSection::Processes => "Processes",
148 OverviewSection::Network => "Network",
149 OverviewSection::Storage => "Storage",
150 }
151 }
152}
153
154#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
156#[serde(default, deny_unknown_fields)]
157pub struct OverviewConfig {
158 pub sections: Vec<OverviewSection>,
162 pub include_local: bool,
164 pub processes: usize,
167}
168
169impl Default for OverviewConfig {
170 fn default() -> Self {
171 Self {
172 sections: OverviewSection::ALL.to_vec(),
173 include_local: true,
174 processes: 10,
175 }
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
181#[serde(deny_unknown_fields)]
182pub struct HostConfig {
183 pub name: String,
185 #[serde(default, skip_serializing_if = "Option::is_none")]
188 pub ssh: Option<String>,
189 #[serde(default, skip_serializing_if = "Option::is_none")]
191 pub command: Option<String>,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub sections: Option<Vec<OverviewSection>>,
196}
197
198impl HostConfig {
199 pub fn from_destination(destination: &str) -> Self {
201 Self {
202 name: destination.to_owned(),
203 ssh: None,
204 command: None,
205 sections: None,
206 }
207 }
208
209 pub fn destination(&self) -> &str {
211 self.ssh.as_deref().unwrap_or(&self.name)
212 }
213}
214
215#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
217#[serde(default, deny_unknown_fields)]
218pub struct SensorConfig {
219 pub labels: BTreeMap<String, String>,
222 pub hidden: Vec<String>,
224}
225
226#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
228#[serde(default, deny_unknown_fields)]
229pub struct SmartConfig {
230 pub dir: PathBuf,
232}
233
234impl Default for SmartConfig {
235 fn default() -> Self {
236 Self {
237 dir: PathBuf::from(DEFAULT_SMART_DIR),
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum NotifyLevel {
246 Off,
248 Warning,
250 #[default]
252 Critical,
253}
254
255#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
257#[serde(default, deny_unknown_fields)]
258pub struct NotificationsConfig {
259 pub level: NotifyLevel,
260}
261
262#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
264#[serde(default, deny_unknown_fields)]
265pub struct LhmConfig {
266 pub url: String,
269}
270
271impl Default for LhmConfig {
272 fn default() -> Self {
273 Self {
274 url: DEFAULT_LHM_URL.to_owned(),
275 }
276 }
277}
278
279#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
281#[serde(default, deny_unknown_fields)]
282pub struct BtrfsConfig {
283 pub scrub_dir: PathBuf,
285}
286
287impl Default for BtrfsConfig {
288 fn default() -> Self {
289 Self {
290 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
291 }
292 }
293}
294
295impl Config {
296 pub fn default_path() -> Option<PathBuf> {
298 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
299 }
300
301 pub fn load(path: &Path) -> Result<Self, ConfigError> {
303 let text = match fs::read_to_string(path) {
304 Ok(text) => text,
305 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
306 Err(source) => {
307 return Err(ConfigError::Io {
308 path: path.to_owned(),
309 source,
310 });
311 }
312 };
313 toml::from_str(&text).map_err(|source| ConfigError::Parse {
314 path: path.to_owned(),
315 source,
316 })
317 }
318
319 pub fn load_default() -> Result<Self, ConfigError> {
322 match Self::default_path() {
323 Some(path) => Self::load(&path),
324 None => Ok(Self::default()),
325 }
326 }
327
328 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
333 let text = toml::to_string_pretty(self)?;
334 let io_err = |source| ConfigError::Io {
335 path: path.to_owned(),
336 source,
337 };
338 if let Some(parent) = path.parent() {
339 fs::create_dir_all(parent).map_err(io_err)?;
340 }
341 let tmp = path.with_extension("toml.tmp");
342 fs::write(&tmp, text).map_err(io_err)?;
343 fs::rename(&tmp, path).map_err(io_err)
344 }
345
346 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
353 let io_err = |source| ConfigError::Io {
354 path: path.to_owned(),
355 source,
356 };
357 let text = match fs::read_to_string(path) {
358 Ok(text) => text,
359 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
360 Err(err) => return Err(io_err(err)),
361 };
362 let mut document: toml_edit::DocumentMut =
363 text.parse().map_err(|source| ConfigError::Edit {
364 path: path.to_owned(),
365 source,
366 })?;
367 if hosts.is_empty() {
368 document.remove("hosts");
369 } else {
370 let mut tables = toml_edit::ArrayOfTables::new();
371 for host in hosts {
372 let mut table = toml_edit::Table::new();
373 table["name"] = toml_edit::value(host.name.as_str());
374 if let Some(ssh) = &host.ssh {
375 table["ssh"] = toml_edit::value(ssh.as_str());
376 }
377 if let Some(command) = &host.command {
378 table["command"] = toml_edit::value(command.as_str());
379 }
380 if let Some(sections) = &host.sections {
381 let mut array = toml_edit::Array::new();
382 for section in sections {
383 let name = toml::Value::try_from(section)?;
385 if let Some(name) = name.as_str() {
386 array.push(name);
387 }
388 }
389 table["sections"] = toml_edit::value(array);
390 }
391 tables.push(table);
392 }
393 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
394 }
395 let edited = document.to_string();
396 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
398 path: path.to_owned(),
399 source,
400 })?;
401 if let Some(parent) = path.parent() {
402 fs::create_dir_all(parent).map_err(io_err)?;
403 }
404 let tmp = path.with_extension("toml.tmp");
405 fs::write(&tmp, edited).map_err(io_err)?;
406 fs::rename(&tmp, path).map_err(io_err)
407 }
408
409 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
411 self.label_for(&fan.key).unwrap_or(&fan.label)
412 }
413
414 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
416 self.label_for(&temperature.key)
417 .unwrap_or(&temperature.label)
418 }
419
420 pub fn label_for(&self, key: &str) -> Option<&str> {
422 self.sensors.labels.get(key).map(String::as_str)
423 }
424
425 pub fn set_label(&mut self, key: &str, name: &str) {
427 let name = name.trim();
428 if name.is_empty() {
429 self.sensors.labels.remove(key);
430 } else {
431 self.sensors.labels.insert(key.to_owned(), name.to_owned());
432 }
433 }
434
435 pub fn is_hidden(&self, key: &str) -> bool {
437 self.sensors.hidden.iter().any(|k| k == key)
438 }
439}
440
441#[cfg(test)]
442mod tests {
443 use super::*;
444
445 #[test]
446 fn missing_file_gives_defaults() {
447 let tmp = tempfile::tempdir().unwrap();
448 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
449 assert_eq!(config, Config::default());
450 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
451 }
452
453 #[test]
454 fn parses_labels_hidden_and_smart_dir() {
455 let tmp = tempfile::tempdir().unwrap();
456 let path = tmp.path().join("config.toml");
457 fs::write(
458 &path,
459 r#"
460[sensors]
461labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
462hidden = ["nct6686:nct6683.2592/temp4"]
463
464[smart]
465dir = "/tmp/smart"
466
467[lhm]
468url = ""
469
470[notifications]
471level = "warning"
472"#,
473 )
474 .unwrap();
475 let config = Config::load(&path).unwrap();
476 let fan = Fan {
477 key: "nct6686:nct6683.2592/fan1".to_owned(),
478 label: "fan1".to_owned(),
479 rpm: 900,
480 };
481 let other = Fan {
482 key: "nct6686:nct6683.2592/fan2".to_owned(),
483 label: "fan2".to_owned(),
484 rpm: 0,
485 };
486 assert_eq!(config.fan_label(&fan), "CPU fan");
487 assert_eq!(config.fan_label(&other), "fan2");
489 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
490 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
491 assert_eq!(config.lhm.url, "");
492 assert_eq!(config.notifications.level, NotifyLevel::Warning);
493 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
494 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
495 }
496
497 #[test]
498 fn parses_hosts_and_overview() {
499 let config: Config = toml::from_str(
500 r#"
501[overview]
502sections = ["storage", "cpu"]
503include_local = false
504
505[[hosts]]
506name = "zefat"
507
508[[hosts]]
509name = "nas"
510ssh = "admin@nas.local"
511sections = ["storage"]
512"#,
513 )
514 .unwrap();
515 assert_eq!(
516 config.overview.sections,
517 vec![OverviewSection::Storage, OverviewSection::Cpu]
518 );
519 assert!(!config.overview.include_local);
520 assert_eq!(config.overview.processes, 10, "default kept");
521 assert_eq!(config.hosts.len(), 2);
522 assert_eq!(config.hosts[0].destination(), "zefat");
523 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
524 assert_eq!(
525 config.hosts[1].sections,
526 Some(vec![OverviewSection::Storage])
527 );
528 let defaults = Config::default();
530 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
531 assert!(defaults.overview.include_local);
532 }
533
534 #[test]
535 fn save_hosts_replaces_only_the_hosts() {
536 let tmp = tempfile::tempdir().unwrap();
537 let path = tmp.path().join("config.toml");
538 fs::write(
539 &path,
540 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
541 )
542 .unwrap();
543 let hosts = vec![
544 HostConfig::from_destination("zefat"),
545 HostConfig {
546 name: "nas".to_owned(),
547 ssh: Some("admin@nas.local".to_owned()),
548 command: Some("/opt/rigtop --agent".to_owned()),
549 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
550 },
551 ];
552 Config::save_hosts(&path, &hosts).unwrap();
553
554 let text = fs::read_to_string(&path).unwrap();
555 assert!(
556 text.contains("# my settings") && text.contains("# keep me"),
557 "{text}"
558 );
559 assert!(!text.contains("\"old\""), "{text}");
560 let config = Config::load(&path).unwrap();
561 assert_eq!(config.hosts, hosts);
562 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
563
564 Config::save_hosts(&path, &[]).unwrap();
566 assert!(Config::load(&path).unwrap().hosts.is_empty());
567 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
568 }
569
570 #[test]
571 fn save_hosts_creates_a_missing_file() {
572 let tmp = tempfile::tempdir().unwrap();
573 let path = tmp.path().join("new/rigtop/config.toml");
574 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
575 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
576 }
577
578 #[test]
579 fn save_hosts_refuses_to_touch_a_broken_file() {
580 let tmp = tempfile::tempdir().unwrap();
581 let path = tmp.path().join("config.toml");
582 fs::write(&path, "[sensors\n").unwrap();
583 let err = Config::save_hosts(&path, &[]).unwrap_err();
584 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
585 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
586 }
587
588 #[test]
589 fn unknown_fields_are_rejected() {
590 let tmp = tempfile::tempdir().unwrap();
591 let path = tmp.path().join("config.toml");
592 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
594 assert!(matches!(
595 Config::load(&path),
596 Err(ConfigError::Parse { .. })
597 ));
598 }
599
600 #[test]
601 fn save_and_reload_round_trip() {
602 let tmp = tempfile::tempdir().unwrap();
603 let path = tmp.path().join("nested/rigtop/config.toml");
604 let mut config = Config::default();
605 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
606 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
607 config.set_label("nct6686:nct6683.2592/fan1", "");
608 config.save(&path).unwrap();
609
610 let back = Config::load(&path).unwrap();
611 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
612 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
614 assert!(!path.with_extension("toml.tmp").exists());
615 }
616}