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 alerts: AlertsConfig,
182 pub notifications: NotificationsConfig,
184 pub updates: UpdatesConfig,
186 pub watch: WatchConfig,
188 pub hosts: Vec<HostConfig>,
190 pub widgets: Vec<WidgetConfig>,
192}
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "lowercase")]
197pub enum BuiltinWidget {
198 Clock,
200}
201
202#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(default, deny_unknown_fields)]
206pub struct WidgetConfig {
207 pub name: String,
209 pub command: Option<String>,
211 pub builtin: Option<BuiltinWidget>,
213 pub interval_secs: u64,
215 pub timeout_secs: u64,
217}
218
219impl Default for WidgetConfig {
220 fn default() -> Self {
221 Self {
222 name: String::new(),
223 command: None,
224 builtin: None,
225 interval_secs: 5,
226 timeout_secs: 10,
227 }
228 }
229}
230
231#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
233#[serde(rename_all = "lowercase")]
234pub enum OverviewSection {
235 Cpu,
236 Gpu,
237 Memory,
238 Processes,
239 Network,
240 Storage,
241}
242
243impl OverviewSection {
244 pub const ALL: [OverviewSection; 6] = [
246 OverviewSection::Cpu,
247 OverviewSection::Gpu,
248 OverviewSection::Memory,
249 OverviewSection::Processes,
250 OverviewSection::Network,
251 OverviewSection::Storage,
252 ];
253
254 pub fn label(self) -> &'static str {
256 match self {
257 OverviewSection::Cpu => "CPU",
258 OverviewSection::Gpu => "GPU",
259 OverviewSection::Memory => "Memory",
260 OverviewSection::Processes => "Processes",
261 OverviewSection::Network => "Network",
262 OverviewSection::Storage => "Storage",
263 }
264 }
265}
266
267#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
269#[serde(default, deny_unknown_fields)]
270pub struct TuiConfig {
271 pub hidden_panels: Vec<OverviewSection>,
274 pub show_widgets: bool,
276}
277
278impl Default for TuiConfig {
279 fn default() -> Self {
280 Self {
281 hidden_panels: Vec::new(),
282 show_widgets: true,
283 }
284 }
285}
286
287#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(default, deny_unknown_fields)]
290pub struct OverviewConfig {
291 pub sections: Vec<OverviewSection>,
295 pub include_local: bool,
297 pub processes: usize,
300 pub alerts_first: bool,
304}
305
306impl Default for OverviewConfig {
307 fn default() -> Self {
308 Self {
309 sections: OverviewSection::ALL.to_vec(),
310 include_local: true,
311 processes: 10,
312 alerts_first: false,
313 }
314 }
315}
316
317#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
319#[serde(deny_unknown_fields)]
320pub struct HostConfig {
321 pub name: String,
323 #[serde(default, skip_serializing_if = "Option::is_none")]
326 pub ssh: Option<String>,
327 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub command: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub sections: Option<Vec<OverviewSection>>,
334 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub alerts: Option<toml::Table>,
339}
340
341impl HostConfig {
342 pub fn from_destination(destination: &str) -> Self {
344 Self {
345 name: destination.to_owned(),
346 ssh: None,
347 command: None,
348 sections: None,
349 alerts: None,
350 }
351 }
352
353 pub fn destination(&self) -> &str {
355 self.ssh.as_deref().unwrap_or(&self.name)
356 }
357}
358
359#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
361#[serde(default, deny_unknown_fields)]
362pub struct SensorConfig {
363 pub labels: BTreeMap<String, String>,
366 pub hidden: Vec<String>,
368}
369
370#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
372#[serde(default, deny_unknown_fields)]
373pub struct SmartConfig {
374 pub dir: PathBuf,
376}
377
378impl Default for SmartConfig {
379 fn default() -> Self {
380 Self {
381 dir: PathBuf::from(DEFAULT_SMART_DIR),
382 }
383 }
384}
385
386#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
388#[serde(rename_all = "lowercase")]
389pub enum NotifyLevel {
390 Off,
392 Warning,
394 #[default]
396 Critical,
397}
398
399#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
403#[serde(default, deny_unknown_fields)]
404pub struct AlertsConfig {
405 pub filesystem_warning_percent: f64,
407 pub filesystem_critical_percent: f64,
409 pub filesystem_full_warning_hours: f64,
412 pub cpu_warning_celsius: f32,
415 pub gpu_warning_celsius: f32,
417 pub drive_warning_celsius: f32,
419 pub memory_warning_percent: f64,
421 pub gpu_memory_warning_percent: f64,
425 pub memory_pressure_warning_percent: f64,
429 pub io_pressure_warning_percent: f64,
433 pub ignored_units: Vec<String>,
436}
437
438impl Default for AlertsConfig {
439 fn default() -> Self {
440 Self {
441 filesystem_warning_percent: 90.0,
442 filesystem_critical_percent: 97.0,
443 filesystem_full_warning_hours: 24.0,
444 cpu_warning_celsius: 96.0,
445 gpu_warning_celsius: 85.0,
446 drive_warning_celsius: 70.0,
447 memory_warning_percent: 90.0,
448 gpu_memory_warning_percent: 0.0,
449 memory_pressure_warning_percent: 10.0,
450 io_pressure_warning_percent: 30.0,
451 ignored_units: Vec::new(),
452 }
453 }
454}
455
456#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
458#[serde(default, deny_unknown_fields)]
459pub struct NotificationsConfig {
460 pub level: NotifyLevel,
461}
462
463#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
465#[serde(default, deny_unknown_fields)]
466pub struct UpdatesConfig {
467 pub check: bool,
469 #[serde(with = "duration_text")]
472 pub interval: Duration,
473 pub notify: bool,
476}
477
478impl Default for UpdatesConfig {
479 fn default() -> Self {
480 Self {
481 check: true,
482 interval: Duration::from_secs(3600),
483 notify: true,
484 }
485 }
486}
487
488impl UpdatesConfig {
489 pub fn check_interval(&self) -> Duration {
491 self.interval.max(Duration::from_secs(60))
492 }
493}
494
495#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
498#[serde(default, deny_unknown_fields)]
499pub struct WatchConfig {
500 pub command: String,
503 pub level: NotifyLevel,
505 pub cleared: bool,
507 #[serde(with = "duration_text")]
509 pub interval: Duration,
510 #[serde(with = "duration_text")]
512 pub offline_after: Duration,
513 #[serde(with = "duration_text")]
515 pub timeout: Duration,
516 pub processes: Vec<String>,
520}
521
522impl Default for WatchConfig {
523 fn default() -> Self {
524 Self {
525 command: String::new(),
526 level: NotifyLevel::Critical,
527 cleared: true,
528 interval: Duration::from_secs(10),
529 offline_after: Duration::from_secs(180),
530 timeout: Duration::from_secs(30),
531 processes: Vec::new(),
532 }
533 }
534}
535
536mod duration_text {
539 use std::time::Duration;
540
541 use serde::de::Error;
542 use serde::{Deserialize, Deserializer, Serializer};
543
544 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
545
546 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
547 serializer.serialize_str(&format(*duration))
548 }
549
550 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
551 let text = String::deserialize(deserializer)?;
552 parse(&text).ok_or_else(|| {
553 D::Error::custom(format!(
554 "invalid duration {text:?}: write a number and s, m, h or d, \
555 e.g. \"30m\", \"6h\" or \"1h30m\""
556 ))
557 })
558 }
559
560 pub fn parse(text: &str) -> Option<Duration> {
562 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
563 if text.is_empty() {
564 return None;
565 }
566 let mut total = 0u64;
567 let mut number = String::new();
568 for c in text.chars() {
569 if c.is_ascii_digit() {
570 number.push(c);
571 continue;
572 }
573 let (_, secs) = UNITS
574 .iter()
575 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
576 let value: u64 = number.parse().ok()?;
577 total = total.checked_add(value.checked_mul(*secs)?)?;
578 number.clear();
579 }
580 number.is_empty().then(|| Duration::from_secs(total))
582 }
583
584 pub fn format(duration: Duration) -> String {
586 let mut secs = duration.as_secs();
587 if secs == 0 {
588 return "0s".to_owned();
589 }
590 let mut text = String::new();
591 for (unit, size) in UNITS {
592 if secs >= size {
593 text.push_str(&format!("{}{unit}", secs / size));
594 secs %= size;
595 }
596 }
597 text
598 }
599}
600
601#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
603#[serde(default, deny_unknown_fields)]
604pub struct LhmConfig {
605 pub url: String,
608}
609
610impl Default for LhmConfig {
611 fn default() -> Self {
612 Self {
613 url: DEFAULT_LHM_URL.to_owned(),
614 }
615 }
616}
617
618#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
620#[serde(default, deny_unknown_fields)]
621pub struct BtrfsConfig {
622 pub scrub_dir: PathBuf,
624}
625
626impl Default for BtrfsConfig {
627 fn default() -> Self {
628 Self {
629 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
630 }
631 }
632}
633
634pub fn create_if_missing(path: &Path) -> io::Result<bool> {
637 if path.exists() {
638 return Ok(false);
639 }
640 if let Some(dir) = path.parent() {
641 fs::create_dir_all(dir)?;
642 }
643 fs::write(path, CONFIG_TEMPLATE)?;
644 Ok(true)
645}
646
647impl Config {
648 pub fn default_path() -> Option<PathBuf> {
650 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
651 }
652
653 pub fn load(path: &Path) -> Result<Self, ConfigError> {
655 let text = match fs::read_to_string(path) {
656 Ok(text) => text,
657 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
658 Err(source) => {
659 return Err(ConfigError::Io {
660 path: path.to_owned(),
661 source,
662 });
663 }
664 };
665 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
666 path: path.to_owned(),
667 source,
668 })?;
669 for host in &config.hosts {
671 config
672 .alerts_for(host)
673 .map_err(|source| ConfigError::HostAlerts {
674 path: path.to_owned(),
675 host: host.name.clone(),
676 source: Box::new(source),
677 })?;
678 }
679 Ok(config)
680 }
681
682 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
685 let Some(overrides) = &host.alerts else {
686 return Ok(self.alerts.clone());
687 };
688 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
690 merged.extend(overrides.clone());
691 merged.try_into()
692 }
693
694 pub fn load_default() -> Result<Self, ConfigError> {
697 match Self::default_path() {
698 Some(path) => Self::load(&path),
699 None => Ok(Self::default()),
700 }
701 }
702
703 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
708 let text = toml::to_string_pretty(self)?;
709 let io_err = |source| ConfigError::Io {
710 path: path.to_owned(),
711 source,
712 };
713 if let Some(parent) = path.parent() {
714 fs::create_dir_all(parent).map_err(io_err)?;
715 }
716 let tmp = path.with_extension("toml.tmp");
717 fs::write(&tmp, text).map_err(io_err)?;
718 fs::rename(&tmp, path).map_err(io_err)
719 }
720
721 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
728 edit_file(path, |document| {
729 if hosts.is_empty() {
730 document.remove("hosts");
731 return Ok(());
732 }
733 let mut tables = toml_edit::ArrayOfTables::new();
734 for host in hosts {
735 let mut table = toml_edit::Table::new();
736 table["name"] = toml_edit::value(host.name.as_str());
737 if let Some(ssh) = &host.ssh {
738 table["ssh"] = toml_edit::value(ssh.as_str());
739 }
740 if let Some(command) = &host.command {
741 table["command"] = toml_edit::value(command.as_str());
742 }
743 if let Some(sections) = &host.sections {
744 let mut array = toml_edit::Array::new();
745 for section in sections {
746 let name = toml::Value::try_from(section)?;
748 if let Some(name) = name.as_str() {
749 array.push(name);
750 }
751 }
752 table["sections"] = toml_edit::value(array);
753 }
754 if let Some(alerts) = &host.alerts {
755 let mut sub = toml_edit::Table::new();
757 for (key, value) in alerts {
758 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
759 sub[key.as_str()] = toml_edit::value(value);
760 }
761 }
762 table["alerts"] = toml_edit::Item::Table(sub);
763 }
764 tables.push(table);
765 }
766 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
767 Ok(())
768 })
769 }
770
771 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
780 edit_file(path, |document| {
781 if !document.contains_key("tui") {
782 if *tui == TuiConfig::default() {
783 return Ok(());
784 }
785 document["tui"] = toml_edit::table();
786 }
787 let Some(table) = document["tui"].as_table_like_mut() else {
788 return Ok(());
790 };
791 if tui.hidden_panels.is_empty() {
792 table.remove("hidden_panels");
793 } else {
794 let names: toml_edit::Array = tui
795 .hidden_panels
796 .iter()
797 .filter_map(|section| {
798 serde_json::to_value(section)
799 .ok()
800 .and_then(|v| v.as_str().map(str::to_owned))
801 })
802 .collect();
803 table.insert("hidden_panels", toml_edit::value(names));
804 }
805 if tui.show_widgets {
806 table.remove("show_widgets");
807 } else {
808 table.insert("show_widgets", toml_edit::value(false));
809 }
810 Ok(())
811 })
812 }
813
814 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
815 edit_file(path, |document| {
816 if !document.contains_key("sensors") {
817 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
818 return Ok(());
819 }
820 document["sensors"] = toml_edit::table();
821 }
822 let Some(table) = document["sensors"].as_table_like_mut() else {
823 return Ok(());
825 };
826 if sensors.hidden.is_empty() {
827 table.remove("hidden");
828 } else {
829 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
830 table.insert("hidden", toml_edit::value(hidden));
831 }
832 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
833 if sensors.labels.is_empty() {
834 table.remove("labels");
835 } else if inline {
836 let labels: toml_edit::InlineTable = sensors
837 .labels
838 .iter()
839 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
840 .collect();
841 table.insert("labels", toml_edit::value(labels));
842 } else {
843 let mut labels = toml_edit::Table::new();
844 for (key, name) in &sensors.labels {
845 labels[key.as_str()] = toml_edit::value(name.as_str());
846 }
847 table.insert("labels", toml_edit::Item::Table(labels));
848 }
849 if table.is_empty() {
850 document.remove("sensors");
851 }
852 Ok(())
853 })
854 }
855
856 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
858 self.label_for(&fan.key).unwrap_or(&fan.label)
859 }
860
861 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
863 self.label_for(&temperature.key)
864 .unwrap_or(&temperature.label)
865 }
866
867 pub fn label_for(&self, key: &str) -> Option<&str> {
869 self.sensors.labels.get(key).map(String::as_str)
870 }
871
872 pub fn set_label(&mut self, key: &str, name: &str) {
874 let name = name.trim();
875 if name.is_empty() {
876 self.sensors.labels.remove(key);
877 } else {
878 self.sensors.labels.insert(key.to_owned(), name.to_owned());
879 }
880 }
881
882 pub fn is_hidden(&self, key: &str) -> bool {
884 self.sensors.hidden.iter().any(|k| k == key)
885 }
886}
887
888fn edit_file(
893 path: &Path,
894 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
895) -> Result<(), ConfigError> {
896 let io_err = |source| ConfigError::Io {
897 path: path.to_owned(),
898 source,
899 };
900 let text = match fs::read_to_string(path) {
901 Ok(text) => text,
902 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
903 Err(err) => return Err(io_err(err)),
904 };
905 let mut document: toml_edit::DocumentMut =
906 text.parse().map_err(|source| ConfigError::Edit {
907 path: path.to_owned(),
908 source,
909 })?;
910 edit(&mut document)?;
911 let edited = document.to_string();
912 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
914 path: path.to_owned(),
915 source,
916 })?;
917 if let Some(parent) = path.parent() {
918 fs::create_dir_all(parent).map_err(io_err)?;
919 }
920 let tmp = path.with_extension("toml.tmp");
921 fs::write(&tmp, edited).map_err(io_err)?;
922 fs::rename(&tmp, path).map_err(io_err)
923}
924
925const WATCH_INTERVAL: Duration = Duration::from_secs(1);
927
928pub struct ConfigWatcher {
931 path: PathBuf,
932 stamp: Option<(Option<SystemTime>, u64)>,
934 last_check: Instant,
935 interval: Duration,
936}
937
938impl ConfigWatcher {
939 pub fn new(path: PathBuf) -> Self {
941 Self {
942 stamp: file_stamp(&path),
943 path,
944 last_check: Instant::now(),
945 interval: WATCH_INTERVAL,
946 }
947 }
948
949 pub fn path(&self) -> &Path {
951 &self.path
952 }
953
954 pub fn mark_seen(&mut self) {
957 self.stamp = file_stamp(&self.path);
958 }
959
960 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
965 if self.last_check.elapsed() < self.interval {
966 return None;
967 }
968 self.last_check = Instant::now();
969 let stamp = file_stamp(&self.path);
970 if stamp == self.stamp {
971 return None;
972 }
973 self.stamp = stamp;
974 stamp?;
975 Some(Config::load(&self.path))
976 }
977}
978
979fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
981 let metadata = fs::metadata(path).ok()?;
982 Some((metadata.modified().ok(), metadata.len()))
983}
984
985#[cfg(test)]
986mod tests {
987 use super::*;
988
989 #[test]
990 fn template_parses_with_every_setting_uncommented() {
991 let settings: String = CONFIG_TEMPLATE
993 .lines()
994 .filter_map(|line| line.strip_prefix('#'))
995 .filter(|line| !line.starts_with(' ') && !line.is_empty())
996 .map(|line| format!("{line}\n"))
997 .collect();
998 let config: Config = toml::from_str(&settings).unwrap();
999 assert_eq!(config.hosts[0].name, "nas");
1000 assert_eq!(config.alerts, AlertsConfig::default());
1002 assert_eq!(config.watch, WatchConfig::default());
1003 assert_eq!(config.overview, OverviewConfig::default());
1004 let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1006 assert_eq!(empty, Config::default());
1007
1008 let tmp = tempfile::tempdir().unwrap();
1009 let path = tmp.path().join("rigtop/config.toml");
1010 assert!(create_if_missing(&path).unwrap());
1011 assert!(!create_if_missing(&path).unwrap(), "left as it is");
1012 assert_eq!(Config::load(&path).unwrap(), Config::default());
1013 }
1014
1015 #[test]
1016 fn hidden_panels_are_saved_and_loaded() {
1017 let tmp = tempfile::tempdir().unwrap();
1018 let path = tmp.path().join("config.toml");
1019 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1020 let tui = TuiConfig {
1021 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1022 show_widgets: false,
1023 };
1024 Config::save_tui(&path, &tui).unwrap();
1025 let text = fs::read_to_string(&path).unwrap();
1026 assert!(text.starts_with("# mine"), "{text}");
1027 let config = Config::load(&path).unwrap();
1028 assert_eq!(
1029 config.tui.hidden_panels,
1030 vec![OverviewSection::Gpu, OverviewSection::Network]
1031 );
1032 assert!(!config.tui.show_widgets);
1033 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1034 Config::save_tui(&path, &TuiConfig::default()).unwrap();
1035 let config = Config::load(&path).unwrap();
1036 assert_eq!(config.tui, TuiConfig::default());
1037 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1038 }
1039
1040 #[test]
1041 fn watch_settings() {
1042 let config: Config = toml::from_str(
1043 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1044 )
1045 .unwrap();
1046 assert_eq!(config.watch.command, "notify-send x");
1047 assert_eq!(config.watch.level, NotifyLevel::Warning);
1048 assert!(config.watch.cleared);
1049 assert_eq!(config.watch.interval, Duration::from_secs(30));
1050 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1051 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1052 }
1053
1054 #[test]
1055 fn update_checks_can_be_tuned_or_turned_off() {
1056 let config: Config =
1057 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1058 assert!(config.updates.check);
1059 assert_eq!(
1060 config.updates.check_interval(),
1061 Duration::from_secs(6 * 3600)
1062 );
1063 assert!(!config.updates.notify);
1064 let config: Config =
1065 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1066 assert!(!config.updates.check);
1067 assert_eq!(
1068 config.updates.check_interval(),
1069 Duration::from_secs(60),
1070 "at least a minute"
1071 );
1072 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1073 assert!(error.to_string().contains("invalid duration"), "{error}");
1074 }
1075
1076 #[test]
1077 fn durations_read_and_write_as_text() {
1078 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1079 assert_eq!(duration_text::parse("30m"), minutes(30));
1080 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1081 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1082 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1083 assert_eq!(duration_text::parse("2H"), minutes(120));
1084 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1085 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1086 }
1087 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1088 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1089 let text = toml::to_string(&Config::default()).unwrap();
1091 assert!(text.contains("interval = \"1h\""), "{text}");
1092 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1093 }
1094
1095 #[test]
1096 fn watcher_reports_edits_once() {
1097 let tmp = tempfile::tempdir().unwrap();
1098 let path = tmp.path().join("config.toml");
1099 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1100 let mut watcher = ConfigWatcher::new(path.clone());
1101 watcher.interval = Duration::ZERO;
1102 assert!(
1103 watcher.poll().is_none(),
1104 "the initial file counts as loaded"
1105 );
1106
1107 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1109 let config = watcher.poll().unwrap().unwrap();
1110 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1111 assert!(watcher.poll().is_none(), "reported once");
1112
1113 fs::write(&path, "[alerts\n").unwrap();
1114 assert!(
1115 watcher.poll().unwrap().is_err(),
1116 "a broken file is an error"
1117 );
1118 fs::remove_file(&path).unwrap();
1119 assert!(
1120 watcher.poll().is_none(),
1121 "removing the file changes nothing"
1122 );
1123 }
1124
1125 #[test]
1126 fn missing_file_gives_defaults() {
1127 let tmp = tempfile::tempdir().unwrap();
1128 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1129 assert_eq!(config, Config::default());
1130 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1131 }
1132
1133 #[test]
1134 fn parses_labels_hidden_and_smart_dir() {
1135 let tmp = tempfile::tempdir().unwrap();
1136 let path = tmp.path().join("config.toml");
1137 fs::write(
1138 &path,
1139 r#"
1140[sensors]
1141labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1142hidden = ["nct6686:nct6683.2592/temp4"]
1143
1144[smart]
1145dir = "/tmp/smart"
1146
1147[lhm]
1148url = ""
1149
1150[notifications]
1151level = "warning"
1152
1153[alerts]
1154drive_warning_celsius = 65
1155cpu_warning_celsius = 0
1156gpu_memory_warning_percent = 98
1157"#,
1158 )
1159 .unwrap();
1160 let config = Config::load(&path).unwrap();
1161 let fan = Fan {
1162 key: "nct6686:nct6683.2592/fan1".to_owned(),
1163 label: "fan1".to_owned(),
1164 rpm: 900,
1165 };
1166 let other = Fan {
1167 key: "nct6686:nct6683.2592/fan2".to_owned(),
1168 label: "fan2".to_owned(),
1169 rpm: 0,
1170 };
1171 assert_eq!(config.fan_label(&fan), "CPU fan");
1172 assert_eq!(config.fan_label(&other), "fan2");
1174 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1175 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1176 assert_eq!(config.lhm.url, "");
1177 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1178 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1179 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1180 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1181 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1182 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1184 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1185 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1186 }
1187
1188 #[test]
1189 fn parses_hosts_and_overview() {
1190 let config: Config = toml::from_str(
1191 r#"
1192[overview]
1193sections = ["storage", "cpu"]
1194include_local = false
1195
1196[[hosts]]
1197name = "zefat"
1198
1199[[hosts]]
1200name = "nas"
1201ssh = "admin@nas.local"
1202sections = ["storage"]
1203
1204[[widgets]]
1205name = "Clock"
1206builtin = "clock"
1207
1208[[widgets]]
1209name = "Rates"
1210command = "rates.sh"
1211interval_secs = 60
1212"#,
1213 )
1214 .unwrap();
1215 assert_eq!(
1216 config.overview.sections,
1217 vec![OverviewSection::Storage, OverviewSection::Cpu]
1218 );
1219 assert!(!config.overview.include_local);
1220 assert_eq!(config.overview.processes, 10, "default kept");
1221 assert_eq!(config.hosts.len(), 2);
1222 assert_eq!(config.hosts[0].destination(), "zefat");
1223 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1224 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1225 assert_eq!(config.widgets[1].interval_secs, 60);
1226 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1227 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1228 assert_eq!(
1229 config.hosts[1].sections,
1230 Some(vec![OverviewSection::Storage])
1231 );
1232 let defaults = Config::default();
1234 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1235 assert!(defaults.overview.include_local);
1236 }
1237
1238 #[test]
1239 fn save_hosts_replaces_only_the_hosts() {
1240 let tmp = tempfile::tempdir().unwrap();
1241 let path = tmp.path().join("config.toml");
1242 fs::write(
1243 &path,
1244 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1245 )
1246 .unwrap();
1247 let hosts = vec![
1248 HostConfig::from_destination("zefat"),
1249 HostConfig {
1250 name: "nas".to_owned(),
1251 ssh: Some("admin@nas.local".to_owned()),
1252 command: Some("/opt/rigtop --agent".to_owned()),
1253 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1254 alerts: Some(
1255 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1256 .unwrap(),
1257 ),
1258 },
1259 ];
1260 Config::save_hosts(&path, &hosts).unwrap();
1261
1262 let text = fs::read_to_string(&path).unwrap();
1263 assert!(
1264 text.contains("# my settings") && text.contains("# keep me"),
1265 "{text}"
1266 );
1267 assert!(!text.contains("\"old\""), "{text}");
1268 let config = Config::load(&path).unwrap();
1269 assert_eq!(config.hosts, hosts);
1270 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1271
1272 Config::save_hosts(&path, &[]).unwrap();
1274 assert!(Config::load(&path).unwrap().hosts.is_empty());
1275 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1276 }
1277
1278 #[test]
1279 fn host_alerts_replace_the_keys_they_give() {
1280 let tmp = tempfile::tempdir().unwrap();
1281 let path = tmp.path().join("config.toml");
1282 fs::write(
1283 &path,
1284 "[alerts]\nmemory_warning_percent = 80\n\n\
1285 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1286 [[hosts]]\nname = \"nas\"\n",
1287 )
1288 .unwrap();
1289 let config = Config::load(&path).unwrap();
1290 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1291 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1292 assert_eq!(laptop.memory_warning_percent, 80.0);
1294 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1295 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1296
1297 fs::write(
1299 &path,
1300 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1301 )
1302 .unwrap();
1303 let err = Config::load(&path).unwrap_err().to_string();
1304 assert!(
1305 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1306 "{err}"
1307 );
1308 }
1309
1310 #[test]
1311 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1312 let tmp = tempfile::tempdir().unwrap();
1313 let path = tmp.path().join("config.toml");
1314 fs::write(
1315 &path,
1316 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1317 )
1318 .unwrap();
1319 let mut sensors = SensorConfig::default();
1320 sensors
1321 .labels
1322 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1323 sensors
1324 .labels
1325 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1326 sensors.hidden.push("b:c.1/temp4".to_owned());
1327 Config::save_sensors(&path, &sensors).unwrap();
1328
1329 let text = fs::read_to_string(&path).unwrap();
1330 assert!(text.contains("# my settings"), "{text}");
1331 assert!(text.contains("labels = {"), "{text}");
1333 let config = Config::load(&path).unwrap();
1334 assert_eq!(config.sensors, sensors);
1335 assert_eq!(config.hosts[0].name, "zefat");
1336
1337 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1339 let text = fs::read_to_string(&path).unwrap();
1340 assert!(!text.contains("[sensors]"), "{text}");
1341 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1342
1343 let fresh = tmp.path().join("new.toml");
1345 Config::save_sensors(&fresh, &sensors).unwrap();
1346 let text = fs::read_to_string(&fresh).unwrap();
1347 assert!(text.contains("[sensors.labels]"), "{text}");
1348 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1349 }
1350
1351 #[test]
1352 fn save_hosts_creates_a_missing_file() {
1353 let tmp = tempfile::tempdir().unwrap();
1354 let path = tmp.path().join("new/rigtop/config.toml");
1355 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1356 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1357 }
1358
1359 #[test]
1360 fn save_hosts_refuses_to_touch_a_broken_file() {
1361 let tmp = tempfile::tempdir().unwrap();
1362 let path = tmp.path().join("config.toml");
1363 fs::write(&path, "[sensors\n").unwrap();
1364 let err = Config::save_hosts(&path, &[]).unwrap_err();
1365 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1366 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1367 }
1368
1369 #[test]
1370 fn unknown_fields_are_rejected() {
1371 let tmp = tempfile::tempdir().unwrap();
1372 let path = tmp.path().join("config.toml");
1373 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1375 assert!(matches!(
1376 Config::load(&path),
1377 Err(ConfigError::Parse { .. })
1378 ));
1379 }
1380
1381 #[test]
1382 fn save_and_reload_round_trip() {
1383 let tmp = tempfile::tempdir().unwrap();
1384 let path = tmp.path().join("nested/rigtop/config.toml");
1385 let mut config = Config::default();
1386 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1387 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1388 config.set_label("nct6686:nct6683.2592/fan1", "");
1389 config.save(&path).unwrap();
1390
1391 let back = Config::load(&path).unwrap();
1392 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1393 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1395 assert!(!path.with_extension("toml.tmp").exists());
1396 }
1397}