1use std::collections::BTreeMap;
92use std::fs;
93use std::io;
94use std::path::{Path, PathBuf};
95use std::time::{Duration, Instant, SystemTime};
96
97use serde::{Deserialize, Serialize};
98
99use crate::snapshot::{Fan, Temperature};
100
101pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
103pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
105pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
107
108#[derive(Debug, thiserror::Error)]
110pub enum ConfigError {
111 #[error("cannot access {path}: {source}")]
113 Io {
114 path: PathBuf,
116 source: io::Error,
118 },
119 #[error("invalid config {path}: {source}")]
121 Parse {
122 path: PathBuf,
124 source: toml::de::Error,
126 },
127 #[error("cannot serialize config: {0}")]
129 Serialize(#[from] toml::ser::Error),
130 #[error("cannot edit {path}: {source}")]
132 Edit {
133 path: PathBuf,
135 source: toml_edit::TomlError,
137 },
138}
139
140#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
142#[serde(default, deny_unknown_fields)]
143pub struct Config {
144 pub sensors: SensorConfig,
146 pub smart: SmartConfig,
148 pub btrfs: BtrfsConfig,
150 pub lhm: LhmConfig,
153 pub overview: OverviewConfig,
155 pub tui: TuiConfig,
157 pub alerts: AlertsConfig,
159 pub notifications: NotificationsConfig,
161 pub updates: UpdatesConfig,
163 pub watch: WatchConfig,
165 pub hosts: Vec<HostConfig>,
167 pub widgets: Vec<WidgetConfig>,
169}
170
171#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
173#[serde(rename_all = "lowercase")]
174pub enum BuiltinWidget {
175 Clock,
177}
178
179#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
182#[serde(default, deny_unknown_fields)]
183pub struct WidgetConfig {
184 pub name: String,
186 pub command: Option<String>,
188 pub builtin: Option<BuiltinWidget>,
190 pub interval_secs: u64,
192 pub timeout_secs: u64,
194}
195
196impl Default for WidgetConfig {
197 fn default() -> Self {
198 Self {
199 name: String::new(),
200 command: None,
201 builtin: None,
202 interval_secs: 5,
203 timeout_secs: 10,
204 }
205 }
206}
207
208#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
210#[serde(rename_all = "lowercase")]
211pub enum OverviewSection {
212 Cpu,
213 Gpu,
214 Memory,
215 Processes,
216 Network,
217 Storage,
218}
219
220impl OverviewSection {
221 pub const ALL: [OverviewSection; 6] = [
223 OverviewSection::Cpu,
224 OverviewSection::Gpu,
225 OverviewSection::Memory,
226 OverviewSection::Processes,
227 OverviewSection::Network,
228 OverviewSection::Storage,
229 ];
230
231 pub fn label(self) -> &'static str {
233 match self {
234 OverviewSection::Cpu => "CPU",
235 OverviewSection::Gpu => "GPU",
236 OverviewSection::Memory => "Memory",
237 OverviewSection::Processes => "Processes",
238 OverviewSection::Network => "Network",
239 OverviewSection::Storage => "Storage",
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
246#[serde(default, deny_unknown_fields)]
247pub struct TuiConfig {
248 pub hidden_panels: Vec<OverviewSection>,
251 pub show_widgets: bool,
253}
254
255impl Default for TuiConfig {
256 fn default() -> Self {
257 Self {
258 hidden_panels: Vec::new(),
259 show_widgets: true,
260 }
261 }
262}
263
264#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct OverviewConfig {
268 pub sections: Vec<OverviewSection>,
272 pub include_local: bool,
274 pub processes: usize,
277 pub alerts_first: bool,
280}
281
282impl Default for OverviewConfig {
283 fn default() -> Self {
284 Self {
285 sections: OverviewSection::ALL.to_vec(),
286 include_local: true,
287 processes: 10,
288 alerts_first: true,
289 }
290 }
291}
292
293#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
295#[serde(deny_unknown_fields)]
296pub struct HostConfig {
297 pub name: String,
299 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub ssh: Option<String>,
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub command: Option<String>,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub sections: Option<Vec<OverviewSection>>,
310}
311
312impl HostConfig {
313 pub fn from_destination(destination: &str) -> Self {
315 Self {
316 name: destination.to_owned(),
317 ssh: None,
318 command: None,
319 sections: None,
320 }
321 }
322
323 pub fn destination(&self) -> &str {
325 self.ssh.as_deref().unwrap_or(&self.name)
326 }
327}
328
329#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
331#[serde(default, deny_unknown_fields)]
332pub struct SensorConfig {
333 pub labels: BTreeMap<String, String>,
336 pub hidden: Vec<String>,
338}
339
340#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
342#[serde(default, deny_unknown_fields)]
343pub struct SmartConfig {
344 pub dir: PathBuf,
346}
347
348impl Default for SmartConfig {
349 fn default() -> Self {
350 Self {
351 dir: PathBuf::from(DEFAULT_SMART_DIR),
352 }
353 }
354}
355
356#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
358#[serde(rename_all = "lowercase")]
359pub enum NotifyLevel {
360 Off,
362 Warning,
364 #[default]
366 Critical,
367}
368
369#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
373#[serde(default, deny_unknown_fields)]
374pub struct AlertsConfig {
375 pub filesystem_warning_percent: f64,
377 pub filesystem_critical_percent: f64,
379 pub cpu_warning_celsius: f32,
382 pub gpu_warning_celsius: f32,
384 pub drive_warning_celsius: f32,
386 pub ignored_units: Vec<String>,
389}
390
391impl Default for AlertsConfig {
392 fn default() -> Self {
393 Self {
394 filesystem_warning_percent: 90.0,
395 filesystem_critical_percent: 97.0,
396 cpu_warning_celsius: 96.0,
397 gpu_warning_celsius: 85.0,
398 drive_warning_celsius: 70.0,
399 ignored_units: Vec::new(),
400 }
401 }
402}
403
404#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
406#[serde(default, deny_unknown_fields)]
407pub struct NotificationsConfig {
408 pub level: NotifyLevel,
409}
410
411#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
413#[serde(default, deny_unknown_fields)]
414pub struct UpdatesConfig {
415 pub check: bool,
417 #[serde(with = "duration_text")]
420 pub interval: Duration,
421 pub notify: bool,
424}
425
426impl Default for UpdatesConfig {
427 fn default() -> Self {
428 Self {
429 check: true,
430 interval: Duration::from_secs(3600),
431 notify: true,
432 }
433 }
434}
435
436impl UpdatesConfig {
437 pub fn check_interval(&self) -> Duration {
439 self.interval.max(Duration::from_secs(60))
440 }
441}
442
443#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
446#[serde(default, deny_unknown_fields)]
447pub struct WatchConfig {
448 pub command: String,
451 pub level: NotifyLevel,
453 pub cleared: bool,
455 #[serde(with = "duration_text")]
457 pub interval: Duration,
458 #[serde(with = "duration_text")]
460 pub offline_after: Duration,
461 #[serde(with = "duration_text")]
463 pub timeout: Duration,
464}
465
466impl Default for WatchConfig {
467 fn default() -> Self {
468 Self {
469 command: String::new(),
470 level: NotifyLevel::Critical,
471 cleared: true,
472 interval: Duration::from_secs(10),
473 offline_after: Duration::from_secs(180),
474 timeout: Duration::from_secs(30),
475 }
476 }
477}
478
479mod duration_text {
482 use std::time::Duration;
483
484 use serde::de::Error;
485 use serde::{Deserialize, Deserializer, Serializer};
486
487 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
488
489 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
490 serializer.serialize_str(&format(*duration))
491 }
492
493 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
494 let text = String::deserialize(deserializer)?;
495 parse(&text).ok_or_else(|| {
496 D::Error::custom(format!(
497 "invalid duration {text:?}: write a number and s, m, h or d, \
498 e.g. \"30m\", \"6h\" or \"1h30m\""
499 ))
500 })
501 }
502
503 pub fn parse(text: &str) -> Option<Duration> {
505 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
506 if text.is_empty() {
507 return None;
508 }
509 let mut total = 0u64;
510 let mut number = String::new();
511 for c in text.chars() {
512 if c.is_ascii_digit() {
513 number.push(c);
514 continue;
515 }
516 let (_, secs) = UNITS
517 .iter()
518 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
519 let value: u64 = number.parse().ok()?;
520 total = total.checked_add(value.checked_mul(*secs)?)?;
521 number.clear();
522 }
523 number.is_empty().then(|| Duration::from_secs(total))
525 }
526
527 pub fn format(duration: Duration) -> String {
529 let mut secs = duration.as_secs();
530 if secs == 0 {
531 return "0s".to_owned();
532 }
533 let mut text = String::new();
534 for (unit, size) in UNITS {
535 if secs >= size {
536 text.push_str(&format!("{}{unit}", secs / size));
537 secs %= size;
538 }
539 }
540 text
541 }
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
546#[serde(default, deny_unknown_fields)]
547pub struct LhmConfig {
548 pub url: String,
551}
552
553impl Default for LhmConfig {
554 fn default() -> Self {
555 Self {
556 url: DEFAULT_LHM_URL.to_owned(),
557 }
558 }
559}
560
561#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
563#[serde(default, deny_unknown_fields)]
564pub struct BtrfsConfig {
565 pub scrub_dir: PathBuf,
567}
568
569impl Default for BtrfsConfig {
570 fn default() -> Self {
571 Self {
572 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
573 }
574 }
575}
576
577impl Config {
578 pub fn default_path() -> Option<PathBuf> {
580 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
581 }
582
583 pub fn load(path: &Path) -> Result<Self, ConfigError> {
585 let text = match fs::read_to_string(path) {
586 Ok(text) => text,
587 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
588 Err(source) => {
589 return Err(ConfigError::Io {
590 path: path.to_owned(),
591 source,
592 });
593 }
594 };
595 toml::from_str(&text).map_err(|source| ConfigError::Parse {
596 path: path.to_owned(),
597 source,
598 })
599 }
600
601 pub fn load_default() -> Result<Self, ConfigError> {
604 match Self::default_path() {
605 Some(path) => Self::load(&path),
606 None => Ok(Self::default()),
607 }
608 }
609
610 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
615 let text = toml::to_string_pretty(self)?;
616 let io_err = |source| ConfigError::Io {
617 path: path.to_owned(),
618 source,
619 };
620 if let Some(parent) = path.parent() {
621 fs::create_dir_all(parent).map_err(io_err)?;
622 }
623 let tmp = path.with_extension("toml.tmp");
624 fs::write(&tmp, text).map_err(io_err)?;
625 fs::rename(&tmp, path).map_err(io_err)
626 }
627
628 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
635 edit_file(path, |document| {
636 if hosts.is_empty() {
637 document.remove("hosts");
638 return Ok(());
639 }
640 let mut tables = toml_edit::ArrayOfTables::new();
641 for host in hosts {
642 let mut table = toml_edit::Table::new();
643 table["name"] = toml_edit::value(host.name.as_str());
644 if let Some(ssh) = &host.ssh {
645 table["ssh"] = toml_edit::value(ssh.as_str());
646 }
647 if let Some(command) = &host.command {
648 table["command"] = toml_edit::value(command.as_str());
649 }
650 if let Some(sections) = &host.sections {
651 let mut array = toml_edit::Array::new();
652 for section in sections {
653 let name = toml::Value::try_from(section)?;
655 if let Some(name) = name.as_str() {
656 array.push(name);
657 }
658 }
659 table["sections"] = toml_edit::value(array);
660 }
661 tables.push(table);
662 }
663 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
664 Ok(())
665 })
666 }
667
668 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
677 edit_file(path, |document| {
678 if !document.contains_key("tui") {
679 if *tui == TuiConfig::default() {
680 return Ok(());
681 }
682 document["tui"] = toml_edit::table();
683 }
684 let Some(table) = document["tui"].as_table_like_mut() else {
685 return Ok(());
687 };
688 if tui.hidden_panels.is_empty() {
689 table.remove("hidden_panels");
690 } else {
691 let names: toml_edit::Array = tui
692 .hidden_panels
693 .iter()
694 .filter_map(|section| {
695 serde_json::to_value(section)
696 .ok()
697 .and_then(|v| v.as_str().map(str::to_owned))
698 })
699 .collect();
700 table.insert("hidden_panels", toml_edit::value(names));
701 }
702 if tui.show_widgets {
703 table.remove("show_widgets");
704 } else {
705 table.insert("show_widgets", toml_edit::value(false));
706 }
707 Ok(())
708 })
709 }
710
711 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
712 edit_file(path, |document| {
713 if !document.contains_key("sensors") {
714 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
715 return Ok(());
716 }
717 document["sensors"] = toml_edit::table();
718 }
719 let Some(table) = document["sensors"].as_table_like_mut() else {
720 return Ok(());
722 };
723 if sensors.hidden.is_empty() {
724 table.remove("hidden");
725 } else {
726 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
727 table.insert("hidden", toml_edit::value(hidden));
728 }
729 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
730 if sensors.labels.is_empty() {
731 table.remove("labels");
732 } else if inline {
733 let labels: toml_edit::InlineTable = sensors
734 .labels
735 .iter()
736 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
737 .collect();
738 table.insert("labels", toml_edit::value(labels));
739 } else {
740 let mut labels = toml_edit::Table::new();
741 for (key, name) in &sensors.labels {
742 labels[key.as_str()] = toml_edit::value(name.as_str());
743 }
744 table.insert("labels", toml_edit::Item::Table(labels));
745 }
746 if table.is_empty() {
747 document.remove("sensors");
748 }
749 Ok(())
750 })
751 }
752
753 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
755 self.label_for(&fan.key).unwrap_or(&fan.label)
756 }
757
758 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
760 self.label_for(&temperature.key)
761 .unwrap_or(&temperature.label)
762 }
763
764 pub fn label_for(&self, key: &str) -> Option<&str> {
766 self.sensors.labels.get(key).map(String::as_str)
767 }
768
769 pub fn set_label(&mut self, key: &str, name: &str) {
771 let name = name.trim();
772 if name.is_empty() {
773 self.sensors.labels.remove(key);
774 } else {
775 self.sensors.labels.insert(key.to_owned(), name.to_owned());
776 }
777 }
778
779 pub fn is_hidden(&self, key: &str) -> bool {
781 self.sensors.hidden.iter().any(|k| k == key)
782 }
783}
784
785fn edit_file(
790 path: &Path,
791 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
792) -> Result<(), ConfigError> {
793 let io_err = |source| ConfigError::Io {
794 path: path.to_owned(),
795 source,
796 };
797 let text = match fs::read_to_string(path) {
798 Ok(text) => text,
799 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
800 Err(err) => return Err(io_err(err)),
801 };
802 let mut document: toml_edit::DocumentMut =
803 text.parse().map_err(|source| ConfigError::Edit {
804 path: path.to_owned(),
805 source,
806 })?;
807 edit(&mut document)?;
808 let edited = document.to_string();
809 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
811 path: path.to_owned(),
812 source,
813 })?;
814 if let Some(parent) = path.parent() {
815 fs::create_dir_all(parent).map_err(io_err)?;
816 }
817 let tmp = path.with_extension("toml.tmp");
818 fs::write(&tmp, edited).map_err(io_err)?;
819 fs::rename(&tmp, path).map_err(io_err)
820}
821
822const WATCH_INTERVAL: Duration = Duration::from_secs(1);
824
825pub struct ConfigWatcher {
828 path: PathBuf,
829 stamp: Option<(Option<SystemTime>, u64)>,
831 last_check: Instant,
832 interval: Duration,
833}
834
835impl ConfigWatcher {
836 pub fn new(path: PathBuf) -> Self {
838 Self {
839 stamp: file_stamp(&path),
840 path,
841 last_check: Instant::now(),
842 interval: WATCH_INTERVAL,
843 }
844 }
845
846 pub fn path(&self) -> &Path {
848 &self.path
849 }
850
851 pub fn mark_seen(&mut self) {
854 self.stamp = file_stamp(&self.path);
855 }
856
857 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
862 if self.last_check.elapsed() < self.interval {
863 return None;
864 }
865 self.last_check = Instant::now();
866 let stamp = file_stamp(&self.path);
867 if stamp == self.stamp {
868 return None;
869 }
870 self.stamp = stamp;
871 stamp?;
872 Some(Config::load(&self.path))
873 }
874}
875
876fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
878 let metadata = fs::metadata(path).ok()?;
879 Some((metadata.modified().ok(), metadata.len()))
880}
881
882#[cfg(test)]
883mod tests {
884 use super::*;
885
886 #[test]
887 fn hidden_panels_are_saved_and_loaded() {
888 let tmp = tempfile::tempdir().unwrap();
889 let path = tmp.path().join("config.toml");
890 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
891 let tui = TuiConfig {
892 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
893 show_widgets: false,
894 };
895 Config::save_tui(&path, &tui).unwrap();
896 let text = fs::read_to_string(&path).unwrap();
897 assert!(text.starts_with("# mine"), "{text}");
898 let config = Config::load(&path).unwrap();
899 assert_eq!(
900 config.tui.hidden_panels,
901 vec![OverviewSection::Gpu, OverviewSection::Network]
902 );
903 assert!(!config.tui.show_widgets);
904 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
905 Config::save_tui(&path, &TuiConfig::default()).unwrap();
906 let config = Config::load(&path).unwrap();
907 assert_eq!(config.tui, TuiConfig::default());
908 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
909 }
910
911 #[test]
912 fn watch_settings() {
913 let config: Config = toml::from_str(
914 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
915 )
916 .unwrap();
917 assert_eq!(config.watch.command, "notify-send x");
918 assert_eq!(config.watch.level, NotifyLevel::Warning);
919 assert!(config.watch.cleared);
920 assert_eq!(config.watch.interval, Duration::from_secs(30));
921 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
922 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
923 }
924
925 #[test]
926 fn update_checks_can_be_tuned_or_turned_off() {
927 let config: Config =
928 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
929 assert!(config.updates.check);
930 assert_eq!(
931 config.updates.check_interval(),
932 Duration::from_secs(6 * 3600)
933 );
934 assert!(!config.updates.notify);
935 let config: Config =
936 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
937 assert!(!config.updates.check);
938 assert_eq!(
939 config.updates.check_interval(),
940 Duration::from_secs(60),
941 "at least a minute"
942 );
943 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
944 assert!(error.to_string().contains("invalid duration"), "{error}");
945 }
946
947 #[test]
948 fn durations_read_and_write_as_text() {
949 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
950 assert_eq!(duration_text::parse("30m"), minutes(30));
951 assert_eq!(duration_text::parse("1h30m"), minutes(90));
952 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
953 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
954 assert_eq!(duration_text::parse("2H"), minutes(120));
955 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
956 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
957 }
958 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
959 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
960 let text = toml::to_string(&Config::default()).unwrap();
962 assert!(text.contains("interval = \"1h\""), "{text}");
963 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
964 }
965
966 #[test]
967 fn watcher_reports_edits_once() {
968 let tmp = tempfile::tempdir().unwrap();
969 let path = tmp.path().join("config.toml");
970 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
971 let mut watcher = ConfigWatcher::new(path.clone());
972 watcher.interval = Duration::ZERO;
973 assert!(
974 watcher.poll().is_none(),
975 "the initial file counts as loaded"
976 );
977
978 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
980 let config = watcher.poll().unwrap().unwrap();
981 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
982 assert!(watcher.poll().is_none(), "reported once");
983
984 fs::write(&path, "[alerts\n").unwrap();
985 assert!(
986 watcher.poll().unwrap().is_err(),
987 "a broken file is an error"
988 );
989 fs::remove_file(&path).unwrap();
990 assert!(
991 watcher.poll().is_none(),
992 "removing the file changes nothing"
993 );
994 }
995
996 #[test]
997 fn missing_file_gives_defaults() {
998 let tmp = tempfile::tempdir().unwrap();
999 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1000 assert_eq!(config, Config::default());
1001 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1002 }
1003
1004 #[test]
1005 fn parses_labels_hidden_and_smart_dir() {
1006 let tmp = tempfile::tempdir().unwrap();
1007 let path = tmp.path().join("config.toml");
1008 fs::write(
1009 &path,
1010 r#"
1011[sensors]
1012labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1013hidden = ["nct6686:nct6683.2592/temp4"]
1014
1015[smart]
1016dir = "/tmp/smart"
1017
1018[lhm]
1019url = ""
1020
1021[notifications]
1022level = "warning"
1023
1024[alerts]
1025drive_warning_celsius = 65
1026cpu_warning_celsius = 0
1027"#,
1028 )
1029 .unwrap();
1030 let config = Config::load(&path).unwrap();
1031 let fan = Fan {
1032 key: "nct6686:nct6683.2592/fan1".to_owned(),
1033 label: "fan1".to_owned(),
1034 rpm: 900,
1035 };
1036 let other = Fan {
1037 key: "nct6686:nct6683.2592/fan2".to_owned(),
1038 label: "fan2".to_owned(),
1039 rpm: 0,
1040 };
1041 assert_eq!(config.fan_label(&fan), "CPU fan");
1042 assert_eq!(config.fan_label(&other), "fan2");
1044 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1045 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1046 assert_eq!(config.lhm.url, "");
1047 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1048 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1049 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1050 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1051 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1052 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1054 }
1055
1056 #[test]
1057 fn parses_hosts_and_overview() {
1058 let config: Config = toml::from_str(
1059 r#"
1060[overview]
1061sections = ["storage", "cpu"]
1062include_local = false
1063
1064[[hosts]]
1065name = "zefat"
1066
1067[[hosts]]
1068name = "nas"
1069ssh = "admin@nas.local"
1070sections = ["storage"]
1071
1072[[widgets]]
1073name = "Clock"
1074builtin = "clock"
1075
1076[[widgets]]
1077name = "Rates"
1078command = "rates.sh"
1079interval_secs = 60
1080"#,
1081 )
1082 .unwrap();
1083 assert_eq!(
1084 config.overview.sections,
1085 vec![OverviewSection::Storage, OverviewSection::Cpu]
1086 );
1087 assert!(!config.overview.include_local);
1088 assert_eq!(config.overview.processes, 10, "default kept");
1089 assert_eq!(config.hosts.len(), 2);
1090 assert_eq!(config.hosts[0].destination(), "zefat");
1091 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1092 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1093 assert_eq!(config.widgets[1].interval_secs, 60);
1094 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1095 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1096 assert_eq!(
1097 config.hosts[1].sections,
1098 Some(vec![OverviewSection::Storage])
1099 );
1100 let defaults = Config::default();
1102 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1103 assert!(defaults.overview.include_local);
1104 }
1105
1106 #[test]
1107 fn save_hosts_replaces_only_the_hosts() {
1108 let tmp = tempfile::tempdir().unwrap();
1109 let path = tmp.path().join("config.toml");
1110 fs::write(
1111 &path,
1112 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1113 )
1114 .unwrap();
1115 let hosts = vec![
1116 HostConfig::from_destination("zefat"),
1117 HostConfig {
1118 name: "nas".to_owned(),
1119 ssh: Some("admin@nas.local".to_owned()),
1120 command: Some("/opt/rigtop --agent".to_owned()),
1121 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1122 },
1123 ];
1124 Config::save_hosts(&path, &hosts).unwrap();
1125
1126 let text = fs::read_to_string(&path).unwrap();
1127 assert!(
1128 text.contains("# my settings") && text.contains("# keep me"),
1129 "{text}"
1130 );
1131 assert!(!text.contains("\"old\""), "{text}");
1132 let config = Config::load(&path).unwrap();
1133 assert_eq!(config.hosts, hosts);
1134 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1135
1136 Config::save_hosts(&path, &[]).unwrap();
1138 assert!(Config::load(&path).unwrap().hosts.is_empty());
1139 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1140 }
1141
1142 #[test]
1143 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1144 let tmp = tempfile::tempdir().unwrap();
1145 let path = tmp.path().join("config.toml");
1146 fs::write(
1147 &path,
1148 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1149 )
1150 .unwrap();
1151 let mut sensors = SensorConfig::default();
1152 sensors
1153 .labels
1154 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1155 sensors
1156 .labels
1157 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1158 sensors.hidden.push("b:c.1/temp4".to_owned());
1159 Config::save_sensors(&path, &sensors).unwrap();
1160
1161 let text = fs::read_to_string(&path).unwrap();
1162 assert!(text.contains("# my settings"), "{text}");
1163 assert!(text.contains("labels = {"), "{text}");
1165 let config = Config::load(&path).unwrap();
1166 assert_eq!(config.sensors, sensors);
1167 assert_eq!(config.hosts[0].name, "zefat");
1168
1169 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1171 let text = fs::read_to_string(&path).unwrap();
1172 assert!(!text.contains("[sensors]"), "{text}");
1173 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1174
1175 let fresh = tmp.path().join("new.toml");
1177 Config::save_sensors(&fresh, &sensors).unwrap();
1178 let text = fs::read_to_string(&fresh).unwrap();
1179 assert!(text.contains("[sensors.labels]"), "{text}");
1180 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1181 }
1182
1183 #[test]
1184 fn save_hosts_creates_a_missing_file() {
1185 let tmp = tempfile::tempdir().unwrap();
1186 let path = tmp.path().join("new/rigtop/config.toml");
1187 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1188 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1189 }
1190
1191 #[test]
1192 fn save_hosts_refuses_to_touch_a_broken_file() {
1193 let tmp = tempfile::tempdir().unwrap();
1194 let path = tmp.path().join("config.toml");
1195 fs::write(&path, "[sensors\n").unwrap();
1196 let err = Config::save_hosts(&path, &[]).unwrap_err();
1197 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1198 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1199 }
1200
1201 #[test]
1202 fn unknown_fields_are_rejected() {
1203 let tmp = tempfile::tempdir().unwrap();
1204 let path = tmp.path().join("config.toml");
1205 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1207 assert!(matches!(
1208 Config::load(&path),
1209 Err(ConfigError::Parse { .. })
1210 ));
1211 }
1212
1213 #[test]
1214 fn save_and_reload_round_trip() {
1215 let tmp = tempfile::tempdir().unwrap();
1216 let path = tmp.path().join("nested/rigtop/config.toml");
1217 let mut config = Config::default();
1218 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1219 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1220 config.set_label("nct6686:nct6683.2592/fan1", "");
1221 config.save(&path).unwrap();
1222
1223 let back = Config::load(&path).unwrap();
1224 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1225 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1227 assert!(!path.with_extension("toml.tmp").exists());
1228 }
1229}