1use std::collections::BTreeMap;
101use std::fs;
102use std::io;
103use std::path::{Path, PathBuf};
104use std::time::{Duration, Instant, SystemTime};
105
106use serde::{Deserialize, Serialize};
107
108use crate::snapshot::{Fan, Temperature};
109use crate::view::SortKey;
110
111pub const CONFIG_TEMPLATE: &str = include_str!("config_template.toml");
114
115pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
117pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
119pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
121
122pub const DEFAULT_UPS_ADDRESS: &str = "127.0.0.1:3493";
124
125#[derive(Debug, thiserror::Error)]
127pub enum ConfigError {
128 #[error("cannot access {path}: {source}")]
130 Io {
131 path: PathBuf,
133 source: io::Error,
135 },
136 #[error("invalid config {path}: {source}")]
138 Parse {
139 path: PathBuf,
141 source: toml::de::Error,
143 },
144 #[error("cannot serialize config: {0}")]
146 Serialize(#[from] toml::ser::Error),
147 #[error("invalid alerts of host {host} in {path}: {source}")]
150 HostAlerts {
151 path: PathBuf,
153 host: String,
155 source: Box<toml::de::Error>,
157 },
158 #[error("cannot edit {path}: {source}")]
160 Edit {
161 path: PathBuf,
163 source: toml_edit::TomlError,
165 },
166}
167
168#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
170#[serde(default, deny_unknown_fields)]
171pub struct Config {
172 pub sensors: SensorConfig,
174 pub smart: SmartConfig,
176 pub btrfs: BtrfsConfig,
178 pub lhm: LhmConfig,
181 pub ups: UpsConfig,
183 pub overview: OverviewConfig,
185 pub tui: TuiConfig,
187 pub gpu: GpuConfig,
189 pub network: NetworkConfig,
191 pub alerts: AlertsConfig,
193 pub notifications: NotificationsConfig,
195 pub updates: UpdatesConfig,
197 pub watch: WatchConfig,
199 pub hosts: Vec<HostConfig>,
201 pub widgets: Vec<WidgetConfig>,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[serde(rename_all = "lowercase")]
208pub enum BuiltinWidget {
209 Clock,
211}
212
213#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
216#[serde(default, deny_unknown_fields)]
217pub struct WidgetConfig {
218 pub name: String,
220 pub command: Option<String>,
222 pub builtin: Option<BuiltinWidget>,
224 pub interval_secs: u64,
226 pub timeout_secs: u64,
228}
229
230impl Default for WidgetConfig {
231 fn default() -> Self {
232 Self {
233 name: String::new(),
234 command: None,
235 builtin: None,
236 interval_secs: 5,
237 timeout_secs: 10,
238 }
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
244#[serde(rename_all = "lowercase")]
245pub enum OverviewSection {
246 Cpu,
247 Gpu,
248 Memory,
249 Processes,
250 Network,
251 Storage,
252}
253
254impl OverviewSection {
255 pub const ALL: [OverviewSection; 6] = [
257 OverviewSection::Cpu,
258 OverviewSection::Gpu,
259 OverviewSection::Memory,
260 OverviewSection::Processes,
261 OverviewSection::Network,
262 OverviewSection::Storage,
263 ];
264
265 pub fn label(self) -> &'static str {
267 match self {
268 OverviewSection::Cpu => "CPU",
269 OverviewSection::Gpu => "GPU",
270 OverviewSection::Memory => "Memory",
271 OverviewSection::Processes => "Processes",
272 OverviewSection::Network => "Network",
273 OverviewSection::Storage => "Storage",
274 }
275 }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
281#[serde(default)]
282pub struct ProcessView {
283 pub sort: SortKey,
284 pub descending: bool,
286 pub tree: bool,
288 pub kernel_threads: bool,
289 pub containers: bool,
291}
292
293impl Default for ProcessView {
294 fn default() -> Self {
295 Self {
296 sort: SortKey::Cpu,
297 descending: true,
298 tree: false,
299 kernel_threads: false,
300 containers: false,
301 }
302 }
303}
304
305#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
307#[serde(default, deny_unknown_fields)]
308pub struct TuiConfig {
309 pub hidden_panels: Vec<OverviewSection>,
312 pub show_widgets: bool,
314 pub sort: SortKey,
316 pub sort_descending: Option<bool>,
319 pub tree: bool,
321 pub kernel_threads: bool,
323 pub containers: bool,
325}
326
327impl Default for TuiConfig {
328 fn default() -> Self {
329 Self {
330 hidden_panels: Vec::new(),
331 show_widgets: true,
332 sort: SortKey::Cpu,
333 sort_descending: None,
334 tree: false,
335 kernel_threads: false,
336 containers: false,
337 }
338 }
339}
340
341impl TuiConfig {
342 pub fn process_view(&self) -> ProcessView {
344 ProcessView {
345 sort: self.sort,
346 descending: self
347 .sort_descending
348 .unwrap_or_else(|| self.sort.descending_by_default()),
349 tree: self.tree,
350 kernel_threads: self.kernel_threads,
351 containers: self.containers,
352 }
353 }
354
355 pub fn set_process_view(&mut self, view: ProcessView) {
358 self.sort = view.sort;
359 self.sort_descending =
360 (view.descending != view.sort.descending_by_default()).then_some(view.descending);
361 self.tree = view.tree;
362 self.kernel_threads = view.kernel_threads;
363 self.containers = view.containers;
364 }
365}
366
367#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369#[serde(default, deny_unknown_fields)]
370pub struct OverviewConfig {
371 pub sections: Vec<OverviewSection>,
375 pub include_local: bool,
377 pub processes: usize,
380 pub alerts_first: bool,
384}
385
386impl Default for OverviewConfig {
387 fn default() -> Self {
388 Self {
389 sections: OverviewSection::ALL.to_vec(),
390 include_local: true,
391 processes: 10,
392 alerts_first: false,
393 }
394 }
395}
396
397#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
399#[serde(deny_unknown_fields)]
400pub struct HostConfig {
401 pub name: String,
403 #[serde(default, skip_serializing_if = "Option::is_none")]
406 pub ssh: Option<String>,
407 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub command: Option<String>,
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub sections: Option<Vec<OverviewSection>>,
414 #[serde(default, skip_serializing_if = "Option::is_none")]
418 pub alerts: Option<toml::Table>,
419}
420
421impl HostConfig {
422 pub fn from_destination(destination: &str) -> Self {
424 Self {
425 name: destination.to_owned(),
426 ssh: None,
427 command: None,
428 sections: None,
429 alerts: None,
430 }
431 }
432
433 pub fn destination(&self) -> &str {
435 self.ssh.as_deref().unwrap_or(&self.name)
436 }
437}
438
439#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
441#[serde(default, deny_unknown_fields)]
442pub struct SensorConfig {
443 pub labels: BTreeMap<String, String>,
446 pub hidden: Vec<String>,
448}
449
450#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
452#[serde(default, deny_unknown_fields)]
453pub struct SmartConfig {
454 pub dir: PathBuf,
456}
457
458impl Default for SmartConfig {
459 fn default() -> Self {
460 Self {
461 dir: PathBuf::from(DEFAULT_SMART_DIR),
462 }
463 }
464}
465
466#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
468#[serde(rename_all = "lowercase")]
469pub enum NotifyLevel {
470 Off,
472 Warning,
474 #[default]
476 Critical,
477}
478
479#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
483#[serde(default, deny_unknown_fields)]
484pub struct AlertsConfig {
485 pub filesystem_warning_percent: f64,
487 pub filesystem_critical_percent: f64,
489 pub filesystem_full_warning_hours: f64,
492 pub cpu_warning_celsius: f32,
495 pub gpu_warning_celsius: f32,
497 pub drive_warning_celsius: f32,
499 pub memory_warning_percent: f64,
501 pub gpu_memory_warning_percent: f64,
505 pub memory_pressure_warning_percent: f64,
509 pub io_pressure_warning_percent: f64,
513 pub ignored_units: Vec<String>,
516}
517
518impl Default for AlertsConfig {
519 fn default() -> Self {
520 Self {
521 filesystem_warning_percent: 90.0,
522 filesystem_critical_percent: 97.0,
523 filesystem_full_warning_hours: 24.0,
524 cpu_warning_celsius: 96.0,
525 gpu_warning_celsius: 85.0,
526 drive_warning_celsius: 70.0,
527 memory_warning_percent: 90.0,
528 gpu_memory_warning_percent: 0.0,
529 memory_pressure_warning_percent: 10.0,
530 io_pressure_warning_percent: 30.0,
531 ignored_units: Vec::new(),
532 }
533 }
534}
535
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
538#[serde(default, deny_unknown_fields)]
539pub struct NetworkConfig {
540 pub show_addresses: bool,
543 pub public_ip: bool,
547 pub public_ip_url: String,
549 #[serde(with = "duration_text")]
551 pub public_ip_interval: Duration,
552}
553
554pub const DEFAULT_PUBLIC_IP_URL: &str = "https://api.ipify.org";
556
557impl Default for NetworkConfig {
558 fn default() -> Self {
559 Self {
560 show_addresses: true,
561 public_ip: false,
562 public_ip_url: DEFAULT_PUBLIC_IP_URL.to_owned(),
563 public_ip_interval: Duration::from_secs(30 * 60),
564 }
565 }
566}
567
568impl NetworkConfig {
569 pub fn public_ip_lookup(&self) -> Option<crate::public_ip::PublicIpLookup> {
571 (self.public_ip && !self.public_ip_url.is_empty()).then(|| {
572 crate::public_ip::PublicIpLookup {
573 url: self.public_ip_url.clone(),
574 interval: self.public_ip_interval,
575 }
576 })
577 }
578}
579
580#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
582#[serde(default, deny_unknown_fields)]
583pub struct GpuConfig {
584 pub integrated: IntegratedGpu,
587}
588
589#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
592#[serde(rename_all = "lowercase")]
593pub enum IntegratedGpu {
594 #[default]
596 Compact,
597 Hide,
599 Full,
601}
602
603#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
605#[serde(default, deny_unknown_fields)]
606pub struct NotificationsConfig {
607 pub level: NotifyLevel,
608}
609
610#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
612#[serde(default, deny_unknown_fields)]
613pub struct UpdatesConfig {
614 pub check: bool,
616 #[serde(with = "duration_text")]
619 pub interval: Duration,
620 pub notify: bool,
623}
624
625impl Default for UpdatesConfig {
626 fn default() -> Self {
627 Self {
628 check: true,
629 interval: Duration::from_secs(3600),
630 notify: true,
631 }
632 }
633}
634
635impl UpdatesConfig {
636 pub fn check_interval(&self) -> Duration {
638 self.interval.max(Duration::from_secs(60))
639 }
640}
641
642#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645#[serde(default, deny_unknown_fields)]
646pub struct WatchConfig {
647 pub command: String,
650 pub level: NotifyLevel,
652 pub cleared: bool,
654 #[serde(with = "duration_text")]
656 pub interval: Duration,
657 #[serde(with = "duration_text")]
659 pub offline_after: Duration,
660 #[serde(with = "duration_text")]
662 pub timeout: Duration,
663 pub processes: Vec<String>,
667}
668
669impl Default for WatchConfig {
670 fn default() -> Self {
671 Self {
672 command: String::new(),
673 level: NotifyLevel::Critical,
674 cleared: true,
675 interval: Duration::from_secs(10),
676 offline_after: Duration::from_secs(180),
677 timeout: Duration::from_secs(30),
678 processes: Vec::new(),
679 }
680 }
681}
682
683mod duration_text {
686 use std::time::Duration;
687
688 use serde::de::Error;
689 use serde::{Deserialize, Deserializer, Serializer};
690
691 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
692
693 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
694 serializer.serialize_str(&format(*duration))
695 }
696
697 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
698 let text = String::deserialize(deserializer)?;
699 parse(&text).ok_or_else(|| {
700 D::Error::custom(format!(
701 "invalid duration {text:?}: write a number and s, m, h or d, \
702 e.g. \"30m\", \"6h\" or \"1h30m\""
703 ))
704 })
705 }
706
707 pub fn parse(text: &str) -> Option<Duration> {
709 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
710 if text.is_empty() {
711 return None;
712 }
713 let mut total = 0u64;
714 let mut number = String::new();
715 for c in text.chars() {
716 if c.is_ascii_digit() {
717 number.push(c);
718 continue;
719 }
720 let (_, secs) = UNITS
721 .iter()
722 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
723 let value: u64 = number.parse().ok()?;
724 total = total.checked_add(value.checked_mul(*secs)?)?;
725 number.clear();
726 }
727 number.is_empty().then(|| Duration::from_secs(total))
729 }
730
731 pub fn format(duration: Duration) -> String {
733 let mut secs = duration.as_secs();
734 if secs == 0 {
735 return "0s".to_owned();
736 }
737 let mut text = String::new();
738 for (unit, size) in UNITS {
739 if secs >= size {
740 text.push_str(&format!("{}{unit}", secs / size));
741 secs %= size;
742 }
743 }
744 text
745 }
746}
747
748#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
750#[serde(default, deny_unknown_fields)]
751pub struct LhmConfig {
752 pub url: String,
755}
756
757impl Default for LhmConfig {
758 fn default() -> Self {
759 Self {
760 url: DEFAULT_LHM_URL.to_owned(),
761 }
762 }
763}
764
765#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
767#[serde(default, deny_unknown_fields)]
768pub struct UpsConfig {
769 pub address: String,
772}
773
774impl Default for UpsConfig {
775 fn default() -> Self {
776 Self {
777 address: DEFAULT_UPS_ADDRESS.to_owned(),
778 }
779 }
780}
781
782#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
784#[serde(default, deny_unknown_fields)]
785pub struct BtrfsConfig {
786 pub scrub_dir: PathBuf,
788}
789
790impl Default for BtrfsConfig {
791 fn default() -> Self {
792 Self {
793 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
794 }
795 }
796}
797
798pub fn create_if_missing(path: &Path) -> io::Result<bool> {
801 if path.exists() {
802 return Ok(false);
803 }
804 if let Some(dir) = path.parent() {
805 fs::create_dir_all(dir)?;
806 }
807 fs::write(path, CONFIG_TEMPLATE)?;
808 Ok(true)
809}
810
811impl Config {
812 pub fn default_path() -> Option<PathBuf> {
816 if let Some(dir) = crate::package::local_state() {
817 return Some(dir.join("config.toml"));
818 }
819 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
820 }
821
822 pub fn load(path: &Path) -> Result<Self, ConfigError> {
824 let text = match fs::read_to_string(path) {
825 Ok(text) => text,
826 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
827 Err(source) => {
828 return Err(ConfigError::Io {
829 path: path.to_owned(),
830 source,
831 });
832 }
833 };
834 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
835 path: path.to_owned(),
836 source,
837 })?;
838 for host in &config.hosts {
840 config
841 .alerts_for(host)
842 .map_err(|source| ConfigError::HostAlerts {
843 path: path.to_owned(),
844 host: host.name.clone(),
845 source: Box::new(source),
846 })?;
847 }
848 Ok(config)
849 }
850
851 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
854 let Some(overrides) = &host.alerts else {
855 return Ok(self.alerts.clone());
856 };
857 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
859 merged.extend(overrides.clone());
860 merged.try_into()
861 }
862
863 pub fn load_default() -> Result<Self, ConfigError> {
866 match Self::default_path() {
867 Some(path) => Self::load(&path),
868 None => Ok(Self::default()),
869 }
870 }
871
872 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
877 let text = toml::to_string_pretty(self)?;
878 let io_err = |source| ConfigError::Io {
879 path: path.to_owned(),
880 source,
881 };
882 if let Some(parent) = path.parent() {
883 fs::create_dir_all(parent).map_err(io_err)?;
884 }
885 let tmp = path.with_extension("toml.tmp");
886 fs::write(&tmp, text).map_err(io_err)?;
887 fs::rename(&tmp, path).map_err(io_err)
888 }
889
890 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
897 edit_file(path, |document| {
898 if hosts.is_empty() {
899 document.remove("hosts");
900 return Ok(());
901 }
902 let mut tables = toml_edit::ArrayOfTables::new();
903 for host in hosts {
904 let mut table = toml_edit::Table::new();
905 table["name"] = toml_edit::value(host.name.as_str());
906 if let Some(ssh) = &host.ssh {
907 table["ssh"] = toml_edit::value(ssh.as_str());
908 }
909 if let Some(command) = &host.command {
910 table["command"] = toml_edit::value(command.as_str());
911 }
912 if let Some(sections) = &host.sections {
913 let mut array = toml_edit::Array::new();
914 for section in sections {
915 let name = toml::Value::try_from(section)?;
917 if let Some(name) = name.as_str() {
918 array.push(name);
919 }
920 }
921 table["sections"] = toml_edit::value(array);
922 }
923 if let Some(alerts) = &host.alerts {
924 let mut sub = toml_edit::Table::new();
926 for (key, value) in alerts {
927 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
928 sub[key.as_str()] = toml_edit::value(value);
929 }
930 }
931 table["alerts"] = toml_edit::Item::Table(sub);
932 }
933 tables.push(table);
934 }
935 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
936 Ok(())
937 })
938 }
939
940 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
949 edit_file(path, |document| {
950 if !document.contains_key("tui") {
951 if *tui == TuiConfig::default() {
952 return Ok(());
953 }
954 document["tui"] = toml_edit::table();
955 }
956 let Some(table) = document["tui"].as_table_like_mut() else {
957 return Ok(());
959 };
960 if tui.hidden_panels.is_empty() {
961 table.remove("hidden_panels");
962 } else {
963 let names: toml_edit::Array = tui
964 .hidden_panels
965 .iter()
966 .filter_map(|section| {
967 serde_json::to_value(section)
968 .ok()
969 .and_then(|v| v.as_str().map(str::to_owned))
970 })
971 .collect();
972 table.insert("hidden_panels", toml_edit::value(names));
973 }
974 if tui.show_widgets {
975 table.remove("show_widgets");
976 } else {
977 table.insert("show_widgets", toml_edit::value(false));
978 }
979 let defaults = TuiConfig::default();
981 if tui.sort == defaults.sort {
982 table.remove("sort");
983 } else if let Some(name) = serde_json::to_value(tui.sort)
984 .ok()
985 .and_then(|v| v.as_str().map(str::to_owned))
986 {
987 table.insert("sort", toml_edit::value(name));
988 }
989 match tui.sort_descending {
990 None => {
991 table.remove("sort_descending");
992 }
993 Some(descending) => {
994 table.insert("sort_descending", toml_edit::value(descending));
995 }
996 }
997 for (key, on) in [
998 ("tree", tui.tree),
999 ("kernel_threads", tui.kernel_threads),
1000 ("containers", tui.containers),
1001 ] {
1002 if on {
1003 table.insert(key, toml_edit::value(true));
1004 } else {
1005 table.remove(key);
1006 }
1007 }
1008 Ok(())
1009 })
1010 }
1011
1012 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
1013 edit_file(path, |document| {
1014 if !document.contains_key("sensors") {
1015 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
1016 return Ok(());
1017 }
1018 document["sensors"] = toml_edit::table();
1019 }
1020 let Some(table) = document["sensors"].as_table_like_mut() else {
1021 return Ok(());
1023 };
1024 if sensors.hidden.is_empty() {
1025 table.remove("hidden");
1026 } else {
1027 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
1028 table.insert("hidden", toml_edit::value(hidden));
1029 }
1030 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
1031 if sensors.labels.is_empty() {
1032 table.remove("labels");
1033 } else if inline {
1034 let labels: toml_edit::InlineTable = sensors
1035 .labels
1036 .iter()
1037 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
1038 .collect();
1039 table.insert("labels", toml_edit::value(labels));
1040 } else {
1041 let mut labels = toml_edit::Table::new();
1042 for (key, name) in &sensors.labels {
1043 labels[key.as_str()] = toml_edit::value(name.as_str());
1044 }
1045 table.insert("labels", toml_edit::Item::Table(labels));
1046 }
1047 if table.is_empty() {
1048 document.remove("sensors");
1049 }
1050 Ok(())
1051 })
1052 }
1053
1054 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
1056 self.label_for(&fan.key).unwrap_or(&fan.label)
1057 }
1058
1059 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
1061 self.label_for(&temperature.key)
1062 .unwrap_or(&temperature.label)
1063 }
1064
1065 pub fn label_for(&self, key: &str) -> Option<&str> {
1067 self.sensors.labels.get(key).map(String::as_str)
1068 }
1069
1070 pub fn set_label(&mut self, key: &str, name: &str) {
1072 let name = name.trim();
1073 if name.is_empty() {
1074 self.sensors.labels.remove(key);
1075 } else {
1076 self.sensors.labels.insert(key.to_owned(), name.to_owned());
1077 }
1078 }
1079
1080 pub fn is_hidden(&self, key: &str) -> bool {
1082 self.sensors.hidden.iter().any(|k| k == key)
1083 }
1084}
1085
1086fn edit_file(
1091 path: &Path,
1092 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
1093) -> Result<(), ConfigError> {
1094 let io_err = |source| ConfigError::Io {
1095 path: path.to_owned(),
1096 source,
1097 };
1098 let text = match fs::read_to_string(path) {
1099 Ok(text) => text,
1100 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
1101 Err(err) => return Err(io_err(err)),
1102 };
1103 let mut document: toml_edit::DocumentMut =
1104 text.parse().map_err(|source| ConfigError::Edit {
1105 path: path.to_owned(),
1106 source,
1107 })?;
1108 edit(&mut document)?;
1109 let edited = document.to_string();
1110 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
1112 path: path.to_owned(),
1113 source,
1114 })?;
1115 if let Some(parent) = path.parent() {
1116 fs::create_dir_all(parent).map_err(io_err)?;
1117 }
1118 let tmp = path.with_extension("toml.tmp");
1119 fs::write(&tmp, edited).map_err(io_err)?;
1120 fs::rename(&tmp, path).map_err(io_err)
1121}
1122
1123const WATCH_INTERVAL: Duration = Duration::from_secs(1);
1125
1126pub struct ConfigWatcher {
1129 path: PathBuf,
1130 stamp: Option<(Option<SystemTime>, u64)>,
1132 last_check: Instant,
1133 interval: Duration,
1134}
1135
1136impl ConfigWatcher {
1137 pub fn new(path: PathBuf) -> Self {
1139 Self {
1140 stamp: file_stamp(&path),
1141 path,
1142 last_check: Instant::now(),
1143 interval: WATCH_INTERVAL,
1144 }
1145 }
1146
1147 pub fn path(&self) -> &Path {
1149 &self.path
1150 }
1151
1152 pub fn mark_seen(&mut self) {
1155 self.stamp = file_stamp(&self.path);
1156 }
1157
1158 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
1163 if self.last_check.elapsed() < self.interval {
1164 return None;
1165 }
1166 self.last_check = Instant::now();
1167 let stamp = file_stamp(&self.path);
1168 if stamp == self.stamp {
1169 return None;
1170 }
1171 self.stamp = stamp;
1172 stamp?;
1173 Some(Config::load(&self.path))
1174 }
1175}
1176
1177fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
1179 let metadata = fs::metadata(path).ok()?;
1180 Some((metadata.modified().ok(), metadata.len()))
1181}
1182
1183#[cfg(test)]
1184mod tests {
1185 use super::*;
1186
1187 #[test]
1188 fn template_parses_with_every_setting_uncommented() {
1189 let settings: String = CONFIG_TEMPLATE
1191 .lines()
1192 .filter_map(|line| line.strip_prefix('#'))
1193 .filter(|line| !line.starts_with(' ') && !line.is_empty())
1194 .map(|line| format!("{line}\n"))
1195 .collect();
1196 let config: Config = toml::from_str(&settings).unwrap();
1197 assert_eq!(config.hosts[0].name, "nas");
1198 assert_eq!(config.alerts, AlertsConfig::default());
1200 assert_eq!(config.watch, WatchConfig::default());
1201 assert_eq!(config.overview, OverviewConfig::default());
1202 assert_eq!(config.ups, UpsConfig::default());
1203 let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1205 assert_eq!(empty, Config::default());
1206
1207 let tmp = tempfile::tempdir().unwrap();
1208 let path = tmp.path().join("rigtop/config.toml");
1209 assert!(create_if_missing(&path).unwrap());
1210 assert!(!create_if_missing(&path).unwrap(), "left as it is");
1211 assert_eq!(Config::load(&path).unwrap(), Config::default());
1212 }
1213
1214 #[test]
1215 fn hidden_panels_are_saved_and_loaded() {
1216 let tmp = tempfile::tempdir().unwrap();
1217 let path = tmp.path().join("config.toml");
1218 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1219 let tui = TuiConfig {
1220 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1221 show_widgets: false,
1222 ..Default::default()
1223 };
1224 Config::save_tui(&path, &tui).unwrap();
1225 let text = fs::read_to_string(&path).unwrap();
1226 assert!(text.starts_with("# mine"), "{text}");
1227 let config = Config::load(&path).unwrap();
1228 assert_eq!(
1229 config.tui.hidden_panels,
1230 vec![OverviewSection::Gpu, OverviewSection::Network]
1231 );
1232 assert!(!config.tui.show_widgets);
1233 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1234 Config::save_tui(&path, &TuiConfig::default()).unwrap();
1235 let config = Config::load(&path).unwrap();
1236 assert_eq!(config.tui, TuiConfig::default());
1237 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1238 }
1239
1240 #[test]
1241 fn the_process_table_is_kept_in_tui() {
1242 let tmp = tempfile::tempdir().unwrap();
1243 let path = tmp.path().join("config.toml");
1244 fs::write(&path, "# mine\n[tui]\nshow_widgets = false\n").unwrap();
1245 let mut tui = Config::load(&path).unwrap().tui;
1246 assert_eq!(tui.process_view(), ProcessView::default());
1247
1248 tui.set_process_view(ProcessView {
1250 sort: SortKey::Name,
1251 descending: false,
1252 tree: true,
1253 ..Default::default()
1254 });
1255 Config::save_tui(&path, &tui).unwrap();
1256 let text = fs::read_to_string(&path).unwrap();
1257 assert!(text.contains("sort = \"name\""), "{text}");
1258 assert!(text.contains("tree = true"), "{text}");
1259 assert!(!text.contains("sort_descending"), "natural order: {text}");
1260 assert!(!text.contains("kernel_threads"), "default: {text}");
1261 let loaded = Config::load(&path).unwrap().tui;
1262 assert_eq!(loaded.process_view().sort, SortKey::Name);
1263 assert!(!loaded.process_view().descending && loaded.tree);
1264 assert!(!loaded.show_widgets, "the rest stays");
1265
1266 tui.set_process_view(ProcessView {
1268 sort: SortKey::Memory,
1269 descending: false,
1270 ..Default::default()
1271 });
1272 Config::save_tui(&path, &tui).unwrap();
1273 let loaded = Config::load(&path).unwrap().tui;
1274 assert_eq!(loaded.sort_descending, Some(false));
1275 assert!(!loaded.process_view().descending);
1276 assert!(!fs::read_to_string(&path).unwrap().contains("tree"));
1277
1278 tui.set_process_view(ProcessView::default());
1280 Config::save_tui(&path, &tui).unwrap();
1281 let text = fs::read_to_string(&path).unwrap();
1282 assert!(!text.contains("sort"), "{text}");
1283 }
1284
1285 #[test]
1286 fn watch_settings() {
1287 let config: Config = toml::from_str(
1288 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1289 )
1290 .unwrap();
1291 assert_eq!(config.watch.command, "notify-send x");
1292 assert_eq!(config.watch.level, NotifyLevel::Warning);
1293 assert!(config.watch.cleared);
1294 assert_eq!(config.watch.interval, Duration::from_secs(30));
1295 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1296 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1297 }
1298
1299 #[test]
1300 fn update_checks_can_be_tuned_or_turned_off() {
1301 let config: Config =
1302 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1303 assert!(config.updates.check);
1304 assert_eq!(
1305 config.updates.check_interval(),
1306 Duration::from_secs(6 * 3600)
1307 );
1308 assert!(!config.updates.notify);
1309 let config: Config =
1310 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1311 assert!(!config.updates.check);
1312 assert_eq!(
1313 config.updates.check_interval(),
1314 Duration::from_secs(60),
1315 "at least a minute"
1316 );
1317 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1318 assert!(error.to_string().contains("invalid duration"), "{error}");
1319 }
1320
1321 #[test]
1322 fn durations_read_and_write_as_text() {
1323 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1324 assert_eq!(duration_text::parse("30m"), minutes(30));
1325 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1326 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1327 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1328 assert_eq!(duration_text::parse("2H"), minutes(120));
1329 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1330 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1331 }
1332 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1333 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1334 let text = toml::to_string(&Config::default()).unwrap();
1336 assert!(text.contains("interval = \"1h\""), "{text}");
1337 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1338 }
1339
1340 #[test]
1341 fn watcher_reports_edits_once() {
1342 let tmp = tempfile::tempdir().unwrap();
1343 let path = tmp.path().join("config.toml");
1344 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1345 let mut watcher = ConfigWatcher::new(path.clone());
1346 watcher.interval = Duration::ZERO;
1347 assert!(
1348 watcher.poll().is_none(),
1349 "the initial file counts as loaded"
1350 );
1351
1352 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1354 let config = watcher.poll().unwrap().unwrap();
1355 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1356 assert!(watcher.poll().is_none(), "reported once");
1357
1358 fs::write(&path, "[alerts\n").unwrap();
1359 assert!(
1360 watcher.poll().unwrap().is_err(),
1361 "a broken file is an error"
1362 );
1363 fs::remove_file(&path).unwrap();
1364 assert!(
1365 watcher.poll().is_none(),
1366 "removing the file changes nothing"
1367 );
1368 }
1369
1370 #[test]
1371 fn missing_file_gives_defaults() {
1372 let tmp = tempfile::tempdir().unwrap();
1373 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1374 assert_eq!(config, Config::default());
1375 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1376 }
1377
1378 #[test]
1379 fn parses_labels_hidden_and_smart_dir() {
1380 let tmp = tempfile::tempdir().unwrap();
1381 let path = tmp.path().join("config.toml");
1382 fs::write(
1383 &path,
1384 r#"
1385[sensors]
1386labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1387hidden = ["nct6686:nct6683.2592/temp4"]
1388
1389[smart]
1390dir = "/tmp/smart"
1391
1392[lhm]
1393url = ""
1394
1395[notifications]
1396level = "warning"
1397
1398[alerts]
1399drive_warning_celsius = 65
1400cpu_warning_celsius = 0
1401gpu_memory_warning_percent = 98
1402"#,
1403 )
1404 .unwrap();
1405 let config = Config::load(&path).unwrap();
1406 let fan = Fan {
1407 key: "nct6686:nct6683.2592/fan1".to_owned(),
1408 label: "fan1".to_owned(),
1409 rpm: 900,
1410 };
1411 let other = Fan {
1412 key: "nct6686:nct6683.2592/fan2".to_owned(),
1413 label: "fan2".to_owned(),
1414 rpm: 0,
1415 };
1416 assert_eq!(config.fan_label(&fan), "CPU fan");
1417 assert_eq!(config.fan_label(&other), "fan2");
1419 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1420 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1421 assert_eq!(config.lhm.url, "");
1422 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1423 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1424 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1425 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1426 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1427 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1429 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1430 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1431 }
1432
1433 #[test]
1434 fn parses_hosts_and_overview() {
1435 let config: Config = toml::from_str(
1436 r#"
1437[overview]
1438sections = ["storage", "cpu"]
1439include_local = false
1440
1441[[hosts]]
1442name = "zefat"
1443
1444[[hosts]]
1445name = "nas"
1446ssh = "admin@nas.local"
1447sections = ["storage"]
1448
1449[[widgets]]
1450name = "Clock"
1451builtin = "clock"
1452
1453[[widgets]]
1454name = "Rates"
1455command = "rates.sh"
1456interval_secs = 60
1457"#,
1458 )
1459 .unwrap();
1460 assert_eq!(
1461 config.overview.sections,
1462 vec![OverviewSection::Storage, OverviewSection::Cpu]
1463 );
1464 assert!(!config.overview.include_local);
1465 assert_eq!(config.overview.processes, 10, "default kept");
1466 assert_eq!(config.hosts.len(), 2);
1467 assert_eq!(config.hosts[0].destination(), "zefat");
1468 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1469 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1470 assert_eq!(config.widgets[1].interval_secs, 60);
1471 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1472 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1473 assert_eq!(
1474 config.hosts[1].sections,
1475 Some(vec![OverviewSection::Storage])
1476 );
1477 let defaults = Config::default();
1479 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1480 assert!(defaults.overview.include_local);
1481 }
1482
1483 #[test]
1484 fn save_hosts_replaces_only_the_hosts() {
1485 let tmp = tempfile::tempdir().unwrap();
1486 let path = tmp.path().join("config.toml");
1487 fs::write(
1488 &path,
1489 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1490 )
1491 .unwrap();
1492 let hosts = vec![
1493 HostConfig::from_destination("zefat"),
1494 HostConfig {
1495 name: "nas".to_owned(),
1496 ssh: Some("admin@nas.local".to_owned()),
1497 command: Some("/opt/rigtop --agent".to_owned()),
1498 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1499 alerts: Some(
1500 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1501 .unwrap(),
1502 ),
1503 },
1504 ];
1505 Config::save_hosts(&path, &hosts).unwrap();
1506
1507 let text = fs::read_to_string(&path).unwrap();
1508 assert!(
1509 text.contains("# my settings") && text.contains("# keep me"),
1510 "{text}"
1511 );
1512 assert!(!text.contains("\"old\""), "{text}");
1513 let config = Config::load(&path).unwrap();
1514 assert_eq!(config.hosts, hosts);
1515 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1516
1517 Config::save_hosts(&path, &[]).unwrap();
1519 assert!(Config::load(&path).unwrap().hosts.is_empty());
1520 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1521 }
1522
1523 #[test]
1524 fn host_alerts_replace_the_keys_they_give() {
1525 let tmp = tempfile::tempdir().unwrap();
1526 let path = tmp.path().join("config.toml");
1527 fs::write(
1528 &path,
1529 "[alerts]\nmemory_warning_percent = 80\n\n\
1530 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1531 [[hosts]]\nname = \"nas\"\n",
1532 )
1533 .unwrap();
1534 let config = Config::load(&path).unwrap();
1535 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1536 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1537 assert_eq!(laptop.memory_warning_percent, 80.0);
1539 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1540 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1541
1542 fs::write(
1544 &path,
1545 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1546 )
1547 .unwrap();
1548 let err = Config::load(&path).unwrap_err().to_string();
1549 assert!(
1550 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1551 "{err}"
1552 );
1553 }
1554
1555 #[test]
1556 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1557 let tmp = tempfile::tempdir().unwrap();
1558 let path = tmp.path().join("config.toml");
1559 fs::write(
1560 &path,
1561 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1562 )
1563 .unwrap();
1564 let mut sensors = SensorConfig::default();
1565 sensors
1566 .labels
1567 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1568 sensors
1569 .labels
1570 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1571 sensors.hidden.push("b:c.1/temp4".to_owned());
1572 Config::save_sensors(&path, &sensors).unwrap();
1573
1574 let text = fs::read_to_string(&path).unwrap();
1575 assert!(text.contains("# my settings"), "{text}");
1576 assert!(text.contains("labels = {"), "{text}");
1578 let config = Config::load(&path).unwrap();
1579 assert_eq!(config.sensors, sensors);
1580 assert_eq!(config.hosts[0].name, "zefat");
1581
1582 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1584 let text = fs::read_to_string(&path).unwrap();
1585 assert!(!text.contains("[sensors]"), "{text}");
1586 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1587
1588 let fresh = tmp.path().join("new.toml");
1590 Config::save_sensors(&fresh, &sensors).unwrap();
1591 let text = fs::read_to_string(&fresh).unwrap();
1592 assert!(text.contains("[sensors.labels]"), "{text}");
1593 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1594 }
1595
1596 #[test]
1597 fn save_hosts_creates_a_missing_file() {
1598 let tmp = tempfile::tempdir().unwrap();
1599 let path = tmp.path().join("new/rigtop/config.toml");
1600 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1601 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1602 }
1603
1604 #[test]
1605 fn save_hosts_refuses_to_touch_a_broken_file() {
1606 let tmp = tempfile::tempdir().unwrap();
1607 let path = tmp.path().join("config.toml");
1608 fs::write(&path, "[sensors\n").unwrap();
1609 let err = Config::save_hosts(&path, &[]).unwrap_err();
1610 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1611 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1612 }
1613
1614 #[test]
1615 fn unknown_fields_are_rejected() {
1616 let tmp = tempfile::tempdir().unwrap();
1617 let path = tmp.path().join("config.toml");
1618 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1620 assert!(matches!(
1621 Config::load(&path),
1622 Err(ConfigError::Parse { .. })
1623 ));
1624 }
1625
1626 #[test]
1627 fn save_and_reload_round_trip() {
1628 let tmp = tempfile::tempdir().unwrap();
1629 let path = tmp.path().join("nested/rigtop/config.toml");
1630 let mut config = Config::default();
1631 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1632 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1633 config.set_label("nct6686:nct6683.2592/fan1", "");
1634 config.save(&path).unwrap();
1635
1636 let back = Config::load(&path).unwrap();
1637 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1638 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1640 assert!(!path.with_extension("toml.tmp").exists());
1641 }
1642}