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