1use std::collections::BTreeMap;
76use std::fs;
77use std::io;
78use std::path::{Path, PathBuf};
79use std::time::{Duration, Instant, SystemTime};
80
81use serde::{Deserialize, Serialize};
82
83use crate::snapshot::{Fan, Temperature};
84
85pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
87pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
89pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
91
92#[derive(Debug, thiserror::Error)]
94pub enum ConfigError {
95 #[error("cannot access {path}: {source}")]
97 Io {
98 path: PathBuf,
100 source: io::Error,
102 },
103 #[error("invalid config {path}: {source}")]
105 Parse {
106 path: PathBuf,
108 source: toml::de::Error,
110 },
111 #[error("cannot serialize config: {0}")]
113 Serialize(#[from] toml::ser::Error),
114 #[error("cannot edit {path}: {source}")]
116 Edit {
117 path: PathBuf,
119 source: toml_edit::TomlError,
121 },
122}
123
124#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
126#[serde(default, deny_unknown_fields)]
127pub struct Config {
128 pub sensors: SensorConfig,
130 pub smart: SmartConfig,
132 pub btrfs: BtrfsConfig,
134 pub lhm: LhmConfig,
137 pub overview: OverviewConfig,
139 pub alerts: AlertsConfig,
141 pub notifications: NotificationsConfig,
143 pub updates: UpdatesConfig,
145 pub hosts: Vec<HostConfig>,
147 pub widgets: Vec<WidgetConfig>,
149}
150
151#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
153#[serde(rename_all = "lowercase")]
154pub enum BuiltinWidget {
155 Clock,
157}
158
159#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
162#[serde(default, deny_unknown_fields)]
163pub struct WidgetConfig {
164 pub name: String,
166 pub command: Option<String>,
168 pub builtin: Option<BuiltinWidget>,
170 pub interval_secs: u64,
172 pub timeout_secs: u64,
174}
175
176impl Default for WidgetConfig {
177 fn default() -> Self {
178 Self {
179 name: String::new(),
180 command: None,
181 builtin: None,
182 interval_secs: 5,
183 timeout_secs: 10,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
190#[serde(rename_all = "lowercase")]
191pub enum OverviewSection {
192 Cpu,
193 Gpu,
194 Memory,
195 Processes,
196 Network,
197 Storage,
198}
199
200impl OverviewSection {
201 pub const ALL: [OverviewSection; 6] = [
203 OverviewSection::Cpu,
204 OverviewSection::Gpu,
205 OverviewSection::Memory,
206 OverviewSection::Processes,
207 OverviewSection::Network,
208 OverviewSection::Storage,
209 ];
210
211 pub fn label(self) -> &'static str {
213 match self {
214 OverviewSection::Cpu => "CPU",
215 OverviewSection::Gpu => "GPU",
216 OverviewSection::Memory => "Memory",
217 OverviewSection::Processes => "Processes",
218 OverviewSection::Network => "Network",
219 OverviewSection::Storage => "Storage",
220 }
221 }
222}
223
224#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
226#[serde(default, deny_unknown_fields)]
227pub struct OverviewConfig {
228 pub sections: Vec<OverviewSection>,
232 pub include_local: bool,
234 pub processes: usize,
237}
238
239impl Default for OverviewConfig {
240 fn default() -> Self {
241 Self {
242 sections: OverviewSection::ALL.to_vec(),
243 include_local: true,
244 processes: 10,
245 }
246 }
247}
248
249#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
251#[serde(deny_unknown_fields)]
252pub struct HostConfig {
253 pub name: String,
255 #[serde(default, skip_serializing_if = "Option::is_none")]
258 pub ssh: Option<String>,
259 #[serde(default, skip_serializing_if = "Option::is_none")]
261 pub command: Option<String>,
262 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub sections: Option<Vec<OverviewSection>>,
266}
267
268impl HostConfig {
269 pub fn from_destination(destination: &str) -> Self {
271 Self {
272 name: destination.to_owned(),
273 ssh: None,
274 command: None,
275 sections: None,
276 }
277 }
278
279 pub fn destination(&self) -> &str {
281 self.ssh.as_deref().unwrap_or(&self.name)
282 }
283}
284
285#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
287#[serde(default, deny_unknown_fields)]
288pub struct SensorConfig {
289 pub labels: BTreeMap<String, String>,
292 pub hidden: Vec<String>,
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct SmartConfig {
300 pub dir: PathBuf,
302}
303
304impl Default for SmartConfig {
305 fn default() -> Self {
306 Self {
307 dir: PathBuf::from(DEFAULT_SMART_DIR),
308 }
309 }
310}
311
312#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
314#[serde(rename_all = "lowercase")]
315pub enum NotifyLevel {
316 Off,
318 Warning,
320 #[default]
322 Critical,
323}
324
325#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
329#[serde(default, deny_unknown_fields)]
330pub struct AlertsConfig {
331 pub filesystem_warning_percent: f64,
333 pub filesystem_critical_percent: f64,
335 pub cpu_warning_celsius: f32,
338 pub gpu_warning_celsius: f32,
340 pub drive_warning_celsius: f32,
342 pub ignored_units: Vec<String>,
345}
346
347impl Default for AlertsConfig {
348 fn default() -> Self {
349 Self {
350 filesystem_warning_percent: 90.0,
351 filesystem_critical_percent: 97.0,
352 cpu_warning_celsius: 96.0,
353 gpu_warning_celsius: 85.0,
354 drive_warning_celsius: 70.0,
355 ignored_units: Vec::new(),
356 }
357 }
358}
359
360#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
362#[serde(default, deny_unknown_fields)]
363pub struct NotificationsConfig {
364 pub level: NotifyLevel,
365}
366
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369#[serde(default, deny_unknown_fields)]
370pub struct UpdatesConfig {
371 pub check: bool,
373 #[serde(with = "duration_text")]
376 pub interval: Duration,
377 pub notify: bool,
380}
381
382impl Default for UpdatesConfig {
383 fn default() -> Self {
384 Self {
385 check: true,
386 interval: Duration::from_secs(3600),
387 notify: true,
388 }
389 }
390}
391
392impl UpdatesConfig {
393 pub fn check_interval(&self) -> Duration {
395 self.interval.max(Duration::from_secs(60))
396 }
397}
398
399mod duration_text {
402 use std::time::Duration;
403
404 use serde::de::Error;
405 use serde::{Deserialize, Deserializer, Serializer};
406
407 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
408
409 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
410 serializer.serialize_str(&format(*duration))
411 }
412
413 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
414 let text = String::deserialize(deserializer)?;
415 parse(&text).ok_or_else(|| {
416 D::Error::custom(format!(
417 "invalid duration {text:?}: write a number and s, m, h or d, \
418 e.g. \"30m\", \"6h\" or \"1h30m\""
419 ))
420 })
421 }
422
423 pub fn parse(text: &str) -> Option<Duration> {
425 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
426 if text.is_empty() {
427 return None;
428 }
429 let mut total = 0u64;
430 let mut number = String::new();
431 for c in text.chars() {
432 if c.is_ascii_digit() {
433 number.push(c);
434 continue;
435 }
436 let (_, secs) = UNITS
437 .iter()
438 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
439 let value: u64 = number.parse().ok()?;
440 total = total.checked_add(value.checked_mul(*secs)?)?;
441 number.clear();
442 }
443 number.is_empty().then(|| Duration::from_secs(total))
445 }
446
447 pub fn format(duration: Duration) -> String {
449 let mut secs = duration.as_secs();
450 if secs == 0 {
451 return "0s".to_owned();
452 }
453 let mut text = String::new();
454 for (unit, size) in UNITS {
455 if secs >= size {
456 text.push_str(&format!("{}{unit}", secs / size));
457 secs %= size;
458 }
459 }
460 text
461 }
462}
463
464#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
466#[serde(default, deny_unknown_fields)]
467pub struct LhmConfig {
468 pub url: String,
471}
472
473impl Default for LhmConfig {
474 fn default() -> Self {
475 Self {
476 url: DEFAULT_LHM_URL.to_owned(),
477 }
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483#[serde(default, deny_unknown_fields)]
484pub struct BtrfsConfig {
485 pub scrub_dir: PathBuf,
487}
488
489impl Default for BtrfsConfig {
490 fn default() -> Self {
491 Self {
492 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
493 }
494 }
495}
496
497impl Config {
498 pub fn default_path() -> Option<PathBuf> {
500 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
501 }
502
503 pub fn load(path: &Path) -> Result<Self, ConfigError> {
505 let text = match fs::read_to_string(path) {
506 Ok(text) => text,
507 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
508 Err(source) => {
509 return Err(ConfigError::Io {
510 path: path.to_owned(),
511 source,
512 });
513 }
514 };
515 toml::from_str(&text).map_err(|source| ConfigError::Parse {
516 path: path.to_owned(),
517 source,
518 })
519 }
520
521 pub fn load_default() -> Result<Self, ConfigError> {
524 match Self::default_path() {
525 Some(path) => Self::load(&path),
526 None => Ok(Self::default()),
527 }
528 }
529
530 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
535 let text = toml::to_string_pretty(self)?;
536 let io_err = |source| ConfigError::Io {
537 path: path.to_owned(),
538 source,
539 };
540 if let Some(parent) = path.parent() {
541 fs::create_dir_all(parent).map_err(io_err)?;
542 }
543 let tmp = path.with_extension("toml.tmp");
544 fs::write(&tmp, text).map_err(io_err)?;
545 fs::rename(&tmp, path).map_err(io_err)
546 }
547
548 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
555 edit_file(path, |document| {
556 if hosts.is_empty() {
557 document.remove("hosts");
558 return Ok(());
559 }
560 let mut tables = toml_edit::ArrayOfTables::new();
561 for host in hosts {
562 let mut table = toml_edit::Table::new();
563 table["name"] = toml_edit::value(host.name.as_str());
564 if let Some(ssh) = &host.ssh {
565 table["ssh"] = toml_edit::value(ssh.as_str());
566 }
567 if let Some(command) = &host.command {
568 table["command"] = toml_edit::value(command.as_str());
569 }
570 if let Some(sections) = &host.sections {
571 let mut array = toml_edit::Array::new();
572 for section in sections {
573 let name = toml::Value::try_from(section)?;
575 if let Some(name) = name.as_str() {
576 array.push(name);
577 }
578 }
579 table["sections"] = toml_edit::value(array);
580 }
581 tables.push(table);
582 }
583 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
584 Ok(())
585 })
586 }
587
588 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
595 edit_file(path, |document| {
596 if !document.contains_key("sensors") {
597 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
598 return Ok(());
599 }
600 document["sensors"] = toml_edit::table();
601 }
602 let Some(table) = document["sensors"].as_table_like_mut() else {
603 return Ok(());
605 };
606 if sensors.hidden.is_empty() {
607 table.remove("hidden");
608 } else {
609 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
610 table.insert("hidden", toml_edit::value(hidden));
611 }
612 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
613 if sensors.labels.is_empty() {
614 table.remove("labels");
615 } else if inline {
616 let labels: toml_edit::InlineTable = sensors
617 .labels
618 .iter()
619 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
620 .collect();
621 table.insert("labels", toml_edit::value(labels));
622 } else {
623 let mut labels = toml_edit::Table::new();
624 for (key, name) in &sensors.labels {
625 labels[key.as_str()] = toml_edit::value(name.as_str());
626 }
627 table.insert("labels", toml_edit::Item::Table(labels));
628 }
629 if table.is_empty() {
630 document.remove("sensors");
631 }
632 Ok(())
633 })
634 }
635
636 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
638 self.label_for(&fan.key).unwrap_or(&fan.label)
639 }
640
641 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
643 self.label_for(&temperature.key)
644 .unwrap_or(&temperature.label)
645 }
646
647 pub fn label_for(&self, key: &str) -> Option<&str> {
649 self.sensors.labels.get(key).map(String::as_str)
650 }
651
652 pub fn set_label(&mut self, key: &str, name: &str) {
654 let name = name.trim();
655 if name.is_empty() {
656 self.sensors.labels.remove(key);
657 } else {
658 self.sensors.labels.insert(key.to_owned(), name.to_owned());
659 }
660 }
661
662 pub fn is_hidden(&self, key: &str) -> bool {
664 self.sensors.hidden.iter().any(|k| k == key)
665 }
666}
667
668fn edit_file(
673 path: &Path,
674 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
675) -> Result<(), ConfigError> {
676 let io_err = |source| ConfigError::Io {
677 path: path.to_owned(),
678 source,
679 };
680 let text = match fs::read_to_string(path) {
681 Ok(text) => text,
682 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
683 Err(err) => return Err(io_err(err)),
684 };
685 let mut document: toml_edit::DocumentMut =
686 text.parse().map_err(|source| ConfigError::Edit {
687 path: path.to_owned(),
688 source,
689 })?;
690 edit(&mut document)?;
691 let edited = document.to_string();
692 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
694 path: path.to_owned(),
695 source,
696 })?;
697 if let Some(parent) = path.parent() {
698 fs::create_dir_all(parent).map_err(io_err)?;
699 }
700 let tmp = path.with_extension("toml.tmp");
701 fs::write(&tmp, edited).map_err(io_err)?;
702 fs::rename(&tmp, path).map_err(io_err)
703}
704
705const WATCH_INTERVAL: Duration = Duration::from_secs(1);
707
708pub struct ConfigWatcher {
711 path: PathBuf,
712 stamp: Option<(Option<SystemTime>, u64)>,
714 last_check: Instant,
715 interval: Duration,
716}
717
718impl ConfigWatcher {
719 pub fn new(path: PathBuf) -> Self {
721 Self {
722 stamp: file_stamp(&path),
723 path,
724 last_check: Instant::now(),
725 interval: WATCH_INTERVAL,
726 }
727 }
728
729 pub fn path(&self) -> &Path {
731 &self.path
732 }
733
734 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
739 if self.last_check.elapsed() < self.interval {
740 return None;
741 }
742 self.last_check = Instant::now();
743 let stamp = file_stamp(&self.path);
744 if stamp == self.stamp {
745 return None;
746 }
747 self.stamp = stamp;
748 stamp?;
749 Some(Config::load(&self.path))
750 }
751}
752
753fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
755 let metadata = fs::metadata(path).ok()?;
756 Some((metadata.modified().ok(), metadata.len()))
757}
758
759#[cfg(test)]
760mod tests {
761 use super::*;
762
763 #[test]
764 fn update_checks_can_be_tuned_or_turned_off() {
765 let config: Config =
766 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
767 assert!(config.updates.check);
768 assert_eq!(
769 config.updates.check_interval(),
770 Duration::from_secs(6 * 3600)
771 );
772 assert!(!config.updates.notify);
773 let config: Config =
774 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
775 assert!(!config.updates.check);
776 assert_eq!(
777 config.updates.check_interval(),
778 Duration::from_secs(60),
779 "at least a minute"
780 );
781 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
782 assert!(error.to_string().contains("invalid duration"), "{error}");
783 }
784
785 #[test]
786 fn durations_read_and_write_as_text() {
787 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
788 assert_eq!(duration_text::parse("30m"), minutes(30));
789 assert_eq!(duration_text::parse("1h30m"), minutes(90));
790 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
791 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
792 assert_eq!(duration_text::parse("2H"), minutes(120));
793 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
794 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
795 }
796 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
797 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
798 let text = toml::to_string(&Config::default()).unwrap();
800 assert!(text.contains("interval = \"1h\""), "{text}");
801 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
802 }
803
804 #[test]
805 fn watcher_reports_edits_once() {
806 let tmp = tempfile::tempdir().unwrap();
807 let path = tmp.path().join("config.toml");
808 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
809 let mut watcher = ConfigWatcher::new(path.clone());
810 watcher.interval = Duration::ZERO;
811 assert!(
812 watcher.poll().is_none(),
813 "the initial file counts as loaded"
814 );
815
816 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
818 let config = watcher.poll().unwrap().unwrap();
819 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
820 assert!(watcher.poll().is_none(), "reported once");
821
822 fs::write(&path, "[alerts\n").unwrap();
823 assert!(
824 watcher.poll().unwrap().is_err(),
825 "a broken file is an error"
826 );
827 fs::remove_file(&path).unwrap();
828 assert!(
829 watcher.poll().is_none(),
830 "removing the file changes nothing"
831 );
832 }
833
834 #[test]
835 fn missing_file_gives_defaults() {
836 let tmp = tempfile::tempdir().unwrap();
837 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
838 assert_eq!(config, Config::default());
839 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
840 }
841
842 #[test]
843 fn parses_labels_hidden_and_smart_dir() {
844 let tmp = tempfile::tempdir().unwrap();
845 let path = tmp.path().join("config.toml");
846 fs::write(
847 &path,
848 r#"
849[sensors]
850labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
851hidden = ["nct6686:nct6683.2592/temp4"]
852
853[smart]
854dir = "/tmp/smart"
855
856[lhm]
857url = ""
858
859[notifications]
860level = "warning"
861
862[alerts]
863drive_warning_celsius = 65
864cpu_warning_celsius = 0
865"#,
866 )
867 .unwrap();
868 let config = Config::load(&path).unwrap();
869 let fan = Fan {
870 key: "nct6686:nct6683.2592/fan1".to_owned(),
871 label: "fan1".to_owned(),
872 rpm: 900,
873 };
874 let other = Fan {
875 key: "nct6686:nct6683.2592/fan2".to_owned(),
876 label: "fan2".to_owned(),
877 rpm: 0,
878 };
879 assert_eq!(config.fan_label(&fan), "CPU fan");
880 assert_eq!(config.fan_label(&other), "fan2");
882 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
883 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
884 assert_eq!(config.lhm.url, "");
885 assert_eq!(config.notifications.level, NotifyLevel::Warning);
886 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
887 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
888 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
889 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
890 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
892 }
893
894 #[test]
895 fn parses_hosts_and_overview() {
896 let config: Config = toml::from_str(
897 r#"
898[overview]
899sections = ["storage", "cpu"]
900include_local = false
901
902[[hosts]]
903name = "zefat"
904
905[[hosts]]
906name = "nas"
907ssh = "admin@nas.local"
908sections = ["storage"]
909
910[[widgets]]
911name = "Clock"
912builtin = "clock"
913
914[[widgets]]
915name = "Rates"
916command = "rates.sh"
917interval_secs = 60
918"#,
919 )
920 .unwrap();
921 assert_eq!(
922 config.overview.sections,
923 vec![OverviewSection::Storage, OverviewSection::Cpu]
924 );
925 assert!(!config.overview.include_local);
926 assert_eq!(config.overview.processes, 10, "default kept");
927 assert_eq!(config.hosts.len(), 2);
928 assert_eq!(config.hosts[0].destination(), "zefat");
929 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
930 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
931 assert_eq!(config.widgets[1].interval_secs, 60);
932 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
933 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
934 assert_eq!(
935 config.hosts[1].sections,
936 Some(vec![OverviewSection::Storage])
937 );
938 let defaults = Config::default();
940 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
941 assert!(defaults.overview.include_local);
942 }
943
944 #[test]
945 fn save_hosts_replaces_only_the_hosts() {
946 let tmp = tempfile::tempdir().unwrap();
947 let path = tmp.path().join("config.toml");
948 fs::write(
949 &path,
950 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
951 )
952 .unwrap();
953 let hosts = vec![
954 HostConfig::from_destination("zefat"),
955 HostConfig {
956 name: "nas".to_owned(),
957 ssh: Some("admin@nas.local".to_owned()),
958 command: Some("/opt/rigtop --agent".to_owned()),
959 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
960 },
961 ];
962 Config::save_hosts(&path, &hosts).unwrap();
963
964 let text = fs::read_to_string(&path).unwrap();
965 assert!(
966 text.contains("# my settings") && text.contains("# keep me"),
967 "{text}"
968 );
969 assert!(!text.contains("\"old\""), "{text}");
970 let config = Config::load(&path).unwrap();
971 assert_eq!(config.hosts, hosts);
972 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
973
974 Config::save_hosts(&path, &[]).unwrap();
976 assert!(Config::load(&path).unwrap().hosts.is_empty());
977 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
978 }
979
980 #[test]
981 fn save_sensors_replaces_only_names_and_hidden_sensors() {
982 let tmp = tempfile::tempdir().unwrap();
983 let path = tmp.path().join("config.toml");
984 fs::write(
985 &path,
986 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
987 )
988 .unwrap();
989 let mut sensors = SensorConfig::default();
990 sensors
991 .labels
992 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
993 sensors
994 .labels
995 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
996 sensors.hidden.push("b:c.1/temp4".to_owned());
997 Config::save_sensors(&path, &sensors).unwrap();
998
999 let text = fs::read_to_string(&path).unwrap();
1000 assert!(text.contains("# my settings"), "{text}");
1001 assert!(text.contains("labels = {"), "{text}");
1003 let config = Config::load(&path).unwrap();
1004 assert_eq!(config.sensors, sensors);
1005 assert_eq!(config.hosts[0].name, "zefat");
1006
1007 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1009 let text = fs::read_to_string(&path).unwrap();
1010 assert!(!text.contains("[sensors]"), "{text}");
1011 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1012
1013 let fresh = tmp.path().join("new.toml");
1015 Config::save_sensors(&fresh, &sensors).unwrap();
1016 let text = fs::read_to_string(&fresh).unwrap();
1017 assert!(text.contains("[sensors.labels]"), "{text}");
1018 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1019 }
1020
1021 #[test]
1022 fn save_hosts_creates_a_missing_file() {
1023 let tmp = tempfile::tempdir().unwrap();
1024 let path = tmp.path().join("new/rigtop/config.toml");
1025 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1026 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1027 }
1028
1029 #[test]
1030 fn save_hosts_refuses_to_touch_a_broken_file() {
1031 let tmp = tempfile::tempdir().unwrap();
1032 let path = tmp.path().join("config.toml");
1033 fs::write(&path, "[sensors\n").unwrap();
1034 let err = Config::save_hosts(&path, &[]).unwrap_err();
1035 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1036 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1037 }
1038
1039 #[test]
1040 fn unknown_fields_are_rejected() {
1041 let tmp = tempfile::tempdir().unwrap();
1042 let path = tmp.path().join("config.toml");
1043 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1045 assert!(matches!(
1046 Config::load(&path),
1047 Err(ConfigError::Parse { .. })
1048 ));
1049 }
1050
1051 #[test]
1052 fn save_and_reload_round_trip() {
1053 let tmp = tempfile::tempdir().unwrap();
1054 let path = tmp.path().join("nested/rigtop/config.toml");
1055 let mut config = Config::default();
1056 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1057 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1058 config.set_label("nct6686:nct6683.2592/fan1", "");
1059 config.save(&path).unwrap();
1060
1061 let back = Config::load(&path).unwrap();
1062 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1063 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1065 assert!(!path.with_extension("toml.tmp").exists());
1066 }
1067}