1use std::collections::BTreeMap;
68use std::fs;
69use std::io;
70use std::path::{Path, PathBuf};
71
72use serde::{Deserialize, Serialize};
73
74use crate::snapshot::{Fan, Temperature};
75
76pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
78pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
80pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
82
83#[derive(Debug, thiserror::Error)]
85pub enum ConfigError {
86 #[error("cannot access {path}: {source}")]
88 Io {
89 path: PathBuf,
91 source: io::Error,
93 },
94 #[error("invalid config {path}: {source}")]
96 Parse {
97 path: PathBuf,
99 source: toml::de::Error,
101 },
102 #[error("cannot serialize config: {0}")]
104 Serialize(#[from] toml::ser::Error),
105 #[error("cannot edit {path}: {source}")]
107 Edit {
108 path: PathBuf,
110 source: toml_edit::TomlError,
112 },
113}
114
115#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
117#[serde(default, deny_unknown_fields)]
118pub struct Config {
119 pub sensors: SensorConfig,
121 pub smart: SmartConfig,
123 pub btrfs: BtrfsConfig,
125 pub lhm: LhmConfig,
128 pub overview: OverviewConfig,
130 pub alerts: AlertsConfig,
132 pub notifications: NotificationsConfig,
134 pub hosts: Vec<HostConfig>,
136 pub widgets: Vec<WidgetConfig>,
138}
139
140#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
142#[serde(rename_all = "lowercase")]
143pub enum BuiltinWidget {
144 Clock,
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
151#[serde(default, deny_unknown_fields)]
152pub struct WidgetConfig {
153 pub name: String,
155 pub command: Option<String>,
157 pub builtin: Option<BuiltinWidget>,
159 pub interval_secs: u64,
161 pub timeout_secs: u64,
163}
164
165impl Default for WidgetConfig {
166 fn default() -> Self {
167 Self {
168 name: String::new(),
169 command: None,
170 builtin: None,
171 interval_secs: 5,
172 timeout_secs: 10,
173 }
174 }
175}
176
177#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
179#[serde(rename_all = "lowercase")]
180pub enum OverviewSection {
181 Cpu,
182 Gpu,
183 Memory,
184 Processes,
185 Network,
186 Storage,
187}
188
189impl OverviewSection {
190 pub const ALL: [OverviewSection; 6] = [
192 OverviewSection::Cpu,
193 OverviewSection::Gpu,
194 OverviewSection::Memory,
195 OverviewSection::Processes,
196 OverviewSection::Network,
197 OverviewSection::Storage,
198 ];
199
200 pub fn label(self) -> &'static str {
202 match self {
203 OverviewSection::Cpu => "CPU",
204 OverviewSection::Gpu => "GPU",
205 OverviewSection::Memory => "Memory",
206 OverviewSection::Processes => "Processes",
207 OverviewSection::Network => "Network",
208 OverviewSection::Storage => "Storage",
209 }
210 }
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
215#[serde(default, deny_unknown_fields)]
216pub struct OverviewConfig {
217 pub sections: Vec<OverviewSection>,
221 pub include_local: bool,
223 pub processes: usize,
226}
227
228impl Default for OverviewConfig {
229 fn default() -> Self {
230 Self {
231 sections: OverviewSection::ALL.to_vec(),
232 include_local: true,
233 processes: 10,
234 }
235 }
236}
237
238#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240#[serde(deny_unknown_fields)]
241pub struct HostConfig {
242 pub name: String,
244 #[serde(default, skip_serializing_if = "Option::is_none")]
247 pub ssh: Option<String>,
248 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub command: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub sections: Option<Vec<OverviewSection>>,
255}
256
257impl HostConfig {
258 pub fn from_destination(destination: &str) -> Self {
260 Self {
261 name: destination.to_owned(),
262 ssh: None,
263 command: None,
264 sections: None,
265 }
266 }
267
268 pub fn destination(&self) -> &str {
270 self.ssh.as_deref().unwrap_or(&self.name)
271 }
272}
273
274#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
276#[serde(default, deny_unknown_fields)]
277pub struct SensorConfig {
278 pub labels: BTreeMap<String, String>,
281 pub hidden: Vec<String>,
283}
284
285#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
287#[serde(default, deny_unknown_fields)]
288pub struct SmartConfig {
289 pub dir: PathBuf,
291}
292
293impl Default for SmartConfig {
294 fn default() -> Self {
295 Self {
296 dir: PathBuf::from(DEFAULT_SMART_DIR),
297 }
298 }
299}
300
301#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
303#[serde(rename_all = "lowercase")]
304pub enum NotifyLevel {
305 Off,
307 Warning,
309 #[default]
311 Critical,
312}
313
314#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
318#[serde(default, deny_unknown_fields)]
319pub struct AlertsConfig {
320 pub filesystem_warning_percent: f64,
322 pub filesystem_critical_percent: f64,
324 pub cpu_warning_celsius: f32,
327 pub gpu_warning_celsius: f32,
329 pub drive_warning_celsius: f32,
331}
332
333impl Default for AlertsConfig {
334 fn default() -> Self {
335 Self {
336 filesystem_warning_percent: 90.0,
337 filesystem_critical_percent: 97.0,
338 cpu_warning_celsius: 96.0,
339 gpu_warning_celsius: 85.0,
340 drive_warning_celsius: 70.0,
341 }
342 }
343}
344
345#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
347#[serde(default, deny_unknown_fields)]
348pub struct NotificationsConfig {
349 pub level: NotifyLevel,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
354#[serde(default, deny_unknown_fields)]
355pub struct LhmConfig {
356 pub url: String,
359}
360
361impl Default for LhmConfig {
362 fn default() -> Self {
363 Self {
364 url: DEFAULT_LHM_URL.to_owned(),
365 }
366 }
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
371#[serde(default, deny_unknown_fields)]
372pub struct BtrfsConfig {
373 pub scrub_dir: PathBuf,
375}
376
377impl Default for BtrfsConfig {
378 fn default() -> Self {
379 Self {
380 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
381 }
382 }
383}
384
385impl Config {
386 pub fn default_path() -> Option<PathBuf> {
388 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
389 }
390
391 pub fn load(path: &Path) -> Result<Self, ConfigError> {
393 let text = match fs::read_to_string(path) {
394 Ok(text) => text,
395 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
396 Err(source) => {
397 return Err(ConfigError::Io {
398 path: path.to_owned(),
399 source,
400 });
401 }
402 };
403 toml::from_str(&text).map_err(|source| ConfigError::Parse {
404 path: path.to_owned(),
405 source,
406 })
407 }
408
409 pub fn load_default() -> Result<Self, ConfigError> {
412 match Self::default_path() {
413 Some(path) => Self::load(&path),
414 None => Ok(Self::default()),
415 }
416 }
417
418 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
423 let text = toml::to_string_pretty(self)?;
424 let io_err = |source| ConfigError::Io {
425 path: path.to_owned(),
426 source,
427 };
428 if let Some(parent) = path.parent() {
429 fs::create_dir_all(parent).map_err(io_err)?;
430 }
431 let tmp = path.with_extension("toml.tmp");
432 fs::write(&tmp, text).map_err(io_err)?;
433 fs::rename(&tmp, path).map_err(io_err)
434 }
435
436 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
443 edit_file(path, |document| {
444 if hosts.is_empty() {
445 document.remove("hosts");
446 return Ok(());
447 }
448 let mut tables = toml_edit::ArrayOfTables::new();
449 for host in hosts {
450 let mut table = toml_edit::Table::new();
451 table["name"] = toml_edit::value(host.name.as_str());
452 if let Some(ssh) = &host.ssh {
453 table["ssh"] = toml_edit::value(ssh.as_str());
454 }
455 if let Some(command) = &host.command {
456 table["command"] = toml_edit::value(command.as_str());
457 }
458 if let Some(sections) = &host.sections {
459 let mut array = toml_edit::Array::new();
460 for section in sections {
461 let name = toml::Value::try_from(section)?;
463 if let Some(name) = name.as_str() {
464 array.push(name);
465 }
466 }
467 table["sections"] = toml_edit::value(array);
468 }
469 tables.push(table);
470 }
471 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
472 Ok(())
473 })
474 }
475
476 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
483 edit_file(path, |document| {
484 if !document.contains_key("sensors") {
485 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
486 return Ok(());
487 }
488 document["sensors"] = toml_edit::table();
489 }
490 let Some(table) = document["sensors"].as_table_like_mut() else {
491 return Ok(());
493 };
494 if sensors.hidden.is_empty() {
495 table.remove("hidden");
496 } else {
497 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
498 table.insert("hidden", toml_edit::value(hidden));
499 }
500 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
501 if sensors.labels.is_empty() {
502 table.remove("labels");
503 } else if inline {
504 let labels: toml_edit::InlineTable = sensors
505 .labels
506 .iter()
507 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
508 .collect();
509 table.insert("labels", toml_edit::value(labels));
510 } else {
511 let mut labels = toml_edit::Table::new();
512 for (key, name) in &sensors.labels {
513 labels[key.as_str()] = toml_edit::value(name.as_str());
514 }
515 table.insert("labels", toml_edit::Item::Table(labels));
516 }
517 if table.is_empty() {
518 document.remove("sensors");
519 }
520 Ok(())
521 })
522 }
523
524 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
526 self.label_for(&fan.key).unwrap_or(&fan.label)
527 }
528
529 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
531 self.label_for(&temperature.key)
532 .unwrap_or(&temperature.label)
533 }
534
535 pub fn label_for(&self, key: &str) -> Option<&str> {
537 self.sensors.labels.get(key).map(String::as_str)
538 }
539
540 pub fn set_label(&mut self, key: &str, name: &str) {
542 let name = name.trim();
543 if name.is_empty() {
544 self.sensors.labels.remove(key);
545 } else {
546 self.sensors.labels.insert(key.to_owned(), name.to_owned());
547 }
548 }
549
550 pub fn is_hidden(&self, key: &str) -> bool {
552 self.sensors.hidden.iter().any(|k| k == key)
553 }
554}
555
556fn edit_file(
561 path: &Path,
562 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
563) -> Result<(), ConfigError> {
564 let io_err = |source| ConfigError::Io {
565 path: path.to_owned(),
566 source,
567 };
568 let text = match fs::read_to_string(path) {
569 Ok(text) => text,
570 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
571 Err(err) => return Err(io_err(err)),
572 };
573 let mut document: toml_edit::DocumentMut =
574 text.parse().map_err(|source| ConfigError::Edit {
575 path: path.to_owned(),
576 source,
577 })?;
578 edit(&mut document)?;
579 let edited = document.to_string();
580 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
582 path: path.to_owned(),
583 source,
584 })?;
585 if let Some(parent) = path.parent() {
586 fs::create_dir_all(parent).map_err(io_err)?;
587 }
588 let tmp = path.with_extension("toml.tmp");
589 fs::write(&tmp, edited).map_err(io_err)?;
590 fs::rename(&tmp, path).map_err(io_err)
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596
597 #[test]
598 fn missing_file_gives_defaults() {
599 let tmp = tempfile::tempdir().unwrap();
600 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
601 assert_eq!(config, Config::default());
602 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
603 }
604
605 #[test]
606 fn parses_labels_hidden_and_smart_dir() {
607 let tmp = tempfile::tempdir().unwrap();
608 let path = tmp.path().join("config.toml");
609 fs::write(
610 &path,
611 r#"
612[sensors]
613labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
614hidden = ["nct6686:nct6683.2592/temp4"]
615
616[smart]
617dir = "/tmp/smart"
618
619[lhm]
620url = ""
621
622[notifications]
623level = "warning"
624
625[alerts]
626drive_warning_celsius = 65
627cpu_warning_celsius = 0
628"#,
629 )
630 .unwrap();
631 let config = Config::load(&path).unwrap();
632 let fan = Fan {
633 key: "nct6686:nct6683.2592/fan1".to_owned(),
634 label: "fan1".to_owned(),
635 rpm: 900,
636 };
637 let other = Fan {
638 key: "nct6686:nct6683.2592/fan2".to_owned(),
639 label: "fan2".to_owned(),
640 rpm: 0,
641 };
642 assert_eq!(config.fan_label(&fan), "CPU fan");
643 assert_eq!(config.fan_label(&other), "fan2");
645 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
646 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
647 assert_eq!(config.lhm.url, "");
648 assert_eq!(config.notifications.level, NotifyLevel::Warning);
649 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
650 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
651 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
652 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
653 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
655 }
656
657 #[test]
658 fn parses_hosts_and_overview() {
659 let config: Config = toml::from_str(
660 r#"
661[overview]
662sections = ["storage", "cpu"]
663include_local = false
664
665[[hosts]]
666name = "zefat"
667
668[[hosts]]
669name = "nas"
670ssh = "admin@nas.local"
671sections = ["storage"]
672
673[[widgets]]
674name = "Clock"
675builtin = "clock"
676
677[[widgets]]
678name = "Rates"
679command = "rates.sh"
680interval_secs = 60
681"#,
682 )
683 .unwrap();
684 assert_eq!(
685 config.overview.sections,
686 vec![OverviewSection::Storage, OverviewSection::Cpu]
687 );
688 assert!(!config.overview.include_local);
689 assert_eq!(config.overview.processes, 10, "default kept");
690 assert_eq!(config.hosts.len(), 2);
691 assert_eq!(config.hosts[0].destination(), "zefat");
692 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
693 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
694 assert_eq!(config.widgets[1].interval_secs, 60);
695 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
696 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
697 assert_eq!(
698 config.hosts[1].sections,
699 Some(vec![OverviewSection::Storage])
700 );
701 let defaults = Config::default();
703 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
704 assert!(defaults.overview.include_local);
705 }
706
707 #[test]
708 fn save_hosts_replaces_only_the_hosts() {
709 let tmp = tempfile::tempdir().unwrap();
710 let path = tmp.path().join("config.toml");
711 fs::write(
712 &path,
713 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
714 )
715 .unwrap();
716 let hosts = vec![
717 HostConfig::from_destination("zefat"),
718 HostConfig {
719 name: "nas".to_owned(),
720 ssh: Some("admin@nas.local".to_owned()),
721 command: Some("/opt/rigtop --agent".to_owned()),
722 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
723 },
724 ];
725 Config::save_hosts(&path, &hosts).unwrap();
726
727 let text = fs::read_to_string(&path).unwrap();
728 assert!(
729 text.contains("# my settings") && text.contains("# keep me"),
730 "{text}"
731 );
732 assert!(!text.contains("\"old\""), "{text}");
733 let config = Config::load(&path).unwrap();
734 assert_eq!(config.hosts, hosts);
735 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
736
737 Config::save_hosts(&path, &[]).unwrap();
739 assert!(Config::load(&path).unwrap().hosts.is_empty());
740 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
741 }
742
743 #[test]
744 fn save_sensors_replaces_only_names_and_hidden_sensors() {
745 let tmp = tempfile::tempdir().unwrap();
746 let path = tmp.path().join("config.toml");
747 fs::write(
748 &path,
749 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
750 )
751 .unwrap();
752 let mut sensors = SensorConfig::default();
753 sensors
754 .labels
755 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
756 sensors
757 .labels
758 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
759 sensors.hidden.push("b:c.1/temp4".to_owned());
760 Config::save_sensors(&path, &sensors).unwrap();
761
762 let text = fs::read_to_string(&path).unwrap();
763 assert!(text.contains("# my settings"), "{text}");
764 assert!(text.contains("labels = {"), "{text}");
766 let config = Config::load(&path).unwrap();
767 assert_eq!(config.sensors, sensors);
768 assert_eq!(config.hosts[0].name, "zefat");
769
770 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
772 let text = fs::read_to_string(&path).unwrap();
773 assert!(!text.contains("[sensors]"), "{text}");
774 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
775
776 let fresh = tmp.path().join("new.toml");
778 Config::save_sensors(&fresh, &sensors).unwrap();
779 let text = fs::read_to_string(&fresh).unwrap();
780 assert!(text.contains("[sensors.labels]"), "{text}");
781 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
782 }
783
784 #[test]
785 fn save_hosts_creates_a_missing_file() {
786 let tmp = tempfile::tempdir().unwrap();
787 let path = tmp.path().join("new/rigtop/config.toml");
788 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
789 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
790 }
791
792 #[test]
793 fn save_hosts_refuses_to_touch_a_broken_file() {
794 let tmp = tempfile::tempdir().unwrap();
795 let path = tmp.path().join("config.toml");
796 fs::write(&path, "[sensors\n").unwrap();
797 let err = Config::save_hosts(&path, &[]).unwrap_err();
798 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
799 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
800 }
801
802 #[test]
803 fn unknown_fields_are_rejected() {
804 let tmp = tempfile::tempdir().unwrap();
805 let path = tmp.path().join("config.toml");
806 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
808 assert!(matches!(
809 Config::load(&path),
810 Err(ConfigError::Parse { .. })
811 ));
812 }
813
814 #[test]
815 fn save_and_reload_round_trip() {
816 let tmp = tempfile::tempdir().unwrap();
817 let path = tmp.path().join("nested/rigtop/config.toml");
818 let mut config = Config::default();
819 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
820 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
821 config.set_label("nct6686:nct6683.2592/fan1", "");
822 config.save(&path).unwrap();
823
824 let back = Config::load(&path).unwrap();
825 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
826 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
828 assert!(!path.with_extension("toml.tmp").exists());
829 }
830}