1use std::collections::BTreeMap;
68use std::fs;
69use std::io;
70use std::path::{Path, PathBuf};
71use std::time::{Duration, Instant, SystemTime};
72
73use serde::{Deserialize, Serialize};
74
75use crate::snapshot::{Fan, Temperature};
76
77pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
79pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
81pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
83
84#[derive(Debug, thiserror::Error)]
86pub enum ConfigError {
87 #[error("cannot access {path}: {source}")]
89 Io {
90 path: PathBuf,
92 source: io::Error,
94 },
95 #[error("invalid config {path}: {source}")]
97 Parse {
98 path: PathBuf,
100 source: toml::de::Error,
102 },
103 #[error("cannot serialize config: {0}")]
105 Serialize(#[from] toml::ser::Error),
106 #[error("cannot edit {path}: {source}")]
108 Edit {
109 path: PathBuf,
111 source: toml_edit::TomlError,
113 },
114}
115
116#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
118#[serde(default, deny_unknown_fields)]
119pub struct Config {
120 pub sensors: SensorConfig,
122 pub smart: SmartConfig,
124 pub btrfs: BtrfsConfig,
126 pub lhm: LhmConfig,
129 pub overview: OverviewConfig,
131 pub alerts: AlertsConfig,
133 pub notifications: NotificationsConfig,
135 pub hosts: Vec<HostConfig>,
137 pub widgets: Vec<WidgetConfig>,
139}
140
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
143#[serde(rename_all = "lowercase")]
144pub enum BuiltinWidget {
145 Clock,
147}
148
149#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
152#[serde(default, deny_unknown_fields)]
153pub struct WidgetConfig {
154 pub name: String,
156 pub command: Option<String>,
158 pub builtin: Option<BuiltinWidget>,
160 pub interval_secs: u64,
162 pub timeout_secs: u64,
164}
165
166impl Default for WidgetConfig {
167 fn default() -> Self {
168 Self {
169 name: String::new(),
170 command: None,
171 builtin: None,
172 interval_secs: 5,
173 timeout_secs: 10,
174 }
175 }
176}
177
178#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
180#[serde(rename_all = "lowercase")]
181pub enum OverviewSection {
182 Cpu,
183 Gpu,
184 Memory,
185 Processes,
186 Network,
187 Storage,
188}
189
190impl OverviewSection {
191 pub const ALL: [OverviewSection; 6] = [
193 OverviewSection::Cpu,
194 OverviewSection::Gpu,
195 OverviewSection::Memory,
196 OverviewSection::Processes,
197 OverviewSection::Network,
198 OverviewSection::Storage,
199 ];
200
201 pub fn label(self) -> &'static str {
203 match self {
204 OverviewSection::Cpu => "CPU",
205 OverviewSection::Gpu => "GPU",
206 OverviewSection::Memory => "Memory",
207 OverviewSection::Processes => "Processes",
208 OverviewSection::Network => "Network",
209 OverviewSection::Storage => "Storage",
210 }
211 }
212}
213
214#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216#[serde(default, deny_unknown_fields)]
217pub struct OverviewConfig {
218 pub sections: Vec<OverviewSection>,
222 pub include_local: bool,
224 pub processes: usize,
227}
228
229impl Default for OverviewConfig {
230 fn default() -> Self {
231 Self {
232 sections: OverviewSection::ALL.to_vec(),
233 include_local: true,
234 processes: 10,
235 }
236 }
237}
238
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
241#[serde(deny_unknown_fields)]
242pub struct HostConfig {
243 pub name: String,
245 #[serde(default, skip_serializing_if = "Option::is_none")]
248 pub ssh: Option<String>,
249 #[serde(default, skip_serializing_if = "Option::is_none")]
251 pub command: Option<String>,
252 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub sections: Option<Vec<OverviewSection>>,
256}
257
258impl HostConfig {
259 pub fn from_destination(destination: &str) -> Self {
261 Self {
262 name: destination.to_owned(),
263 ssh: None,
264 command: None,
265 sections: None,
266 }
267 }
268
269 pub fn destination(&self) -> &str {
271 self.ssh.as_deref().unwrap_or(&self.name)
272 }
273}
274
275#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
277#[serde(default, deny_unknown_fields)]
278pub struct SensorConfig {
279 pub labels: BTreeMap<String, String>,
282 pub hidden: Vec<String>,
284}
285
286#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
288#[serde(default, deny_unknown_fields)]
289pub struct SmartConfig {
290 pub dir: PathBuf,
292}
293
294impl Default for SmartConfig {
295 fn default() -> Self {
296 Self {
297 dir: PathBuf::from(DEFAULT_SMART_DIR),
298 }
299 }
300}
301
302#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
304#[serde(rename_all = "lowercase")]
305pub enum NotifyLevel {
306 Off,
308 Warning,
310 #[default]
312 Critical,
313}
314
315#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(default, deny_unknown_fields)]
320pub struct AlertsConfig {
321 pub filesystem_warning_percent: f64,
323 pub filesystem_critical_percent: f64,
325 pub cpu_warning_celsius: f32,
328 pub gpu_warning_celsius: f32,
330 pub drive_warning_celsius: f32,
332}
333
334impl Default for AlertsConfig {
335 fn default() -> Self {
336 Self {
337 filesystem_warning_percent: 90.0,
338 filesystem_critical_percent: 97.0,
339 cpu_warning_celsius: 96.0,
340 gpu_warning_celsius: 85.0,
341 drive_warning_celsius: 70.0,
342 }
343 }
344}
345
346#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
348#[serde(default, deny_unknown_fields)]
349pub struct NotificationsConfig {
350 pub level: NotifyLevel,
351}
352
353#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
355#[serde(default, deny_unknown_fields)]
356pub struct LhmConfig {
357 pub url: String,
360}
361
362impl Default for LhmConfig {
363 fn default() -> Self {
364 Self {
365 url: DEFAULT_LHM_URL.to_owned(),
366 }
367 }
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(default, deny_unknown_fields)]
373pub struct BtrfsConfig {
374 pub scrub_dir: PathBuf,
376}
377
378impl Default for BtrfsConfig {
379 fn default() -> Self {
380 Self {
381 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
382 }
383 }
384}
385
386impl Config {
387 pub fn default_path() -> Option<PathBuf> {
389 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
390 }
391
392 pub fn load(path: &Path) -> Result<Self, ConfigError> {
394 let text = match fs::read_to_string(path) {
395 Ok(text) => text,
396 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
397 Err(source) => {
398 return Err(ConfigError::Io {
399 path: path.to_owned(),
400 source,
401 });
402 }
403 };
404 toml::from_str(&text).map_err(|source| ConfigError::Parse {
405 path: path.to_owned(),
406 source,
407 })
408 }
409
410 pub fn load_default() -> Result<Self, ConfigError> {
413 match Self::default_path() {
414 Some(path) => Self::load(&path),
415 None => Ok(Self::default()),
416 }
417 }
418
419 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
424 let text = toml::to_string_pretty(self)?;
425 let io_err = |source| ConfigError::Io {
426 path: path.to_owned(),
427 source,
428 };
429 if let Some(parent) = path.parent() {
430 fs::create_dir_all(parent).map_err(io_err)?;
431 }
432 let tmp = path.with_extension("toml.tmp");
433 fs::write(&tmp, text).map_err(io_err)?;
434 fs::rename(&tmp, path).map_err(io_err)
435 }
436
437 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
444 edit_file(path, |document| {
445 if hosts.is_empty() {
446 document.remove("hosts");
447 return Ok(());
448 }
449 let mut tables = toml_edit::ArrayOfTables::new();
450 for host in hosts {
451 let mut table = toml_edit::Table::new();
452 table["name"] = toml_edit::value(host.name.as_str());
453 if let Some(ssh) = &host.ssh {
454 table["ssh"] = toml_edit::value(ssh.as_str());
455 }
456 if let Some(command) = &host.command {
457 table["command"] = toml_edit::value(command.as_str());
458 }
459 if let Some(sections) = &host.sections {
460 let mut array = toml_edit::Array::new();
461 for section in sections {
462 let name = toml::Value::try_from(section)?;
464 if let Some(name) = name.as_str() {
465 array.push(name);
466 }
467 }
468 table["sections"] = toml_edit::value(array);
469 }
470 tables.push(table);
471 }
472 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
473 Ok(())
474 })
475 }
476
477 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
484 edit_file(path, |document| {
485 if !document.contains_key("sensors") {
486 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
487 return Ok(());
488 }
489 document["sensors"] = toml_edit::table();
490 }
491 let Some(table) = document["sensors"].as_table_like_mut() else {
492 return Ok(());
494 };
495 if sensors.hidden.is_empty() {
496 table.remove("hidden");
497 } else {
498 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
499 table.insert("hidden", toml_edit::value(hidden));
500 }
501 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
502 if sensors.labels.is_empty() {
503 table.remove("labels");
504 } else if inline {
505 let labels: toml_edit::InlineTable = sensors
506 .labels
507 .iter()
508 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
509 .collect();
510 table.insert("labels", toml_edit::value(labels));
511 } else {
512 let mut labels = toml_edit::Table::new();
513 for (key, name) in &sensors.labels {
514 labels[key.as_str()] = toml_edit::value(name.as_str());
515 }
516 table.insert("labels", toml_edit::Item::Table(labels));
517 }
518 if table.is_empty() {
519 document.remove("sensors");
520 }
521 Ok(())
522 })
523 }
524
525 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
527 self.label_for(&fan.key).unwrap_or(&fan.label)
528 }
529
530 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
532 self.label_for(&temperature.key)
533 .unwrap_or(&temperature.label)
534 }
535
536 pub fn label_for(&self, key: &str) -> Option<&str> {
538 self.sensors.labels.get(key).map(String::as_str)
539 }
540
541 pub fn set_label(&mut self, key: &str, name: &str) {
543 let name = name.trim();
544 if name.is_empty() {
545 self.sensors.labels.remove(key);
546 } else {
547 self.sensors.labels.insert(key.to_owned(), name.to_owned());
548 }
549 }
550
551 pub fn is_hidden(&self, key: &str) -> bool {
553 self.sensors.hidden.iter().any(|k| k == key)
554 }
555}
556
557fn edit_file(
562 path: &Path,
563 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
564) -> Result<(), ConfigError> {
565 let io_err = |source| ConfigError::Io {
566 path: path.to_owned(),
567 source,
568 };
569 let text = match fs::read_to_string(path) {
570 Ok(text) => text,
571 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
572 Err(err) => return Err(io_err(err)),
573 };
574 let mut document: toml_edit::DocumentMut =
575 text.parse().map_err(|source| ConfigError::Edit {
576 path: path.to_owned(),
577 source,
578 })?;
579 edit(&mut document)?;
580 let edited = document.to_string();
581 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
583 path: path.to_owned(),
584 source,
585 })?;
586 if let Some(parent) = path.parent() {
587 fs::create_dir_all(parent).map_err(io_err)?;
588 }
589 let tmp = path.with_extension("toml.tmp");
590 fs::write(&tmp, edited).map_err(io_err)?;
591 fs::rename(&tmp, path).map_err(io_err)
592}
593
594const WATCH_INTERVAL: Duration = Duration::from_secs(1);
596
597pub struct ConfigWatcher {
600 path: PathBuf,
601 stamp: Option<(Option<SystemTime>, u64)>,
603 last_check: Instant,
604 interval: Duration,
605}
606
607impl ConfigWatcher {
608 pub fn new(path: PathBuf) -> Self {
610 Self {
611 stamp: file_stamp(&path),
612 path,
613 last_check: Instant::now(),
614 interval: WATCH_INTERVAL,
615 }
616 }
617
618 pub fn path(&self) -> &Path {
620 &self.path
621 }
622
623 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
628 if self.last_check.elapsed() < self.interval {
629 return None;
630 }
631 self.last_check = Instant::now();
632 let stamp = file_stamp(&self.path);
633 if stamp == self.stamp {
634 return None;
635 }
636 self.stamp = stamp;
637 stamp?;
638 Some(Config::load(&self.path))
639 }
640}
641
642fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
644 let metadata = fs::metadata(path).ok()?;
645 Some((metadata.modified().ok(), metadata.len()))
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651
652 #[test]
653 fn watcher_reports_edits_once() {
654 let tmp = tempfile::tempdir().unwrap();
655 let path = tmp.path().join("config.toml");
656 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
657 let mut watcher = ConfigWatcher::new(path.clone());
658 watcher.interval = Duration::ZERO;
659 assert!(
660 watcher.poll().is_none(),
661 "the initial file counts as loaded"
662 );
663
664 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
666 let config = watcher.poll().unwrap().unwrap();
667 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
668 assert!(watcher.poll().is_none(), "reported once");
669
670 fs::write(&path, "[alerts\n").unwrap();
671 assert!(
672 watcher.poll().unwrap().is_err(),
673 "a broken file is an error"
674 );
675 fs::remove_file(&path).unwrap();
676 assert!(
677 watcher.poll().is_none(),
678 "removing the file changes nothing"
679 );
680 }
681
682 #[test]
683 fn missing_file_gives_defaults() {
684 let tmp = tempfile::tempdir().unwrap();
685 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
686 assert_eq!(config, Config::default());
687 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
688 }
689
690 #[test]
691 fn parses_labels_hidden_and_smart_dir() {
692 let tmp = tempfile::tempdir().unwrap();
693 let path = tmp.path().join("config.toml");
694 fs::write(
695 &path,
696 r#"
697[sensors]
698labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
699hidden = ["nct6686:nct6683.2592/temp4"]
700
701[smart]
702dir = "/tmp/smart"
703
704[lhm]
705url = ""
706
707[notifications]
708level = "warning"
709
710[alerts]
711drive_warning_celsius = 65
712cpu_warning_celsius = 0
713"#,
714 )
715 .unwrap();
716 let config = Config::load(&path).unwrap();
717 let fan = Fan {
718 key: "nct6686:nct6683.2592/fan1".to_owned(),
719 label: "fan1".to_owned(),
720 rpm: 900,
721 };
722 let other = Fan {
723 key: "nct6686:nct6683.2592/fan2".to_owned(),
724 label: "fan2".to_owned(),
725 rpm: 0,
726 };
727 assert_eq!(config.fan_label(&fan), "CPU fan");
728 assert_eq!(config.fan_label(&other), "fan2");
730 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
731 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
732 assert_eq!(config.lhm.url, "");
733 assert_eq!(config.notifications.level, NotifyLevel::Warning);
734 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
735 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
736 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
737 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
738 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
740 }
741
742 #[test]
743 fn parses_hosts_and_overview() {
744 let config: Config = toml::from_str(
745 r#"
746[overview]
747sections = ["storage", "cpu"]
748include_local = false
749
750[[hosts]]
751name = "zefat"
752
753[[hosts]]
754name = "nas"
755ssh = "admin@nas.local"
756sections = ["storage"]
757
758[[widgets]]
759name = "Clock"
760builtin = "clock"
761
762[[widgets]]
763name = "Rates"
764command = "rates.sh"
765interval_secs = 60
766"#,
767 )
768 .unwrap();
769 assert_eq!(
770 config.overview.sections,
771 vec![OverviewSection::Storage, OverviewSection::Cpu]
772 );
773 assert!(!config.overview.include_local);
774 assert_eq!(config.overview.processes, 10, "default kept");
775 assert_eq!(config.hosts.len(), 2);
776 assert_eq!(config.hosts[0].destination(), "zefat");
777 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
778 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
779 assert_eq!(config.widgets[1].interval_secs, 60);
780 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
781 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
782 assert_eq!(
783 config.hosts[1].sections,
784 Some(vec![OverviewSection::Storage])
785 );
786 let defaults = Config::default();
788 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
789 assert!(defaults.overview.include_local);
790 }
791
792 #[test]
793 fn save_hosts_replaces_only_the_hosts() {
794 let tmp = tempfile::tempdir().unwrap();
795 let path = tmp.path().join("config.toml");
796 fs::write(
797 &path,
798 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
799 )
800 .unwrap();
801 let hosts = vec![
802 HostConfig::from_destination("zefat"),
803 HostConfig {
804 name: "nas".to_owned(),
805 ssh: Some("admin@nas.local".to_owned()),
806 command: Some("/opt/rigtop --agent".to_owned()),
807 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
808 },
809 ];
810 Config::save_hosts(&path, &hosts).unwrap();
811
812 let text = fs::read_to_string(&path).unwrap();
813 assert!(
814 text.contains("# my settings") && text.contains("# keep me"),
815 "{text}"
816 );
817 assert!(!text.contains("\"old\""), "{text}");
818 let config = Config::load(&path).unwrap();
819 assert_eq!(config.hosts, hosts);
820 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
821
822 Config::save_hosts(&path, &[]).unwrap();
824 assert!(Config::load(&path).unwrap().hosts.is_empty());
825 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
826 }
827
828 #[test]
829 fn save_sensors_replaces_only_names_and_hidden_sensors() {
830 let tmp = tempfile::tempdir().unwrap();
831 let path = tmp.path().join("config.toml");
832 fs::write(
833 &path,
834 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
835 )
836 .unwrap();
837 let mut sensors = SensorConfig::default();
838 sensors
839 .labels
840 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
841 sensors
842 .labels
843 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
844 sensors.hidden.push("b:c.1/temp4".to_owned());
845 Config::save_sensors(&path, &sensors).unwrap();
846
847 let text = fs::read_to_string(&path).unwrap();
848 assert!(text.contains("# my settings"), "{text}");
849 assert!(text.contains("labels = {"), "{text}");
851 let config = Config::load(&path).unwrap();
852 assert_eq!(config.sensors, sensors);
853 assert_eq!(config.hosts[0].name, "zefat");
854
855 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
857 let text = fs::read_to_string(&path).unwrap();
858 assert!(!text.contains("[sensors]"), "{text}");
859 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
860
861 let fresh = tmp.path().join("new.toml");
863 Config::save_sensors(&fresh, &sensors).unwrap();
864 let text = fs::read_to_string(&fresh).unwrap();
865 assert!(text.contains("[sensors.labels]"), "{text}");
866 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
867 }
868
869 #[test]
870 fn save_hosts_creates_a_missing_file() {
871 let tmp = tempfile::tempdir().unwrap();
872 let path = tmp.path().join("new/rigtop/config.toml");
873 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
874 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
875 }
876
877 #[test]
878 fn save_hosts_refuses_to_touch_a_broken_file() {
879 let tmp = tempfile::tempdir().unwrap();
880 let path = tmp.path().join("config.toml");
881 fs::write(&path, "[sensors\n").unwrap();
882 let err = Config::save_hosts(&path, &[]).unwrap_err();
883 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
884 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
885 }
886
887 #[test]
888 fn unknown_fields_are_rejected() {
889 let tmp = tempfile::tempdir().unwrap();
890 let path = tmp.path().join("config.toml");
891 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
893 assert!(matches!(
894 Config::load(&path),
895 Err(ConfigError::Parse { .. })
896 ));
897 }
898
899 #[test]
900 fn save_and_reload_round_trip() {
901 let tmp = tempfile::tempdir().unwrap();
902 let path = tmp.path().join("nested/rigtop/config.toml");
903 let mut config = Config::default();
904 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
905 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
906 config.set_label("nct6686:nct6683.2592/fan1", "");
907 config.save(&path).unwrap();
908
909 let back = Config::load(&path).unwrap();
910 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
911 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
913 assert!(!path.with_extension("toml.tmp").exists());
914 }
915}