1use std::collections::BTreeMap;
100use std::fs;
101use std::io;
102use std::path::{Path, PathBuf};
103use std::time::{Duration, Instant, SystemTime};
104
105use serde::{Deserialize, Serialize};
106
107use crate::snapshot::{Fan, Temperature};
108
109pub const CONFIG_TEMPLATE: &str = include_str!("config_template.toml");
112
113pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
115pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
117pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
119
120pub const DEFAULT_UPS_ADDRESS: &str = "127.0.0.1:3493";
122
123#[derive(Debug, thiserror::Error)]
125pub enum ConfigError {
126 #[error("cannot access {path}: {source}")]
128 Io {
129 path: PathBuf,
131 source: io::Error,
133 },
134 #[error("invalid config {path}: {source}")]
136 Parse {
137 path: PathBuf,
139 source: toml::de::Error,
141 },
142 #[error("cannot serialize config: {0}")]
144 Serialize(#[from] toml::ser::Error),
145 #[error("invalid alerts of host {host} in {path}: {source}")]
148 HostAlerts {
149 path: PathBuf,
151 host: String,
153 source: Box<toml::de::Error>,
155 },
156 #[error("cannot edit {path}: {source}")]
158 Edit {
159 path: PathBuf,
161 source: toml_edit::TomlError,
163 },
164}
165
166#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
168#[serde(default, deny_unknown_fields)]
169pub struct Config {
170 pub sensors: SensorConfig,
172 pub smart: SmartConfig,
174 pub btrfs: BtrfsConfig,
176 pub lhm: LhmConfig,
179 pub ups: UpsConfig,
181 pub overview: OverviewConfig,
183 pub tui: TuiConfig,
185 pub gpu: GpuConfig,
187 pub network: NetworkConfig,
189 pub alerts: AlertsConfig,
191 pub notifications: NotificationsConfig,
193 pub updates: UpdatesConfig,
195 pub watch: WatchConfig,
197 pub hosts: Vec<HostConfig>,
199 pub widgets: Vec<WidgetConfig>,
201}
202
203#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
205#[serde(rename_all = "lowercase")]
206pub enum BuiltinWidget {
207 Clock,
209}
210
211#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
214#[serde(default, deny_unknown_fields)]
215pub struct WidgetConfig {
216 pub name: String,
218 pub command: Option<String>,
220 pub builtin: Option<BuiltinWidget>,
222 pub interval_secs: u64,
224 pub timeout_secs: u64,
226}
227
228impl Default for WidgetConfig {
229 fn default() -> Self {
230 Self {
231 name: String::new(),
232 command: None,
233 builtin: None,
234 interval_secs: 5,
235 timeout_secs: 10,
236 }
237 }
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
242#[serde(rename_all = "lowercase")]
243pub enum OverviewSection {
244 Cpu,
245 Gpu,
246 Memory,
247 Processes,
248 Network,
249 Storage,
250}
251
252impl OverviewSection {
253 pub const ALL: [OverviewSection; 6] = [
255 OverviewSection::Cpu,
256 OverviewSection::Gpu,
257 OverviewSection::Memory,
258 OverviewSection::Processes,
259 OverviewSection::Network,
260 OverviewSection::Storage,
261 ];
262
263 pub fn label(self) -> &'static str {
265 match self {
266 OverviewSection::Cpu => "CPU",
267 OverviewSection::Gpu => "GPU",
268 OverviewSection::Memory => "Memory",
269 OverviewSection::Processes => "Processes",
270 OverviewSection::Network => "Network",
271 OverviewSection::Storage => "Storage",
272 }
273 }
274}
275
276#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
278#[serde(default, deny_unknown_fields)]
279pub struct TuiConfig {
280 pub hidden_panels: Vec<OverviewSection>,
283 pub show_widgets: bool,
285}
286
287impl Default for TuiConfig {
288 fn default() -> Self {
289 Self {
290 hidden_panels: Vec::new(),
291 show_widgets: true,
292 }
293 }
294}
295
296#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
298#[serde(default, deny_unknown_fields)]
299pub struct OverviewConfig {
300 pub sections: Vec<OverviewSection>,
304 pub include_local: bool,
306 pub processes: usize,
309 pub alerts_first: bool,
313}
314
315impl Default for OverviewConfig {
316 fn default() -> Self {
317 Self {
318 sections: OverviewSection::ALL.to_vec(),
319 include_local: true,
320 processes: 10,
321 alerts_first: false,
322 }
323 }
324}
325
326#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
328#[serde(deny_unknown_fields)]
329pub struct HostConfig {
330 pub name: String,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub ssh: Option<String>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub command: Option<String>,
339 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub sections: Option<Vec<OverviewSection>>,
343 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub alerts: Option<toml::Table>,
348}
349
350impl HostConfig {
351 pub fn from_destination(destination: &str) -> Self {
353 Self {
354 name: destination.to_owned(),
355 ssh: None,
356 command: None,
357 sections: None,
358 alerts: None,
359 }
360 }
361
362 pub fn destination(&self) -> &str {
364 self.ssh.as_deref().unwrap_or(&self.name)
365 }
366}
367
368#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
370#[serde(default, deny_unknown_fields)]
371pub struct SensorConfig {
372 pub labels: BTreeMap<String, String>,
375 pub hidden: Vec<String>,
377}
378
379#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
381#[serde(default, deny_unknown_fields)]
382pub struct SmartConfig {
383 pub dir: PathBuf,
385}
386
387impl Default for SmartConfig {
388 fn default() -> Self {
389 Self {
390 dir: PathBuf::from(DEFAULT_SMART_DIR),
391 }
392 }
393}
394
395#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
397#[serde(rename_all = "lowercase")]
398pub enum NotifyLevel {
399 Off,
401 Warning,
403 #[default]
405 Critical,
406}
407
408#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
412#[serde(default, deny_unknown_fields)]
413pub struct AlertsConfig {
414 pub filesystem_warning_percent: f64,
416 pub filesystem_critical_percent: f64,
418 pub filesystem_full_warning_hours: f64,
421 pub cpu_warning_celsius: f32,
424 pub gpu_warning_celsius: f32,
426 pub drive_warning_celsius: f32,
428 pub memory_warning_percent: f64,
430 pub gpu_memory_warning_percent: f64,
434 pub memory_pressure_warning_percent: f64,
438 pub io_pressure_warning_percent: f64,
442 pub ignored_units: Vec<String>,
445}
446
447impl Default for AlertsConfig {
448 fn default() -> Self {
449 Self {
450 filesystem_warning_percent: 90.0,
451 filesystem_critical_percent: 97.0,
452 filesystem_full_warning_hours: 24.0,
453 cpu_warning_celsius: 96.0,
454 gpu_warning_celsius: 85.0,
455 drive_warning_celsius: 70.0,
456 memory_warning_percent: 90.0,
457 gpu_memory_warning_percent: 0.0,
458 memory_pressure_warning_percent: 10.0,
459 io_pressure_warning_percent: 30.0,
460 ignored_units: Vec::new(),
461 }
462 }
463}
464
465#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
467#[serde(default, deny_unknown_fields)]
468pub struct NetworkConfig {
469 pub show_addresses: bool,
472 pub public_ip: bool,
476 pub public_ip_url: String,
478 #[serde(with = "duration_text")]
480 pub public_ip_interval: Duration,
481}
482
483pub const DEFAULT_PUBLIC_IP_URL: &str = "https://api.ipify.org";
485
486impl Default for NetworkConfig {
487 fn default() -> Self {
488 Self {
489 show_addresses: true,
490 public_ip: false,
491 public_ip_url: DEFAULT_PUBLIC_IP_URL.to_owned(),
492 public_ip_interval: Duration::from_secs(30 * 60),
493 }
494 }
495}
496
497impl NetworkConfig {
498 pub fn public_ip_lookup(&self) -> Option<crate::public_ip::PublicIpLookup> {
500 (self.public_ip && !self.public_ip_url.is_empty()).then(|| {
501 crate::public_ip::PublicIpLookup {
502 url: self.public_ip_url.clone(),
503 interval: self.public_ip_interval,
504 }
505 })
506 }
507}
508
509#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
511#[serde(default, deny_unknown_fields)]
512pub struct GpuConfig {
513 pub integrated: IntegratedGpu,
516}
517
518#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
521#[serde(rename_all = "lowercase")]
522pub enum IntegratedGpu {
523 #[default]
525 Compact,
526 Hide,
528 Full,
530}
531
532#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
534#[serde(default, deny_unknown_fields)]
535pub struct NotificationsConfig {
536 pub level: NotifyLevel,
537}
538
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
541#[serde(default, deny_unknown_fields)]
542pub struct UpdatesConfig {
543 pub check: bool,
545 #[serde(with = "duration_text")]
548 pub interval: Duration,
549 pub notify: bool,
552}
553
554impl Default for UpdatesConfig {
555 fn default() -> Self {
556 Self {
557 check: true,
558 interval: Duration::from_secs(3600),
559 notify: true,
560 }
561 }
562}
563
564impl UpdatesConfig {
565 pub fn check_interval(&self) -> Duration {
567 self.interval.max(Duration::from_secs(60))
568 }
569}
570
571#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
574#[serde(default, deny_unknown_fields)]
575pub struct WatchConfig {
576 pub command: String,
579 pub level: NotifyLevel,
581 pub cleared: bool,
583 #[serde(with = "duration_text")]
585 pub interval: Duration,
586 #[serde(with = "duration_text")]
588 pub offline_after: Duration,
589 #[serde(with = "duration_text")]
591 pub timeout: Duration,
592 pub processes: Vec<String>,
596}
597
598impl Default for WatchConfig {
599 fn default() -> Self {
600 Self {
601 command: String::new(),
602 level: NotifyLevel::Critical,
603 cleared: true,
604 interval: Duration::from_secs(10),
605 offline_after: Duration::from_secs(180),
606 timeout: Duration::from_secs(30),
607 processes: Vec::new(),
608 }
609 }
610}
611
612mod duration_text {
615 use std::time::Duration;
616
617 use serde::de::Error;
618 use serde::{Deserialize, Deserializer, Serializer};
619
620 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
621
622 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
623 serializer.serialize_str(&format(*duration))
624 }
625
626 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
627 let text = String::deserialize(deserializer)?;
628 parse(&text).ok_or_else(|| {
629 D::Error::custom(format!(
630 "invalid duration {text:?}: write a number and s, m, h or d, \
631 e.g. \"30m\", \"6h\" or \"1h30m\""
632 ))
633 })
634 }
635
636 pub fn parse(text: &str) -> Option<Duration> {
638 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
639 if text.is_empty() {
640 return None;
641 }
642 let mut total = 0u64;
643 let mut number = String::new();
644 for c in text.chars() {
645 if c.is_ascii_digit() {
646 number.push(c);
647 continue;
648 }
649 let (_, secs) = UNITS
650 .iter()
651 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
652 let value: u64 = number.parse().ok()?;
653 total = total.checked_add(value.checked_mul(*secs)?)?;
654 number.clear();
655 }
656 number.is_empty().then(|| Duration::from_secs(total))
658 }
659
660 pub fn format(duration: Duration) -> String {
662 let mut secs = duration.as_secs();
663 if secs == 0 {
664 return "0s".to_owned();
665 }
666 let mut text = String::new();
667 for (unit, size) in UNITS {
668 if secs >= size {
669 text.push_str(&format!("{}{unit}", secs / size));
670 secs %= size;
671 }
672 }
673 text
674 }
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
679#[serde(default, deny_unknown_fields)]
680pub struct LhmConfig {
681 pub url: String,
684}
685
686impl Default for LhmConfig {
687 fn default() -> Self {
688 Self {
689 url: DEFAULT_LHM_URL.to_owned(),
690 }
691 }
692}
693
694#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
696#[serde(default, deny_unknown_fields)]
697pub struct UpsConfig {
698 pub address: String,
701}
702
703impl Default for UpsConfig {
704 fn default() -> Self {
705 Self {
706 address: DEFAULT_UPS_ADDRESS.to_owned(),
707 }
708 }
709}
710
711#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
713#[serde(default, deny_unknown_fields)]
714pub struct BtrfsConfig {
715 pub scrub_dir: PathBuf,
717}
718
719impl Default for BtrfsConfig {
720 fn default() -> Self {
721 Self {
722 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
723 }
724 }
725}
726
727pub fn create_if_missing(path: &Path) -> io::Result<bool> {
730 if path.exists() {
731 return Ok(false);
732 }
733 if let Some(dir) = path.parent() {
734 fs::create_dir_all(dir)?;
735 }
736 fs::write(path, CONFIG_TEMPLATE)?;
737 Ok(true)
738}
739
740impl Config {
741 pub fn default_path() -> Option<PathBuf> {
743 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
744 }
745
746 pub fn load(path: &Path) -> Result<Self, ConfigError> {
748 let text = match fs::read_to_string(path) {
749 Ok(text) => text,
750 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
751 Err(source) => {
752 return Err(ConfigError::Io {
753 path: path.to_owned(),
754 source,
755 });
756 }
757 };
758 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
759 path: path.to_owned(),
760 source,
761 })?;
762 for host in &config.hosts {
764 config
765 .alerts_for(host)
766 .map_err(|source| ConfigError::HostAlerts {
767 path: path.to_owned(),
768 host: host.name.clone(),
769 source: Box::new(source),
770 })?;
771 }
772 Ok(config)
773 }
774
775 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
778 let Some(overrides) = &host.alerts else {
779 return Ok(self.alerts.clone());
780 };
781 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
783 merged.extend(overrides.clone());
784 merged.try_into()
785 }
786
787 pub fn load_default() -> Result<Self, ConfigError> {
790 match Self::default_path() {
791 Some(path) => Self::load(&path),
792 None => Ok(Self::default()),
793 }
794 }
795
796 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
801 let text = toml::to_string_pretty(self)?;
802 let io_err = |source| ConfigError::Io {
803 path: path.to_owned(),
804 source,
805 };
806 if let Some(parent) = path.parent() {
807 fs::create_dir_all(parent).map_err(io_err)?;
808 }
809 let tmp = path.with_extension("toml.tmp");
810 fs::write(&tmp, text).map_err(io_err)?;
811 fs::rename(&tmp, path).map_err(io_err)
812 }
813
814 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
821 edit_file(path, |document| {
822 if hosts.is_empty() {
823 document.remove("hosts");
824 return Ok(());
825 }
826 let mut tables = toml_edit::ArrayOfTables::new();
827 for host in hosts {
828 let mut table = toml_edit::Table::new();
829 table["name"] = toml_edit::value(host.name.as_str());
830 if let Some(ssh) = &host.ssh {
831 table["ssh"] = toml_edit::value(ssh.as_str());
832 }
833 if let Some(command) = &host.command {
834 table["command"] = toml_edit::value(command.as_str());
835 }
836 if let Some(sections) = &host.sections {
837 let mut array = toml_edit::Array::new();
838 for section in sections {
839 let name = toml::Value::try_from(section)?;
841 if let Some(name) = name.as_str() {
842 array.push(name);
843 }
844 }
845 table["sections"] = toml_edit::value(array);
846 }
847 if let Some(alerts) = &host.alerts {
848 let mut sub = toml_edit::Table::new();
850 for (key, value) in alerts {
851 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
852 sub[key.as_str()] = toml_edit::value(value);
853 }
854 }
855 table["alerts"] = toml_edit::Item::Table(sub);
856 }
857 tables.push(table);
858 }
859 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
860 Ok(())
861 })
862 }
863
864 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
873 edit_file(path, |document| {
874 if !document.contains_key("tui") {
875 if *tui == TuiConfig::default() {
876 return Ok(());
877 }
878 document["tui"] = toml_edit::table();
879 }
880 let Some(table) = document["tui"].as_table_like_mut() else {
881 return Ok(());
883 };
884 if tui.hidden_panels.is_empty() {
885 table.remove("hidden_panels");
886 } else {
887 let names: toml_edit::Array = tui
888 .hidden_panels
889 .iter()
890 .filter_map(|section| {
891 serde_json::to_value(section)
892 .ok()
893 .and_then(|v| v.as_str().map(str::to_owned))
894 })
895 .collect();
896 table.insert("hidden_panels", toml_edit::value(names));
897 }
898 if tui.show_widgets {
899 table.remove("show_widgets");
900 } else {
901 table.insert("show_widgets", toml_edit::value(false));
902 }
903 Ok(())
904 })
905 }
906
907 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
908 edit_file(path, |document| {
909 if !document.contains_key("sensors") {
910 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
911 return Ok(());
912 }
913 document["sensors"] = toml_edit::table();
914 }
915 let Some(table) = document["sensors"].as_table_like_mut() else {
916 return Ok(());
918 };
919 if sensors.hidden.is_empty() {
920 table.remove("hidden");
921 } else {
922 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
923 table.insert("hidden", toml_edit::value(hidden));
924 }
925 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
926 if sensors.labels.is_empty() {
927 table.remove("labels");
928 } else if inline {
929 let labels: toml_edit::InlineTable = sensors
930 .labels
931 .iter()
932 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
933 .collect();
934 table.insert("labels", toml_edit::value(labels));
935 } else {
936 let mut labels = toml_edit::Table::new();
937 for (key, name) in &sensors.labels {
938 labels[key.as_str()] = toml_edit::value(name.as_str());
939 }
940 table.insert("labels", toml_edit::Item::Table(labels));
941 }
942 if table.is_empty() {
943 document.remove("sensors");
944 }
945 Ok(())
946 })
947 }
948
949 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
951 self.label_for(&fan.key).unwrap_or(&fan.label)
952 }
953
954 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
956 self.label_for(&temperature.key)
957 .unwrap_or(&temperature.label)
958 }
959
960 pub fn label_for(&self, key: &str) -> Option<&str> {
962 self.sensors.labels.get(key).map(String::as_str)
963 }
964
965 pub fn set_label(&mut self, key: &str, name: &str) {
967 let name = name.trim();
968 if name.is_empty() {
969 self.sensors.labels.remove(key);
970 } else {
971 self.sensors.labels.insert(key.to_owned(), name.to_owned());
972 }
973 }
974
975 pub fn is_hidden(&self, key: &str) -> bool {
977 self.sensors.hidden.iter().any(|k| k == key)
978 }
979}
980
981fn edit_file(
986 path: &Path,
987 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
988) -> Result<(), ConfigError> {
989 let io_err = |source| ConfigError::Io {
990 path: path.to_owned(),
991 source,
992 };
993 let text = match fs::read_to_string(path) {
994 Ok(text) => text,
995 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
996 Err(err) => return Err(io_err(err)),
997 };
998 let mut document: toml_edit::DocumentMut =
999 text.parse().map_err(|source| ConfigError::Edit {
1000 path: path.to_owned(),
1001 source,
1002 })?;
1003 edit(&mut document)?;
1004 let edited = document.to_string();
1005 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
1007 path: path.to_owned(),
1008 source,
1009 })?;
1010 if let Some(parent) = path.parent() {
1011 fs::create_dir_all(parent).map_err(io_err)?;
1012 }
1013 let tmp = path.with_extension("toml.tmp");
1014 fs::write(&tmp, edited).map_err(io_err)?;
1015 fs::rename(&tmp, path).map_err(io_err)
1016}
1017
1018const WATCH_INTERVAL: Duration = Duration::from_secs(1);
1020
1021pub struct ConfigWatcher {
1024 path: PathBuf,
1025 stamp: Option<(Option<SystemTime>, u64)>,
1027 last_check: Instant,
1028 interval: Duration,
1029}
1030
1031impl ConfigWatcher {
1032 pub fn new(path: PathBuf) -> Self {
1034 Self {
1035 stamp: file_stamp(&path),
1036 path,
1037 last_check: Instant::now(),
1038 interval: WATCH_INTERVAL,
1039 }
1040 }
1041
1042 pub fn path(&self) -> &Path {
1044 &self.path
1045 }
1046
1047 pub fn mark_seen(&mut self) {
1050 self.stamp = file_stamp(&self.path);
1051 }
1052
1053 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
1058 if self.last_check.elapsed() < self.interval {
1059 return None;
1060 }
1061 self.last_check = Instant::now();
1062 let stamp = file_stamp(&self.path);
1063 if stamp == self.stamp {
1064 return None;
1065 }
1066 self.stamp = stamp;
1067 stamp?;
1068 Some(Config::load(&self.path))
1069 }
1070}
1071
1072fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
1074 let metadata = fs::metadata(path).ok()?;
1075 Some((metadata.modified().ok(), metadata.len()))
1076}
1077
1078#[cfg(test)]
1079mod tests {
1080 use super::*;
1081
1082 #[test]
1083 fn template_parses_with_every_setting_uncommented() {
1084 let settings: String = CONFIG_TEMPLATE
1086 .lines()
1087 .filter_map(|line| line.strip_prefix('#'))
1088 .filter(|line| !line.starts_with(' ') && !line.is_empty())
1089 .map(|line| format!("{line}\n"))
1090 .collect();
1091 let config: Config = toml::from_str(&settings).unwrap();
1092 assert_eq!(config.hosts[0].name, "nas");
1093 assert_eq!(config.alerts, AlertsConfig::default());
1095 assert_eq!(config.watch, WatchConfig::default());
1096 assert_eq!(config.overview, OverviewConfig::default());
1097 assert_eq!(config.ups, UpsConfig::default());
1098 let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1100 assert_eq!(empty, Config::default());
1101
1102 let tmp = tempfile::tempdir().unwrap();
1103 let path = tmp.path().join("rigtop/config.toml");
1104 assert!(create_if_missing(&path).unwrap());
1105 assert!(!create_if_missing(&path).unwrap(), "left as it is");
1106 assert_eq!(Config::load(&path).unwrap(), Config::default());
1107 }
1108
1109 #[test]
1110 fn hidden_panels_are_saved_and_loaded() {
1111 let tmp = tempfile::tempdir().unwrap();
1112 let path = tmp.path().join("config.toml");
1113 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1114 let tui = TuiConfig {
1115 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1116 show_widgets: false,
1117 };
1118 Config::save_tui(&path, &tui).unwrap();
1119 let text = fs::read_to_string(&path).unwrap();
1120 assert!(text.starts_with("# mine"), "{text}");
1121 let config = Config::load(&path).unwrap();
1122 assert_eq!(
1123 config.tui.hidden_panels,
1124 vec![OverviewSection::Gpu, OverviewSection::Network]
1125 );
1126 assert!(!config.tui.show_widgets);
1127 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1128 Config::save_tui(&path, &TuiConfig::default()).unwrap();
1129 let config = Config::load(&path).unwrap();
1130 assert_eq!(config.tui, TuiConfig::default());
1131 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1132 }
1133
1134 #[test]
1135 fn watch_settings() {
1136 let config: Config = toml::from_str(
1137 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1138 )
1139 .unwrap();
1140 assert_eq!(config.watch.command, "notify-send x");
1141 assert_eq!(config.watch.level, NotifyLevel::Warning);
1142 assert!(config.watch.cleared);
1143 assert_eq!(config.watch.interval, Duration::from_secs(30));
1144 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1145 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1146 }
1147
1148 #[test]
1149 fn update_checks_can_be_tuned_or_turned_off() {
1150 let config: Config =
1151 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1152 assert!(config.updates.check);
1153 assert_eq!(
1154 config.updates.check_interval(),
1155 Duration::from_secs(6 * 3600)
1156 );
1157 assert!(!config.updates.notify);
1158 let config: Config =
1159 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1160 assert!(!config.updates.check);
1161 assert_eq!(
1162 config.updates.check_interval(),
1163 Duration::from_secs(60),
1164 "at least a minute"
1165 );
1166 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1167 assert!(error.to_string().contains("invalid duration"), "{error}");
1168 }
1169
1170 #[test]
1171 fn durations_read_and_write_as_text() {
1172 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1173 assert_eq!(duration_text::parse("30m"), minutes(30));
1174 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1175 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1176 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1177 assert_eq!(duration_text::parse("2H"), minutes(120));
1178 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1179 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1180 }
1181 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1182 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1183 let text = toml::to_string(&Config::default()).unwrap();
1185 assert!(text.contains("interval = \"1h\""), "{text}");
1186 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1187 }
1188
1189 #[test]
1190 fn watcher_reports_edits_once() {
1191 let tmp = tempfile::tempdir().unwrap();
1192 let path = tmp.path().join("config.toml");
1193 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1194 let mut watcher = ConfigWatcher::new(path.clone());
1195 watcher.interval = Duration::ZERO;
1196 assert!(
1197 watcher.poll().is_none(),
1198 "the initial file counts as loaded"
1199 );
1200
1201 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1203 let config = watcher.poll().unwrap().unwrap();
1204 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1205 assert!(watcher.poll().is_none(), "reported once");
1206
1207 fs::write(&path, "[alerts\n").unwrap();
1208 assert!(
1209 watcher.poll().unwrap().is_err(),
1210 "a broken file is an error"
1211 );
1212 fs::remove_file(&path).unwrap();
1213 assert!(
1214 watcher.poll().is_none(),
1215 "removing the file changes nothing"
1216 );
1217 }
1218
1219 #[test]
1220 fn missing_file_gives_defaults() {
1221 let tmp = tempfile::tempdir().unwrap();
1222 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1223 assert_eq!(config, Config::default());
1224 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1225 }
1226
1227 #[test]
1228 fn parses_labels_hidden_and_smart_dir() {
1229 let tmp = tempfile::tempdir().unwrap();
1230 let path = tmp.path().join("config.toml");
1231 fs::write(
1232 &path,
1233 r#"
1234[sensors]
1235labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1236hidden = ["nct6686:nct6683.2592/temp4"]
1237
1238[smart]
1239dir = "/tmp/smart"
1240
1241[lhm]
1242url = ""
1243
1244[notifications]
1245level = "warning"
1246
1247[alerts]
1248drive_warning_celsius = 65
1249cpu_warning_celsius = 0
1250gpu_memory_warning_percent = 98
1251"#,
1252 )
1253 .unwrap();
1254 let config = Config::load(&path).unwrap();
1255 let fan = Fan {
1256 key: "nct6686:nct6683.2592/fan1".to_owned(),
1257 label: "fan1".to_owned(),
1258 rpm: 900,
1259 };
1260 let other = Fan {
1261 key: "nct6686:nct6683.2592/fan2".to_owned(),
1262 label: "fan2".to_owned(),
1263 rpm: 0,
1264 };
1265 assert_eq!(config.fan_label(&fan), "CPU fan");
1266 assert_eq!(config.fan_label(&other), "fan2");
1268 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1269 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1270 assert_eq!(config.lhm.url, "");
1271 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1272 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1273 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1274 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1275 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1276 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1278 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1279 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1280 }
1281
1282 #[test]
1283 fn parses_hosts_and_overview() {
1284 let config: Config = toml::from_str(
1285 r#"
1286[overview]
1287sections = ["storage", "cpu"]
1288include_local = false
1289
1290[[hosts]]
1291name = "zefat"
1292
1293[[hosts]]
1294name = "nas"
1295ssh = "admin@nas.local"
1296sections = ["storage"]
1297
1298[[widgets]]
1299name = "Clock"
1300builtin = "clock"
1301
1302[[widgets]]
1303name = "Rates"
1304command = "rates.sh"
1305interval_secs = 60
1306"#,
1307 )
1308 .unwrap();
1309 assert_eq!(
1310 config.overview.sections,
1311 vec![OverviewSection::Storage, OverviewSection::Cpu]
1312 );
1313 assert!(!config.overview.include_local);
1314 assert_eq!(config.overview.processes, 10, "default kept");
1315 assert_eq!(config.hosts.len(), 2);
1316 assert_eq!(config.hosts[0].destination(), "zefat");
1317 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1318 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1319 assert_eq!(config.widgets[1].interval_secs, 60);
1320 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1321 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1322 assert_eq!(
1323 config.hosts[1].sections,
1324 Some(vec![OverviewSection::Storage])
1325 );
1326 let defaults = Config::default();
1328 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1329 assert!(defaults.overview.include_local);
1330 }
1331
1332 #[test]
1333 fn save_hosts_replaces_only_the_hosts() {
1334 let tmp = tempfile::tempdir().unwrap();
1335 let path = tmp.path().join("config.toml");
1336 fs::write(
1337 &path,
1338 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1339 )
1340 .unwrap();
1341 let hosts = vec![
1342 HostConfig::from_destination("zefat"),
1343 HostConfig {
1344 name: "nas".to_owned(),
1345 ssh: Some("admin@nas.local".to_owned()),
1346 command: Some("/opt/rigtop --agent".to_owned()),
1347 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1348 alerts: Some(
1349 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1350 .unwrap(),
1351 ),
1352 },
1353 ];
1354 Config::save_hosts(&path, &hosts).unwrap();
1355
1356 let text = fs::read_to_string(&path).unwrap();
1357 assert!(
1358 text.contains("# my settings") && text.contains("# keep me"),
1359 "{text}"
1360 );
1361 assert!(!text.contains("\"old\""), "{text}");
1362 let config = Config::load(&path).unwrap();
1363 assert_eq!(config.hosts, hosts);
1364 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1365
1366 Config::save_hosts(&path, &[]).unwrap();
1368 assert!(Config::load(&path).unwrap().hosts.is_empty());
1369 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1370 }
1371
1372 #[test]
1373 fn host_alerts_replace_the_keys_they_give() {
1374 let tmp = tempfile::tempdir().unwrap();
1375 let path = tmp.path().join("config.toml");
1376 fs::write(
1377 &path,
1378 "[alerts]\nmemory_warning_percent = 80\n\n\
1379 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1380 [[hosts]]\nname = \"nas\"\n",
1381 )
1382 .unwrap();
1383 let config = Config::load(&path).unwrap();
1384 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1385 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1386 assert_eq!(laptop.memory_warning_percent, 80.0);
1388 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1389 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1390
1391 fs::write(
1393 &path,
1394 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1395 )
1396 .unwrap();
1397 let err = Config::load(&path).unwrap_err().to_string();
1398 assert!(
1399 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1400 "{err}"
1401 );
1402 }
1403
1404 #[test]
1405 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1406 let tmp = tempfile::tempdir().unwrap();
1407 let path = tmp.path().join("config.toml");
1408 fs::write(
1409 &path,
1410 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1411 )
1412 .unwrap();
1413 let mut sensors = SensorConfig::default();
1414 sensors
1415 .labels
1416 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1417 sensors
1418 .labels
1419 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1420 sensors.hidden.push("b:c.1/temp4".to_owned());
1421 Config::save_sensors(&path, &sensors).unwrap();
1422
1423 let text = fs::read_to_string(&path).unwrap();
1424 assert!(text.contains("# my settings"), "{text}");
1425 assert!(text.contains("labels = {"), "{text}");
1427 let config = Config::load(&path).unwrap();
1428 assert_eq!(config.sensors, sensors);
1429 assert_eq!(config.hosts[0].name, "zefat");
1430
1431 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1433 let text = fs::read_to_string(&path).unwrap();
1434 assert!(!text.contains("[sensors]"), "{text}");
1435 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1436
1437 let fresh = tmp.path().join("new.toml");
1439 Config::save_sensors(&fresh, &sensors).unwrap();
1440 let text = fs::read_to_string(&fresh).unwrap();
1441 assert!(text.contains("[sensors.labels]"), "{text}");
1442 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1443 }
1444
1445 #[test]
1446 fn save_hosts_creates_a_missing_file() {
1447 let tmp = tempfile::tempdir().unwrap();
1448 let path = tmp.path().join("new/rigtop/config.toml");
1449 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1450 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1451 }
1452
1453 #[test]
1454 fn save_hosts_refuses_to_touch_a_broken_file() {
1455 let tmp = tempfile::tempdir().unwrap();
1456 let path = tmp.path().join("config.toml");
1457 fs::write(&path, "[sensors\n").unwrap();
1458 let err = Config::save_hosts(&path, &[]).unwrap_err();
1459 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1460 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1461 }
1462
1463 #[test]
1464 fn unknown_fields_are_rejected() {
1465 let tmp = tempfile::tempdir().unwrap();
1466 let path = tmp.path().join("config.toml");
1467 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1469 assert!(matches!(
1470 Config::load(&path),
1471 Err(ConfigError::Parse { .. })
1472 ));
1473 }
1474
1475 #[test]
1476 fn save_and_reload_round_trip() {
1477 let tmp = tempfile::tempdir().unwrap();
1478 let path = tmp.path().join("nested/rigtop/config.toml");
1479 let mut config = Config::default();
1480 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1481 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1482 config.set_label("nct6686:nct6683.2592/fan1", "");
1483 config.save(&path).unwrap();
1484
1485 let back = Config::load(&path).unwrap();
1486 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1487 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1489 assert!(!path.with_extension("toml.tmp").exists());
1490 }
1491}