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