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