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
120#[derive(Debug, thiserror::Error)]
122pub enum ConfigError {
123 #[error("cannot access {path}: {source}")]
125 Io {
126 path: PathBuf,
128 source: io::Error,
130 },
131 #[error("invalid config {path}: {source}")]
133 Parse {
134 path: PathBuf,
136 source: toml::de::Error,
138 },
139 #[error("cannot serialize config: {0}")]
141 Serialize(#[from] toml::ser::Error),
142 #[error("invalid alerts of host {host} in {path}: {source}")]
145 HostAlerts {
146 path: PathBuf,
148 host: String,
150 source: Box<toml::de::Error>,
152 },
153 #[error("cannot edit {path}: {source}")]
155 Edit {
156 path: PathBuf,
158 source: toml_edit::TomlError,
160 },
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct Config {
167 pub sensors: SensorConfig,
169 pub smart: SmartConfig,
171 pub btrfs: BtrfsConfig,
173 pub lhm: LhmConfig,
176 pub overview: OverviewConfig,
178 pub tui: TuiConfig,
180 pub gpu: GpuConfig,
182 pub network: NetworkConfig,
184 pub alerts: AlertsConfig,
186 pub notifications: NotificationsConfig,
188 pub updates: UpdatesConfig,
190 pub watch: WatchConfig,
192 pub hosts: Vec<HostConfig>,
194 pub widgets: Vec<WidgetConfig>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "lowercase")]
201pub enum BuiltinWidget {
202 Clock,
204}
205
206#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
209#[serde(default, deny_unknown_fields)]
210pub struct WidgetConfig {
211 pub name: String,
213 pub command: Option<String>,
215 pub builtin: Option<BuiltinWidget>,
217 pub interval_secs: u64,
219 pub timeout_secs: u64,
221}
222
223impl Default for WidgetConfig {
224 fn default() -> Self {
225 Self {
226 name: String::new(),
227 command: None,
228 builtin: None,
229 interval_secs: 5,
230 timeout_secs: 10,
231 }
232 }
233}
234
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
237#[serde(rename_all = "lowercase")]
238pub enum OverviewSection {
239 Cpu,
240 Gpu,
241 Memory,
242 Processes,
243 Network,
244 Storage,
245}
246
247impl OverviewSection {
248 pub const ALL: [OverviewSection; 6] = [
250 OverviewSection::Cpu,
251 OverviewSection::Gpu,
252 OverviewSection::Memory,
253 OverviewSection::Processes,
254 OverviewSection::Network,
255 OverviewSection::Storage,
256 ];
257
258 pub fn label(self) -> &'static str {
260 match self {
261 OverviewSection::Cpu => "CPU",
262 OverviewSection::Gpu => "GPU",
263 OverviewSection::Memory => "Memory",
264 OverviewSection::Processes => "Processes",
265 OverviewSection::Network => "Network",
266 OverviewSection::Storage => "Storage",
267 }
268 }
269}
270
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
273#[serde(default, deny_unknown_fields)]
274pub struct TuiConfig {
275 pub hidden_panels: Vec<OverviewSection>,
278 pub show_widgets: bool,
280}
281
282impl Default for TuiConfig {
283 fn default() -> Self {
284 Self {
285 hidden_panels: Vec::new(),
286 show_widgets: true,
287 }
288 }
289}
290
291#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
293#[serde(default, deny_unknown_fields)]
294pub struct OverviewConfig {
295 pub sections: Vec<OverviewSection>,
299 pub include_local: bool,
301 pub processes: usize,
304 pub alerts_first: bool,
308}
309
310impl Default for OverviewConfig {
311 fn default() -> Self {
312 Self {
313 sections: OverviewSection::ALL.to_vec(),
314 include_local: true,
315 processes: 10,
316 alerts_first: false,
317 }
318 }
319}
320
321#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
323#[serde(deny_unknown_fields)]
324pub struct HostConfig {
325 pub name: String,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub ssh: Option<String>,
331 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub command: Option<String>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub sections: Option<Vec<OverviewSection>>,
338 #[serde(default, skip_serializing_if = "Option::is_none")]
342 pub alerts: Option<toml::Table>,
343}
344
345impl HostConfig {
346 pub fn from_destination(destination: &str) -> Self {
348 Self {
349 name: destination.to_owned(),
350 ssh: None,
351 command: None,
352 sections: None,
353 alerts: None,
354 }
355 }
356
357 pub fn destination(&self) -> &str {
359 self.ssh.as_deref().unwrap_or(&self.name)
360 }
361}
362
363#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
365#[serde(default, deny_unknown_fields)]
366pub struct SensorConfig {
367 pub labels: BTreeMap<String, String>,
370 pub hidden: Vec<String>,
372}
373
374#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
376#[serde(default, deny_unknown_fields)]
377pub struct SmartConfig {
378 pub dir: PathBuf,
380}
381
382impl Default for SmartConfig {
383 fn default() -> Self {
384 Self {
385 dir: PathBuf::from(DEFAULT_SMART_DIR),
386 }
387 }
388}
389
390#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
392#[serde(rename_all = "lowercase")]
393pub enum NotifyLevel {
394 Off,
396 Warning,
398 #[default]
400 Critical,
401}
402
403#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
407#[serde(default, deny_unknown_fields)]
408pub struct AlertsConfig {
409 pub filesystem_warning_percent: f64,
411 pub filesystem_critical_percent: f64,
413 pub filesystem_full_warning_hours: f64,
416 pub cpu_warning_celsius: f32,
419 pub gpu_warning_celsius: f32,
421 pub drive_warning_celsius: f32,
423 pub memory_warning_percent: f64,
425 pub gpu_memory_warning_percent: f64,
429 pub memory_pressure_warning_percent: f64,
433 pub io_pressure_warning_percent: f64,
437 pub ignored_units: Vec<String>,
440}
441
442impl Default for AlertsConfig {
443 fn default() -> Self {
444 Self {
445 filesystem_warning_percent: 90.0,
446 filesystem_critical_percent: 97.0,
447 filesystem_full_warning_hours: 24.0,
448 cpu_warning_celsius: 96.0,
449 gpu_warning_celsius: 85.0,
450 drive_warning_celsius: 70.0,
451 memory_warning_percent: 90.0,
452 gpu_memory_warning_percent: 0.0,
453 memory_pressure_warning_percent: 10.0,
454 io_pressure_warning_percent: 30.0,
455 ignored_units: Vec::new(),
456 }
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
462#[serde(default, deny_unknown_fields)]
463pub struct NetworkConfig {
464 pub show_addresses: bool,
467 pub public_ip: bool,
471 pub public_ip_url: String,
473 #[serde(with = "duration_text")]
475 pub public_ip_interval: Duration,
476}
477
478pub const DEFAULT_PUBLIC_IP_URL: &str = "https://api.ipify.org";
480
481impl Default for NetworkConfig {
482 fn default() -> Self {
483 Self {
484 show_addresses: true,
485 public_ip: false,
486 public_ip_url: DEFAULT_PUBLIC_IP_URL.to_owned(),
487 public_ip_interval: Duration::from_secs(30 * 60),
488 }
489 }
490}
491
492impl NetworkConfig {
493 pub fn public_ip_lookup(&self) -> Option<crate::public_ip::PublicIpLookup> {
495 (self.public_ip && !self.public_ip_url.is_empty()).then(|| {
496 crate::public_ip::PublicIpLookup {
497 url: self.public_ip_url.clone(),
498 interval: self.public_ip_interval,
499 }
500 })
501 }
502}
503
504#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
506#[serde(default, deny_unknown_fields)]
507pub struct GpuConfig {
508 pub integrated: IntegratedGpu,
511}
512
513#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
516#[serde(rename_all = "lowercase")]
517pub enum IntegratedGpu {
518 #[default]
520 Compact,
521 Hide,
523 Full,
525}
526
527#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
529#[serde(default, deny_unknown_fields)]
530pub struct NotificationsConfig {
531 pub level: NotifyLevel,
532}
533
534#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
536#[serde(default, deny_unknown_fields)]
537pub struct UpdatesConfig {
538 pub check: bool,
540 #[serde(with = "duration_text")]
543 pub interval: Duration,
544 pub notify: bool,
547}
548
549impl Default for UpdatesConfig {
550 fn default() -> Self {
551 Self {
552 check: true,
553 interval: Duration::from_secs(3600),
554 notify: true,
555 }
556 }
557}
558
559impl UpdatesConfig {
560 pub fn check_interval(&self) -> Duration {
562 self.interval.max(Duration::from_secs(60))
563 }
564}
565
566#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
569#[serde(default, deny_unknown_fields)]
570pub struct WatchConfig {
571 pub command: String,
574 pub level: NotifyLevel,
576 pub cleared: bool,
578 #[serde(with = "duration_text")]
580 pub interval: Duration,
581 #[serde(with = "duration_text")]
583 pub offline_after: Duration,
584 #[serde(with = "duration_text")]
586 pub timeout: Duration,
587 pub processes: Vec<String>,
591}
592
593impl Default for WatchConfig {
594 fn default() -> Self {
595 Self {
596 command: String::new(),
597 level: NotifyLevel::Critical,
598 cleared: true,
599 interval: Duration::from_secs(10),
600 offline_after: Duration::from_secs(180),
601 timeout: Duration::from_secs(30),
602 processes: Vec::new(),
603 }
604 }
605}
606
607mod duration_text {
610 use std::time::Duration;
611
612 use serde::de::Error;
613 use serde::{Deserialize, Deserializer, Serializer};
614
615 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
616
617 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
618 serializer.serialize_str(&format(*duration))
619 }
620
621 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
622 let text = String::deserialize(deserializer)?;
623 parse(&text).ok_or_else(|| {
624 D::Error::custom(format!(
625 "invalid duration {text:?}: write a number and s, m, h or d, \
626 e.g. \"30m\", \"6h\" or \"1h30m\""
627 ))
628 })
629 }
630
631 pub fn parse(text: &str) -> Option<Duration> {
633 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
634 if text.is_empty() {
635 return None;
636 }
637 let mut total = 0u64;
638 let mut number = String::new();
639 for c in text.chars() {
640 if c.is_ascii_digit() {
641 number.push(c);
642 continue;
643 }
644 let (_, secs) = UNITS
645 .iter()
646 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
647 let value: u64 = number.parse().ok()?;
648 total = total.checked_add(value.checked_mul(*secs)?)?;
649 number.clear();
650 }
651 number.is_empty().then(|| Duration::from_secs(total))
653 }
654
655 pub fn format(duration: Duration) -> String {
657 let mut secs = duration.as_secs();
658 if secs == 0 {
659 return "0s".to_owned();
660 }
661 let mut text = String::new();
662 for (unit, size) in UNITS {
663 if secs >= size {
664 text.push_str(&format!("{}{unit}", secs / size));
665 secs %= size;
666 }
667 }
668 text
669 }
670}
671
672#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
674#[serde(default, deny_unknown_fields)]
675pub struct LhmConfig {
676 pub url: String,
679}
680
681impl Default for LhmConfig {
682 fn default() -> Self {
683 Self {
684 url: DEFAULT_LHM_URL.to_owned(),
685 }
686 }
687}
688
689#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
691#[serde(default, deny_unknown_fields)]
692pub struct BtrfsConfig {
693 pub scrub_dir: PathBuf,
695}
696
697impl Default for BtrfsConfig {
698 fn default() -> Self {
699 Self {
700 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
701 }
702 }
703}
704
705pub fn create_if_missing(path: &Path) -> io::Result<bool> {
708 if path.exists() {
709 return Ok(false);
710 }
711 if let Some(dir) = path.parent() {
712 fs::create_dir_all(dir)?;
713 }
714 fs::write(path, CONFIG_TEMPLATE)?;
715 Ok(true)
716}
717
718impl Config {
719 pub fn default_path() -> Option<PathBuf> {
721 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
722 }
723
724 pub fn load(path: &Path) -> Result<Self, ConfigError> {
726 let text = match fs::read_to_string(path) {
727 Ok(text) => text,
728 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
729 Err(source) => {
730 return Err(ConfigError::Io {
731 path: path.to_owned(),
732 source,
733 });
734 }
735 };
736 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
737 path: path.to_owned(),
738 source,
739 })?;
740 for host in &config.hosts {
742 config
743 .alerts_for(host)
744 .map_err(|source| ConfigError::HostAlerts {
745 path: path.to_owned(),
746 host: host.name.clone(),
747 source: Box::new(source),
748 })?;
749 }
750 Ok(config)
751 }
752
753 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
756 let Some(overrides) = &host.alerts else {
757 return Ok(self.alerts.clone());
758 };
759 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
761 merged.extend(overrides.clone());
762 merged.try_into()
763 }
764
765 pub fn load_default() -> Result<Self, ConfigError> {
768 match Self::default_path() {
769 Some(path) => Self::load(&path),
770 None => Ok(Self::default()),
771 }
772 }
773
774 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
779 let text = toml::to_string_pretty(self)?;
780 let io_err = |source| ConfigError::Io {
781 path: path.to_owned(),
782 source,
783 };
784 if let Some(parent) = path.parent() {
785 fs::create_dir_all(parent).map_err(io_err)?;
786 }
787 let tmp = path.with_extension("toml.tmp");
788 fs::write(&tmp, text).map_err(io_err)?;
789 fs::rename(&tmp, path).map_err(io_err)
790 }
791
792 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
799 edit_file(path, |document| {
800 if hosts.is_empty() {
801 document.remove("hosts");
802 return Ok(());
803 }
804 let mut tables = toml_edit::ArrayOfTables::new();
805 for host in hosts {
806 let mut table = toml_edit::Table::new();
807 table["name"] = toml_edit::value(host.name.as_str());
808 if let Some(ssh) = &host.ssh {
809 table["ssh"] = toml_edit::value(ssh.as_str());
810 }
811 if let Some(command) = &host.command {
812 table["command"] = toml_edit::value(command.as_str());
813 }
814 if let Some(sections) = &host.sections {
815 let mut array = toml_edit::Array::new();
816 for section in sections {
817 let name = toml::Value::try_from(section)?;
819 if let Some(name) = name.as_str() {
820 array.push(name);
821 }
822 }
823 table["sections"] = toml_edit::value(array);
824 }
825 if let Some(alerts) = &host.alerts {
826 let mut sub = toml_edit::Table::new();
828 for (key, value) in alerts {
829 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
830 sub[key.as_str()] = toml_edit::value(value);
831 }
832 }
833 table["alerts"] = toml_edit::Item::Table(sub);
834 }
835 tables.push(table);
836 }
837 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
838 Ok(())
839 })
840 }
841
842 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
851 edit_file(path, |document| {
852 if !document.contains_key("tui") {
853 if *tui == TuiConfig::default() {
854 return Ok(());
855 }
856 document["tui"] = toml_edit::table();
857 }
858 let Some(table) = document["tui"].as_table_like_mut() else {
859 return Ok(());
861 };
862 if tui.hidden_panels.is_empty() {
863 table.remove("hidden_panels");
864 } else {
865 let names: toml_edit::Array = tui
866 .hidden_panels
867 .iter()
868 .filter_map(|section| {
869 serde_json::to_value(section)
870 .ok()
871 .and_then(|v| v.as_str().map(str::to_owned))
872 })
873 .collect();
874 table.insert("hidden_panels", toml_edit::value(names));
875 }
876 if tui.show_widgets {
877 table.remove("show_widgets");
878 } else {
879 table.insert("show_widgets", toml_edit::value(false));
880 }
881 Ok(())
882 })
883 }
884
885 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
886 edit_file(path, |document| {
887 if !document.contains_key("sensors") {
888 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
889 return Ok(());
890 }
891 document["sensors"] = toml_edit::table();
892 }
893 let Some(table) = document["sensors"].as_table_like_mut() else {
894 return Ok(());
896 };
897 if sensors.hidden.is_empty() {
898 table.remove("hidden");
899 } else {
900 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
901 table.insert("hidden", toml_edit::value(hidden));
902 }
903 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
904 if sensors.labels.is_empty() {
905 table.remove("labels");
906 } else if inline {
907 let labels: toml_edit::InlineTable = sensors
908 .labels
909 .iter()
910 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
911 .collect();
912 table.insert("labels", toml_edit::value(labels));
913 } else {
914 let mut labels = toml_edit::Table::new();
915 for (key, name) in &sensors.labels {
916 labels[key.as_str()] = toml_edit::value(name.as_str());
917 }
918 table.insert("labels", toml_edit::Item::Table(labels));
919 }
920 if table.is_empty() {
921 document.remove("sensors");
922 }
923 Ok(())
924 })
925 }
926
927 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
929 self.label_for(&fan.key).unwrap_or(&fan.label)
930 }
931
932 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
934 self.label_for(&temperature.key)
935 .unwrap_or(&temperature.label)
936 }
937
938 pub fn label_for(&self, key: &str) -> Option<&str> {
940 self.sensors.labels.get(key).map(String::as_str)
941 }
942
943 pub fn set_label(&mut self, key: &str, name: &str) {
945 let name = name.trim();
946 if name.is_empty() {
947 self.sensors.labels.remove(key);
948 } else {
949 self.sensors.labels.insert(key.to_owned(), name.to_owned());
950 }
951 }
952
953 pub fn is_hidden(&self, key: &str) -> bool {
955 self.sensors.hidden.iter().any(|k| k == key)
956 }
957}
958
959fn edit_file(
964 path: &Path,
965 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
966) -> Result<(), ConfigError> {
967 let io_err = |source| ConfigError::Io {
968 path: path.to_owned(),
969 source,
970 };
971 let text = match fs::read_to_string(path) {
972 Ok(text) => text,
973 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
974 Err(err) => return Err(io_err(err)),
975 };
976 let mut document: toml_edit::DocumentMut =
977 text.parse().map_err(|source| ConfigError::Edit {
978 path: path.to_owned(),
979 source,
980 })?;
981 edit(&mut document)?;
982 let edited = document.to_string();
983 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
985 path: path.to_owned(),
986 source,
987 })?;
988 if let Some(parent) = path.parent() {
989 fs::create_dir_all(parent).map_err(io_err)?;
990 }
991 let tmp = path.with_extension("toml.tmp");
992 fs::write(&tmp, edited).map_err(io_err)?;
993 fs::rename(&tmp, path).map_err(io_err)
994}
995
996const WATCH_INTERVAL: Duration = Duration::from_secs(1);
998
999pub struct ConfigWatcher {
1002 path: PathBuf,
1003 stamp: Option<(Option<SystemTime>, u64)>,
1005 last_check: Instant,
1006 interval: Duration,
1007}
1008
1009impl ConfigWatcher {
1010 pub fn new(path: PathBuf) -> Self {
1012 Self {
1013 stamp: file_stamp(&path),
1014 path,
1015 last_check: Instant::now(),
1016 interval: WATCH_INTERVAL,
1017 }
1018 }
1019
1020 pub fn path(&self) -> &Path {
1022 &self.path
1023 }
1024
1025 pub fn mark_seen(&mut self) {
1028 self.stamp = file_stamp(&self.path);
1029 }
1030
1031 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
1036 if self.last_check.elapsed() < self.interval {
1037 return None;
1038 }
1039 self.last_check = Instant::now();
1040 let stamp = file_stamp(&self.path);
1041 if stamp == self.stamp {
1042 return None;
1043 }
1044 self.stamp = stamp;
1045 stamp?;
1046 Some(Config::load(&self.path))
1047 }
1048}
1049
1050fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
1052 let metadata = fs::metadata(path).ok()?;
1053 Some((metadata.modified().ok(), metadata.len()))
1054}
1055
1056#[cfg(test)]
1057mod tests {
1058 use super::*;
1059
1060 #[test]
1061 fn template_parses_with_every_setting_uncommented() {
1062 let settings: String = CONFIG_TEMPLATE
1064 .lines()
1065 .filter_map(|line| line.strip_prefix('#'))
1066 .filter(|line| !line.starts_with(' ') && !line.is_empty())
1067 .map(|line| format!("{line}\n"))
1068 .collect();
1069 let config: Config = toml::from_str(&settings).unwrap();
1070 assert_eq!(config.hosts[0].name, "nas");
1071 assert_eq!(config.alerts, AlertsConfig::default());
1073 assert_eq!(config.watch, WatchConfig::default());
1074 assert_eq!(config.overview, OverviewConfig::default());
1075 let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1077 assert_eq!(empty, Config::default());
1078
1079 let tmp = tempfile::tempdir().unwrap();
1080 let path = tmp.path().join("rigtop/config.toml");
1081 assert!(create_if_missing(&path).unwrap());
1082 assert!(!create_if_missing(&path).unwrap(), "left as it is");
1083 assert_eq!(Config::load(&path).unwrap(), Config::default());
1084 }
1085
1086 #[test]
1087 fn hidden_panels_are_saved_and_loaded() {
1088 let tmp = tempfile::tempdir().unwrap();
1089 let path = tmp.path().join("config.toml");
1090 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1091 let tui = TuiConfig {
1092 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1093 show_widgets: false,
1094 };
1095 Config::save_tui(&path, &tui).unwrap();
1096 let text = fs::read_to_string(&path).unwrap();
1097 assert!(text.starts_with("# mine"), "{text}");
1098 let config = Config::load(&path).unwrap();
1099 assert_eq!(
1100 config.tui.hidden_panels,
1101 vec![OverviewSection::Gpu, OverviewSection::Network]
1102 );
1103 assert!(!config.tui.show_widgets);
1104 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1105 Config::save_tui(&path, &TuiConfig::default()).unwrap();
1106 let config = Config::load(&path).unwrap();
1107 assert_eq!(config.tui, TuiConfig::default());
1108 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1109 }
1110
1111 #[test]
1112 fn watch_settings() {
1113 let config: Config = toml::from_str(
1114 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1115 )
1116 .unwrap();
1117 assert_eq!(config.watch.command, "notify-send x");
1118 assert_eq!(config.watch.level, NotifyLevel::Warning);
1119 assert!(config.watch.cleared);
1120 assert_eq!(config.watch.interval, Duration::from_secs(30));
1121 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1122 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1123 }
1124
1125 #[test]
1126 fn update_checks_can_be_tuned_or_turned_off() {
1127 let config: Config =
1128 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1129 assert!(config.updates.check);
1130 assert_eq!(
1131 config.updates.check_interval(),
1132 Duration::from_secs(6 * 3600)
1133 );
1134 assert!(!config.updates.notify);
1135 let config: Config =
1136 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1137 assert!(!config.updates.check);
1138 assert_eq!(
1139 config.updates.check_interval(),
1140 Duration::from_secs(60),
1141 "at least a minute"
1142 );
1143 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1144 assert!(error.to_string().contains("invalid duration"), "{error}");
1145 }
1146
1147 #[test]
1148 fn durations_read_and_write_as_text() {
1149 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1150 assert_eq!(duration_text::parse("30m"), minutes(30));
1151 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1152 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1153 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1154 assert_eq!(duration_text::parse("2H"), minutes(120));
1155 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1156 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1157 }
1158 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1159 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1160 let text = toml::to_string(&Config::default()).unwrap();
1162 assert!(text.contains("interval = \"1h\""), "{text}");
1163 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1164 }
1165
1166 #[test]
1167 fn watcher_reports_edits_once() {
1168 let tmp = tempfile::tempdir().unwrap();
1169 let path = tmp.path().join("config.toml");
1170 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1171 let mut watcher = ConfigWatcher::new(path.clone());
1172 watcher.interval = Duration::ZERO;
1173 assert!(
1174 watcher.poll().is_none(),
1175 "the initial file counts as loaded"
1176 );
1177
1178 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1180 let config = watcher.poll().unwrap().unwrap();
1181 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1182 assert!(watcher.poll().is_none(), "reported once");
1183
1184 fs::write(&path, "[alerts\n").unwrap();
1185 assert!(
1186 watcher.poll().unwrap().is_err(),
1187 "a broken file is an error"
1188 );
1189 fs::remove_file(&path).unwrap();
1190 assert!(
1191 watcher.poll().is_none(),
1192 "removing the file changes nothing"
1193 );
1194 }
1195
1196 #[test]
1197 fn missing_file_gives_defaults() {
1198 let tmp = tempfile::tempdir().unwrap();
1199 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1200 assert_eq!(config, Config::default());
1201 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1202 }
1203
1204 #[test]
1205 fn parses_labels_hidden_and_smart_dir() {
1206 let tmp = tempfile::tempdir().unwrap();
1207 let path = tmp.path().join("config.toml");
1208 fs::write(
1209 &path,
1210 r#"
1211[sensors]
1212labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1213hidden = ["nct6686:nct6683.2592/temp4"]
1214
1215[smart]
1216dir = "/tmp/smart"
1217
1218[lhm]
1219url = ""
1220
1221[notifications]
1222level = "warning"
1223
1224[alerts]
1225drive_warning_celsius = 65
1226cpu_warning_celsius = 0
1227gpu_memory_warning_percent = 98
1228"#,
1229 )
1230 .unwrap();
1231 let config = Config::load(&path).unwrap();
1232 let fan = Fan {
1233 key: "nct6686:nct6683.2592/fan1".to_owned(),
1234 label: "fan1".to_owned(),
1235 rpm: 900,
1236 };
1237 let other = Fan {
1238 key: "nct6686:nct6683.2592/fan2".to_owned(),
1239 label: "fan2".to_owned(),
1240 rpm: 0,
1241 };
1242 assert_eq!(config.fan_label(&fan), "CPU fan");
1243 assert_eq!(config.fan_label(&other), "fan2");
1245 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1246 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1247 assert_eq!(config.lhm.url, "");
1248 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1249 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1250 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1251 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1252 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1253 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1255 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1256 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1257 }
1258
1259 #[test]
1260 fn parses_hosts_and_overview() {
1261 let config: Config = toml::from_str(
1262 r#"
1263[overview]
1264sections = ["storage", "cpu"]
1265include_local = false
1266
1267[[hosts]]
1268name = "zefat"
1269
1270[[hosts]]
1271name = "nas"
1272ssh = "admin@nas.local"
1273sections = ["storage"]
1274
1275[[widgets]]
1276name = "Clock"
1277builtin = "clock"
1278
1279[[widgets]]
1280name = "Rates"
1281command = "rates.sh"
1282interval_secs = 60
1283"#,
1284 )
1285 .unwrap();
1286 assert_eq!(
1287 config.overview.sections,
1288 vec![OverviewSection::Storage, OverviewSection::Cpu]
1289 );
1290 assert!(!config.overview.include_local);
1291 assert_eq!(config.overview.processes, 10, "default kept");
1292 assert_eq!(config.hosts.len(), 2);
1293 assert_eq!(config.hosts[0].destination(), "zefat");
1294 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1295 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1296 assert_eq!(config.widgets[1].interval_secs, 60);
1297 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1298 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1299 assert_eq!(
1300 config.hosts[1].sections,
1301 Some(vec![OverviewSection::Storage])
1302 );
1303 let defaults = Config::default();
1305 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1306 assert!(defaults.overview.include_local);
1307 }
1308
1309 #[test]
1310 fn save_hosts_replaces_only_the_hosts() {
1311 let tmp = tempfile::tempdir().unwrap();
1312 let path = tmp.path().join("config.toml");
1313 fs::write(
1314 &path,
1315 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1316 )
1317 .unwrap();
1318 let hosts = vec![
1319 HostConfig::from_destination("zefat"),
1320 HostConfig {
1321 name: "nas".to_owned(),
1322 ssh: Some("admin@nas.local".to_owned()),
1323 command: Some("/opt/rigtop --agent".to_owned()),
1324 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1325 alerts: Some(
1326 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1327 .unwrap(),
1328 ),
1329 },
1330 ];
1331 Config::save_hosts(&path, &hosts).unwrap();
1332
1333 let text = fs::read_to_string(&path).unwrap();
1334 assert!(
1335 text.contains("# my settings") && text.contains("# keep me"),
1336 "{text}"
1337 );
1338 assert!(!text.contains("\"old\""), "{text}");
1339 let config = Config::load(&path).unwrap();
1340 assert_eq!(config.hosts, hosts);
1341 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1342
1343 Config::save_hosts(&path, &[]).unwrap();
1345 assert!(Config::load(&path).unwrap().hosts.is_empty());
1346 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1347 }
1348
1349 #[test]
1350 fn host_alerts_replace_the_keys_they_give() {
1351 let tmp = tempfile::tempdir().unwrap();
1352 let path = tmp.path().join("config.toml");
1353 fs::write(
1354 &path,
1355 "[alerts]\nmemory_warning_percent = 80\n\n\
1356 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1357 [[hosts]]\nname = \"nas\"\n",
1358 )
1359 .unwrap();
1360 let config = Config::load(&path).unwrap();
1361 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1362 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1363 assert_eq!(laptop.memory_warning_percent, 80.0);
1365 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1366 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1367
1368 fs::write(
1370 &path,
1371 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1372 )
1373 .unwrap();
1374 let err = Config::load(&path).unwrap_err().to_string();
1375 assert!(
1376 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1377 "{err}"
1378 );
1379 }
1380
1381 #[test]
1382 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1383 let tmp = tempfile::tempdir().unwrap();
1384 let path = tmp.path().join("config.toml");
1385 fs::write(
1386 &path,
1387 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1388 )
1389 .unwrap();
1390 let mut sensors = SensorConfig::default();
1391 sensors
1392 .labels
1393 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1394 sensors
1395 .labels
1396 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1397 sensors.hidden.push("b:c.1/temp4".to_owned());
1398 Config::save_sensors(&path, &sensors).unwrap();
1399
1400 let text = fs::read_to_string(&path).unwrap();
1401 assert!(text.contains("# my settings"), "{text}");
1402 assert!(text.contains("labels = {"), "{text}");
1404 let config = Config::load(&path).unwrap();
1405 assert_eq!(config.sensors, sensors);
1406 assert_eq!(config.hosts[0].name, "zefat");
1407
1408 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1410 let text = fs::read_to_string(&path).unwrap();
1411 assert!(!text.contains("[sensors]"), "{text}");
1412 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1413
1414 let fresh = tmp.path().join("new.toml");
1416 Config::save_sensors(&fresh, &sensors).unwrap();
1417 let text = fs::read_to_string(&fresh).unwrap();
1418 assert!(text.contains("[sensors.labels]"), "{text}");
1419 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1420 }
1421
1422 #[test]
1423 fn save_hosts_creates_a_missing_file() {
1424 let tmp = tempfile::tempdir().unwrap();
1425 let path = tmp.path().join("new/rigtop/config.toml");
1426 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1427 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1428 }
1429
1430 #[test]
1431 fn save_hosts_refuses_to_touch_a_broken_file() {
1432 let tmp = tempfile::tempdir().unwrap();
1433 let path = tmp.path().join("config.toml");
1434 fs::write(&path, "[sensors\n").unwrap();
1435 let err = Config::save_hosts(&path, &[]).unwrap_err();
1436 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1437 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1438 }
1439
1440 #[test]
1441 fn unknown_fields_are_rejected() {
1442 let tmp = tempfile::tempdir().unwrap();
1443 let path = tmp.path().join("config.toml");
1444 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1446 assert!(matches!(
1447 Config::load(&path),
1448 Err(ConfigError::Parse { .. })
1449 ));
1450 }
1451
1452 #[test]
1453 fn save_and_reload_round_trip() {
1454 let tmp = tempfile::tempdir().unwrap();
1455 let path = tmp.path().join("nested/rigtop/config.toml");
1456 let mut config = Config::default();
1457 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1458 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1459 config.set_label("nct6686:nct6683.2592/fan1", "");
1460 config.save(&path).unwrap();
1461
1462 let back = Config::load(&path).unwrap();
1463 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1464 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1466 assert!(!path.with_extension("toml.tmp").exists());
1467 }
1468}