1use std::collections::BTreeMap;
81use std::fs;
82use std::io;
83use std::path::{Path, PathBuf};
84use std::time::{Duration, Instant, SystemTime};
85
86use serde::{Deserialize, Serialize};
87
88use crate::snapshot::{Fan, Temperature};
89
90pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
92pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
94pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
96
97#[derive(Debug, thiserror::Error)]
99pub enum ConfigError {
100 #[error("cannot access {path}: {source}")]
102 Io {
103 path: PathBuf,
105 source: io::Error,
107 },
108 #[error("invalid config {path}: {source}")]
110 Parse {
111 path: PathBuf,
113 source: toml::de::Error,
115 },
116 #[error("cannot serialize config: {0}")]
118 Serialize(#[from] toml::ser::Error),
119 #[error("cannot edit {path}: {source}")]
121 Edit {
122 path: PathBuf,
124 source: toml_edit::TomlError,
126 },
127}
128
129#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
131#[serde(default, deny_unknown_fields)]
132pub struct Config {
133 pub sensors: SensorConfig,
135 pub smart: SmartConfig,
137 pub btrfs: BtrfsConfig,
139 pub lhm: LhmConfig,
142 pub overview: OverviewConfig,
144 pub tui: TuiConfig,
146 pub alerts: AlertsConfig,
148 pub notifications: NotificationsConfig,
150 pub updates: UpdatesConfig,
152 pub hosts: Vec<HostConfig>,
154 pub widgets: Vec<WidgetConfig>,
156}
157
158#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
160#[serde(rename_all = "lowercase")]
161pub enum BuiltinWidget {
162 Clock,
164}
165
166#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
169#[serde(default, deny_unknown_fields)]
170pub struct WidgetConfig {
171 pub name: String,
173 pub command: Option<String>,
175 pub builtin: Option<BuiltinWidget>,
177 pub interval_secs: u64,
179 pub timeout_secs: u64,
181}
182
183impl Default for WidgetConfig {
184 fn default() -> Self {
185 Self {
186 name: String::new(),
187 command: None,
188 builtin: None,
189 interval_secs: 5,
190 timeout_secs: 10,
191 }
192 }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
197#[serde(rename_all = "lowercase")]
198pub enum OverviewSection {
199 Cpu,
200 Gpu,
201 Memory,
202 Processes,
203 Network,
204 Storage,
205}
206
207impl OverviewSection {
208 pub const ALL: [OverviewSection; 6] = [
210 OverviewSection::Cpu,
211 OverviewSection::Gpu,
212 OverviewSection::Memory,
213 OverviewSection::Processes,
214 OverviewSection::Network,
215 OverviewSection::Storage,
216 ];
217
218 pub fn label(self) -> &'static str {
220 match self {
221 OverviewSection::Cpu => "CPU",
222 OverviewSection::Gpu => "GPU",
223 OverviewSection::Memory => "Memory",
224 OverviewSection::Processes => "Processes",
225 OverviewSection::Network => "Network",
226 OverviewSection::Storage => "Storage",
227 }
228 }
229}
230
231#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
233#[serde(default, deny_unknown_fields)]
234pub struct TuiConfig {
235 pub hidden_panels: Vec<OverviewSection>,
238 pub show_widgets: bool,
240}
241
242impl Default for TuiConfig {
243 fn default() -> Self {
244 Self {
245 hidden_panels: Vec::new(),
246 show_widgets: true,
247 }
248 }
249}
250
251#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
253#[serde(default, deny_unknown_fields)]
254pub struct OverviewConfig {
255 pub sections: Vec<OverviewSection>,
259 pub include_local: bool,
261 pub processes: usize,
264}
265
266impl Default for OverviewConfig {
267 fn default() -> Self {
268 Self {
269 sections: OverviewSection::ALL.to_vec(),
270 include_local: true,
271 processes: 10,
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(deny_unknown_fields)]
279pub struct HostConfig {
280 pub name: String,
282 #[serde(default, skip_serializing_if = "Option::is_none")]
285 pub ssh: Option<String>,
286 #[serde(default, skip_serializing_if = "Option::is_none")]
288 pub command: Option<String>,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
292 pub sections: Option<Vec<OverviewSection>>,
293}
294
295impl HostConfig {
296 pub fn from_destination(destination: &str) -> Self {
298 Self {
299 name: destination.to_owned(),
300 ssh: None,
301 command: None,
302 sections: None,
303 }
304 }
305
306 pub fn destination(&self) -> &str {
308 self.ssh.as_deref().unwrap_or(&self.name)
309 }
310}
311
312#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
314#[serde(default, deny_unknown_fields)]
315pub struct SensorConfig {
316 pub labels: BTreeMap<String, String>,
319 pub hidden: Vec<String>,
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325#[serde(default, deny_unknown_fields)]
326pub struct SmartConfig {
327 pub dir: PathBuf,
329}
330
331impl Default for SmartConfig {
332 fn default() -> Self {
333 Self {
334 dir: PathBuf::from(DEFAULT_SMART_DIR),
335 }
336 }
337}
338
339#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
341#[serde(rename_all = "lowercase")]
342pub enum NotifyLevel {
343 Off,
345 Warning,
347 #[default]
349 Critical,
350}
351
352#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct AlertsConfig {
358 pub filesystem_warning_percent: f64,
360 pub filesystem_critical_percent: f64,
362 pub cpu_warning_celsius: f32,
365 pub gpu_warning_celsius: f32,
367 pub drive_warning_celsius: f32,
369 pub ignored_units: Vec<String>,
372}
373
374impl Default for AlertsConfig {
375 fn default() -> Self {
376 Self {
377 filesystem_warning_percent: 90.0,
378 filesystem_critical_percent: 97.0,
379 cpu_warning_celsius: 96.0,
380 gpu_warning_celsius: 85.0,
381 drive_warning_celsius: 70.0,
382 ignored_units: Vec::new(),
383 }
384 }
385}
386
387#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
389#[serde(default, deny_unknown_fields)]
390pub struct NotificationsConfig {
391 pub level: NotifyLevel,
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
396#[serde(default, deny_unknown_fields)]
397pub struct UpdatesConfig {
398 pub check: bool,
400 #[serde(with = "duration_text")]
403 pub interval: Duration,
404 pub notify: bool,
407}
408
409impl Default for UpdatesConfig {
410 fn default() -> Self {
411 Self {
412 check: true,
413 interval: Duration::from_secs(3600),
414 notify: true,
415 }
416 }
417}
418
419impl UpdatesConfig {
420 pub fn check_interval(&self) -> Duration {
422 self.interval.max(Duration::from_secs(60))
423 }
424}
425
426mod duration_text {
429 use std::time::Duration;
430
431 use serde::de::Error;
432 use serde::{Deserialize, Deserializer, Serializer};
433
434 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
435
436 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
437 serializer.serialize_str(&format(*duration))
438 }
439
440 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
441 let text = String::deserialize(deserializer)?;
442 parse(&text).ok_or_else(|| {
443 D::Error::custom(format!(
444 "invalid duration {text:?}: write a number and s, m, h or d, \
445 e.g. \"30m\", \"6h\" or \"1h30m\""
446 ))
447 })
448 }
449
450 pub fn parse(text: &str) -> Option<Duration> {
452 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
453 if text.is_empty() {
454 return None;
455 }
456 let mut total = 0u64;
457 let mut number = String::new();
458 for c in text.chars() {
459 if c.is_ascii_digit() {
460 number.push(c);
461 continue;
462 }
463 let (_, secs) = UNITS
464 .iter()
465 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
466 let value: u64 = number.parse().ok()?;
467 total = total.checked_add(value.checked_mul(*secs)?)?;
468 number.clear();
469 }
470 number.is_empty().then(|| Duration::from_secs(total))
472 }
473
474 pub fn format(duration: Duration) -> String {
476 let mut secs = duration.as_secs();
477 if secs == 0 {
478 return "0s".to_owned();
479 }
480 let mut text = String::new();
481 for (unit, size) in UNITS {
482 if secs >= size {
483 text.push_str(&format!("{}{unit}", secs / size));
484 secs %= size;
485 }
486 }
487 text
488 }
489}
490
491#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493#[serde(default, deny_unknown_fields)]
494pub struct LhmConfig {
495 pub url: String,
498}
499
500impl Default for LhmConfig {
501 fn default() -> Self {
502 Self {
503 url: DEFAULT_LHM_URL.to_owned(),
504 }
505 }
506}
507
508#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
510#[serde(default, deny_unknown_fields)]
511pub struct BtrfsConfig {
512 pub scrub_dir: PathBuf,
514}
515
516impl Default for BtrfsConfig {
517 fn default() -> Self {
518 Self {
519 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
520 }
521 }
522}
523
524impl Config {
525 pub fn default_path() -> Option<PathBuf> {
527 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
528 }
529
530 pub fn load(path: &Path) -> Result<Self, ConfigError> {
532 let text = match fs::read_to_string(path) {
533 Ok(text) => text,
534 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
535 Err(source) => {
536 return Err(ConfigError::Io {
537 path: path.to_owned(),
538 source,
539 });
540 }
541 };
542 toml::from_str(&text).map_err(|source| ConfigError::Parse {
543 path: path.to_owned(),
544 source,
545 })
546 }
547
548 pub fn load_default() -> Result<Self, ConfigError> {
551 match Self::default_path() {
552 Some(path) => Self::load(&path),
553 None => Ok(Self::default()),
554 }
555 }
556
557 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
562 let text = toml::to_string_pretty(self)?;
563 let io_err = |source| ConfigError::Io {
564 path: path.to_owned(),
565 source,
566 };
567 if let Some(parent) = path.parent() {
568 fs::create_dir_all(parent).map_err(io_err)?;
569 }
570 let tmp = path.with_extension("toml.tmp");
571 fs::write(&tmp, text).map_err(io_err)?;
572 fs::rename(&tmp, path).map_err(io_err)
573 }
574
575 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
582 edit_file(path, |document| {
583 if hosts.is_empty() {
584 document.remove("hosts");
585 return Ok(());
586 }
587 let mut tables = toml_edit::ArrayOfTables::new();
588 for host in hosts {
589 let mut table = toml_edit::Table::new();
590 table["name"] = toml_edit::value(host.name.as_str());
591 if let Some(ssh) = &host.ssh {
592 table["ssh"] = toml_edit::value(ssh.as_str());
593 }
594 if let Some(command) = &host.command {
595 table["command"] = toml_edit::value(command.as_str());
596 }
597 if let Some(sections) = &host.sections {
598 let mut array = toml_edit::Array::new();
599 for section in sections {
600 let name = toml::Value::try_from(section)?;
602 if let Some(name) = name.as_str() {
603 array.push(name);
604 }
605 }
606 table["sections"] = toml_edit::value(array);
607 }
608 tables.push(table);
609 }
610 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
611 Ok(())
612 })
613 }
614
615 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
624 edit_file(path, |document| {
625 if !document.contains_key("tui") {
626 if *tui == TuiConfig::default() {
627 return Ok(());
628 }
629 document["tui"] = toml_edit::table();
630 }
631 let Some(table) = document["tui"].as_table_like_mut() else {
632 return Ok(());
634 };
635 if tui.hidden_panels.is_empty() {
636 table.remove("hidden_panels");
637 } else {
638 let names: toml_edit::Array = tui
639 .hidden_panels
640 .iter()
641 .filter_map(|section| {
642 serde_json::to_value(section)
643 .ok()
644 .and_then(|v| v.as_str().map(str::to_owned))
645 })
646 .collect();
647 table.insert("hidden_panels", toml_edit::value(names));
648 }
649 if tui.show_widgets {
650 table.remove("show_widgets");
651 } else {
652 table.insert("show_widgets", toml_edit::value(false));
653 }
654 Ok(())
655 })
656 }
657
658 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
659 edit_file(path, |document| {
660 if !document.contains_key("sensors") {
661 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
662 return Ok(());
663 }
664 document["sensors"] = toml_edit::table();
665 }
666 let Some(table) = document["sensors"].as_table_like_mut() else {
667 return Ok(());
669 };
670 if sensors.hidden.is_empty() {
671 table.remove("hidden");
672 } else {
673 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
674 table.insert("hidden", toml_edit::value(hidden));
675 }
676 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
677 if sensors.labels.is_empty() {
678 table.remove("labels");
679 } else if inline {
680 let labels: toml_edit::InlineTable = sensors
681 .labels
682 .iter()
683 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
684 .collect();
685 table.insert("labels", toml_edit::value(labels));
686 } else {
687 let mut labels = toml_edit::Table::new();
688 for (key, name) in &sensors.labels {
689 labels[key.as_str()] = toml_edit::value(name.as_str());
690 }
691 table.insert("labels", toml_edit::Item::Table(labels));
692 }
693 if table.is_empty() {
694 document.remove("sensors");
695 }
696 Ok(())
697 })
698 }
699
700 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
702 self.label_for(&fan.key).unwrap_or(&fan.label)
703 }
704
705 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
707 self.label_for(&temperature.key)
708 .unwrap_or(&temperature.label)
709 }
710
711 pub fn label_for(&self, key: &str) -> Option<&str> {
713 self.sensors.labels.get(key).map(String::as_str)
714 }
715
716 pub fn set_label(&mut self, key: &str, name: &str) {
718 let name = name.trim();
719 if name.is_empty() {
720 self.sensors.labels.remove(key);
721 } else {
722 self.sensors.labels.insert(key.to_owned(), name.to_owned());
723 }
724 }
725
726 pub fn is_hidden(&self, key: &str) -> bool {
728 self.sensors.hidden.iter().any(|k| k == key)
729 }
730}
731
732fn edit_file(
737 path: &Path,
738 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
739) -> Result<(), ConfigError> {
740 let io_err = |source| ConfigError::Io {
741 path: path.to_owned(),
742 source,
743 };
744 let text = match fs::read_to_string(path) {
745 Ok(text) => text,
746 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
747 Err(err) => return Err(io_err(err)),
748 };
749 let mut document: toml_edit::DocumentMut =
750 text.parse().map_err(|source| ConfigError::Edit {
751 path: path.to_owned(),
752 source,
753 })?;
754 edit(&mut document)?;
755 let edited = document.to_string();
756 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
758 path: path.to_owned(),
759 source,
760 })?;
761 if let Some(parent) = path.parent() {
762 fs::create_dir_all(parent).map_err(io_err)?;
763 }
764 let tmp = path.with_extension("toml.tmp");
765 fs::write(&tmp, edited).map_err(io_err)?;
766 fs::rename(&tmp, path).map_err(io_err)
767}
768
769const WATCH_INTERVAL: Duration = Duration::from_secs(1);
771
772pub struct ConfigWatcher {
775 path: PathBuf,
776 stamp: Option<(Option<SystemTime>, u64)>,
778 last_check: Instant,
779 interval: Duration,
780}
781
782impl ConfigWatcher {
783 pub fn new(path: PathBuf) -> Self {
785 Self {
786 stamp: file_stamp(&path),
787 path,
788 last_check: Instant::now(),
789 interval: WATCH_INTERVAL,
790 }
791 }
792
793 pub fn path(&self) -> &Path {
795 &self.path
796 }
797
798 pub fn mark_seen(&mut self) {
801 self.stamp = file_stamp(&self.path);
802 }
803
804 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
809 if self.last_check.elapsed() < self.interval {
810 return None;
811 }
812 self.last_check = Instant::now();
813 let stamp = file_stamp(&self.path);
814 if stamp == self.stamp {
815 return None;
816 }
817 self.stamp = stamp;
818 stamp?;
819 Some(Config::load(&self.path))
820 }
821}
822
823fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
825 let metadata = fs::metadata(path).ok()?;
826 Some((metadata.modified().ok(), metadata.len()))
827}
828
829#[cfg(test)]
830mod tests {
831 use super::*;
832
833 #[test]
834 fn hidden_panels_are_saved_and_loaded() {
835 let tmp = tempfile::tempdir().unwrap();
836 let path = tmp.path().join("config.toml");
837 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
838 let tui = TuiConfig {
839 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
840 show_widgets: false,
841 };
842 Config::save_tui(&path, &tui).unwrap();
843 let text = fs::read_to_string(&path).unwrap();
844 assert!(text.starts_with("# mine"), "{text}");
845 let config = Config::load(&path).unwrap();
846 assert_eq!(
847 config.tui.hidden_panels,
848 vec![OverviewSection::Gpu, OverviewSection::Network]
849 );
850 assert!(!config.tui.show_widgets);
851 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
852 Config::save_tui(&path, &TuiConfig::default()).unwrap();
853 let config = Config::load(&path).unwrap();
854 assert_eq!(config.tui, TuiConfig::default());
855 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
856 }
857
858 #[test]
859 fn update_checks_can_be_tuned_or_turned_off() {
860 let config: Config =
861 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
862 assert!(config.updates.check);
863 assert_eq!(
864 config.updates.check_interval(),
865 Duration::from_secs(6 * 3600)
866 );
867 assert!(!config.updates.notify);
868 let config: Config =
869 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
870 assert!(!config.updates.check);
871 assert_eq!(
872 config.updates.check_interval(),
873 Duration::from_secs(60),
874 "at least a minute"
875 );
876 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
877 assert!(error.to_string().contains("invalid duration"), "{error}");
878 }
879
880 #[test]
881 fn durations_read_and_write_as_text() {
882 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
883 assert_eq!(duration_text::parse("30m"), minutes(30));
884 assert_eq!(duration_text::parse("1h30m"), minutes(90));
885 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
886 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
887 assert_eq!(duration_text::parse("2H"), minutes(120));
888 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
889 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
890 }
891 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
892 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
893 let text = toml::to_string(&Config::default()).unwrap();
895 assert!(text.contains("interval = \"1h\""), "{text}");
896 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
897 }
898
899 #[test]
900 fn watcher_reports_edits_once() {
901 let tmp = tempfile::tempdir().unwrap();
902 let path = tmp.path().join("config.toml");
903 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
904 let mut watcher = ConfigWatcher::new(path.clone());
905 watcher.interval = Duration::ZERO;
906 assert!(
907 watcher.poll().is_none(),
908 "the initial file counts as loaded"
909 );
910
911 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
913 let config = watcher.poll().unwrap().unwrap();
914 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
915 assert!(watcher.poll().is_none(), "reported once");
916
917 fs::write(&path, "[alerts\n").unwrap();
918 assert!(
919 watcher.poll().unwrap().is_err(),
920 "a broken file is an error"
921 );
922 fs::remove_file(&path).unwrap();
923 assert!(
924 watcher.poll().is_none(),
925 "removing the file changes nothing"
926 );
927 }
928
929 #[test]
930 fn missing_file_gives_defaults() {
931 let tmp = tempfile::tempdir().unwrap();
932 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
933 assert_eq!(config, Config::default());
934 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
935 }
936
937 #[test]
938 fn parses_labels_hidden_and_smart_dir() {
939 let tmp = tempfile::tempdir().unwrap();
940 let path = tmp.path().join("config.toml");
941 fs::write(
942 &path,
943 r#"
944[sensors]
945labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
946hidden = ["nct6686:nct6683.2592/temp4"]
947
948[smart]
949dir = "/tmp/smart"
950
951[lhm]
952url = ""
953
954[notifications]
955level = "warning"
956
957[alerts]
958drive_warning_celsius = 65
959cpu_warning_celsius = 0
960"#,
961 )
962 .unwrap();
963 let config = Config::load(&path).unwrap();
964 let fan = Fan {
965 key: "nct6686:nct6683.2592/fan1".to_owned(),
966 label: "fan1".to_owned(),
967 rpm: 900,
968 };
969 let other = Fan {
970 key: "nct6686:nct6683.2592/fan2".to_owned(),
971 label: "fan2".to_owned(),
972 rpm: 0,
973 };
974 assert_eq!(config.fan_label(&fan), "CPU fan");
975 assert_eq!(config.fan_label(&other), "fan2");
977 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
978 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
979 assert_eq!(config.lhm.url, "");
980 assert_eq!(config.notifications.level, NotifyLevel::Warning);
981 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
982 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
983 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
984 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
985 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
987 }
988
989 #[test]
990 fn parses_hosts_and_overview() {
991 let config: Config = toml::from_str(
992 r#"
993[overview]
994sections = ["storage", "cpu"]
995include_local = false
996
997[[hosts]]
998name = "zefat"
999
1000[[hosts]]
1001name = "nas"
1002ssh = "admin@nas.local"
1003sections = ["storage"]
1004
1005[[widgets]]
1006name = "Clock"
1007builtin = "clock"
1008
1009[[widgets]]
1010name = "Rates"
1011command = "rates.sh"
1012interval_secs = 60
1013"#,
1014 )
1015 .unwrap();
1016 assert_eq!(
1017 config.overview.sections,
1018 vec![OverviewSection::Storage, OverviewSection::Cpu]
1019 );
1020 assert!(!config.overview.include_local);
1021 assert_eq!(config.overview.processes, 10, "default kept");
1022 assert_eq!(config.hosts.len(), 2);
1023 assert_eq!(config.hosts[0].destination(), "zefat");
1024 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1025 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1026 assert_eq!(config.widgets[1].interval_secs, 60);
1027 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1028 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1029 assert_eq!(
1030 config.hosts[1].sections,
1031 Some(vec![OverviewSection::Storage])
1032 );
1033 let defaults = Config::default();
1035 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1036 assert!(defaults.overview.include_local);
1037 }
1038
1039 #[test]
1040 fn save_hosts_replaces_only_the_hosts() {
1041 let tmp = tempfile::tempdir().unwrap();
1042 let path = tmp.path().join("config.toml");
1043 fs::write(
1044 &path,
1045 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1046 )
1047 .unwrap();
1048 let hosts = vec![
1049 HostConfig::from_destination("zefat"),
1050 HostConfig {
1051 name: "nas".to_owned(),
1052 ssh: Some("admin@nas.local".to_owned()),
1053 command: Some("/opt/rigtop --agent".to_owned()),
1054 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1055 },
1056 ];
1057 Config::save_hosts(&path, &hosts).unwrap();
1058
1059 let text = fs::read_to_string(&path).unwrap();
1060 assert!(
1061 text.contains("# my settings") && text.contains("# keep me"),
1062 "{text}"
1063 );
1064 assert!(!text.contains("\"old\""), "{text}");
1065 let config = Config::load(&path).unwrap();
1066 assert_eq!(config.hosts, hosts);
1067 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1068
1069 Config::save_hosts(&path, &[]).unwrap();
1071 assert!(Config::load(&path).unwrap().hosts.is_empty());
1072 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1073 }
1074
1075 #[test]
1076 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1077 let tmp = tempfile::tempdir().unwrap();
1078 let path = tmp.path().join("config.toml");
1079 fs::write(
1080 &path,
1081 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1082 )
1083 .unwrap();
1084 let mut sensors = SensorConfig::default();
1085 sensors
1086 .labels
1087 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1088 sensors
1089 .labels
1090 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1091 sensors.hidden.push("b:c.1/temp4".to_owned());
1092 Config::save_sensors(&path, &sensors).unwrap();
1093
1094 let text = fs::read_to_string(&path).unwrap();
1095 assert!(text.contains("# my settings"), "{text}");
1096 assert!(text.contains("labels = {"), "{text}");
1098 let config = Config::load(&path).unwrap();
1099 assert_eq!(config.sensors, sensors);
1100 assert_eq!(config.hosts[0].name, "zefat");
1101
1102 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1104 let text = fs::read_to_string(&path).unwrap();
1105 assert!(!text.contains("[sensors]"), "{text}");
1106 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1107
1108 let fresh = tmp.path().join("new.toml");
1110 Config::save_sensors(&fresh, &sensors).unwrap();
1111 let text = fs::read_to_string(&fresh).unwrap();
1112 assert!(text.contains("[sensors.labels]"), "{text}");
1113 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1114 }
1115
1116 #[test]
1117 fn save_hosts_creates_a_missing_file() {
1118 let tmp = tempfile::tempdir().unwrap();
1119 let path = tmp.path().join("new/rigtop/config.toml");
1120 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1121 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1122 }
1123
1124 #[test]
1125 fn save_hosts_refuses_to_touch_a_broken_file() {
1126 let tmp = tempfile::tempdir().unwrap();
1127 let path = tmp.path().join("config.toml");
1128 fs::write(&path, "[sensors\n").unwrap();
1129 let err = Config::save_hosts(&path, &[]).unwrap_err();
1130 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1131 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1132 }
1133
1134 #[test]
1135 fn unknown_fields_are_rejected() {
1136 let tmp = tempfile::tempdir().unwrap();
1137 let path = tmp.path().join("config.toml");
1138 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1140 assert!(matches!(
1141 Config::load(&path),
1142 Err(ConfigError::Parse { .. })
1143 ));
1144 }
1145
1146 #[test]
1147 fn save_and_reload_round_trip() {
1148 let tmp = tempfile::tempdir().unwrap();
1149 let path = tmp.path().join("nested/rigtop/config.toml");
1150 let mut config = Config::default();
1151 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1152 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1153 config.set_label("nct6686:nct6683.2592/fan1", "");
1154 config.save(&path).unwrap();
1155
1156 let back = Config::load(&path).unwrap();
1157 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1158 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1160 assert!(!path.with_extension("toml.tmp").exists());
1161 }
1162}