1use std::collections::BTreeMap;
100use std::fs;
101use std::io;
102use std::path::{Path, PathBuf};
103use std::time::{Duration, Instant, SystemTime};
104
105use serde::{Deserialize, Serialize};
106
107use crate::snapshot::{Fan, Temperature};
108
109pub const CONFIG_TEMPLATE: &str = include_str!("config_template.toml");
112
113pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
115pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
117pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
119
120#[derive(Debug, thiserror::Error)]
122pub enum ConfigError {
123 #[error("cannot access {path}: {source}")]
125 Io {
126 path: PathBuf,
128 source: io::Error,
130 },
131 #[error("invalid config {path}: {source}")]
133 Parse {
134 path: PathBuf,
136 source: toml::de::Error,
138 },
139 #[error("cannot serialize config: {0}")]
141 Serialize(#[from] toml::ser::Error),
142 #[error("invalid alerts of host {host} in {path}: {source}")]
145 HostAlerts {
146 path: PathBuf,
148 host: String,
150 source: Box<toml::de::Error>,
152 },
153 #[error("cannot edit {path}: {source}")]
155 Edit {
156 path: PathBuf,
158 source: toml_edit::TomlError,
160 },
161}
162
163#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
165#[serde(default, deny_unknown_fields)]
166pub struct Config {
167 pub sensors: SensorConfig,
169 pub smart: SmartConfig,
171 pub btrfs: BtrfsConfig,
173 pub lhm: LhmConfig,
176 pub overview: OverviewConfig,
178 pub tui: TuiConfig,
180 pub gpu: GpuConfig,
182 pub alerts: AlertsConfig,
184 pub notifications: NotificationsConfig,
186 pub updates: UpdatesConfig,
188 pub watch: WatchConfig,
190 pub hosts: Vec<HostConfig>,
192 pub widgets: Vec<WidgetConfig>,
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
198#[serde(rename_all = "lowercase")]
199pub enum BuiltinWidget {
200 Clock,
202}
203
204#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
207#[serde(default, deny_unknown_fields)]
208pub struct WidgetConfig {
209 pub name: String,
211 pub command: Option<String>,
213 pub builtin: Option<BuiltinWidget>,
215 pub interval_secs: u64,
217 pub timeout_secs: u64,
219}
220
221impl Default for WidgetConfig {
222 fn default() -> Self {
223 Self {
224 name: String::new(),
225 command: None,
226 builtin: None,
227 interval_secs: 5,
228 timeout_secs: 10,
229 }
230 }
231}
232
233#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
235#[serde(rename_all = "lowercase")]
236pub enum OverviewSection {
237 Cpu,
238 Gpu,
239 Memory,
240 Processes,
241 Network,
242 Storage,
243}
244
245impl OverviewSection {
246 pub const ALL: [OverviewSection; 6] = [
248 OverviewSection::Cpu,
249 OverviewSection::Gpu,
250 OverviewSection::Memory,
251 OverviewSection::Processes,
252 OverviewSection::Network,
253 OverviewSection::Storage,
254 ];
255
256 pub fn label(self) -> &'static str {
258 match self {
259 OverviewSection::Cpu => "CPU",
260 OverviewSection::Gpu => "GPU",
261 OverviewSection::Memory => "Memory",
262 OverviewSection::Processes => "Processes",
263 OverviewSection::Network => "Network",
264 OverviewSection::Storage => "Storage",
265 }
266 }
267}
268
269#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
271#[serde(default, deny_unknown_fields)]
272pub struct TuiConfig {
273 pub hidden_panels: Vec<OverviewSection>,
276 pub show_widgets: bool,
278}
279
280impl Default for TuiConfig {
281 fn default() -> Self {
282 Self {
283 hidden_panels: Vec::new(),
284 show_widgets: true,
285 }
286 }
287}
288
289#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
291#[serde(default, deny_unknown_fields)]
292pub struct OverviewConfig {
293 pub sections: Vec<OverviewSection>,
297 pub include_local: bool,
299 pub processes: usize,
302 pub alerts_first: bool,
306}
307
308impl Default for OverviewConfig {
309 fn default() -> Self {
310 Self {
311 sections: OverviewSection::ALL.to_vec(),
312 include_local: true,
313 processes: 10,
314 alerts_first: false,
315 }
316 }
317}
318
319#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
321#[serde(deny_unknown_fields)]
322pub struct HostConfig {
323 pub name: String,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub ssh: Option<String>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub command: Option<String>,
332 #[serde(default, skip_serializing_if = "Option::is_none")]
335 pub sections: Option<Vec<OverviewSection>>,
336 #[serde(default, skip_serializing_if = "Option::is_none")]
340 pub alerts: Option<toml::Table>,
341}
342
343impl HostConfig {
344 pub fn from_destination(destination: &str) -> Self {
346 Self {
347 name: destination.to_owned(),
348 ssh: None,
349 command: None,
350 sections: None,
351 alerts: None,
352 }
353 }
354
355 pub fn destination(&self) -> &str {
357 self.ssh.as_deref().unwrap_or(&self.name)
358 }
359}
360
361#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
363#[serde(default, deny_unknown_fields)]
364pub struct SensorConfig {
365 pub labels: BTreeMap<String, String>,
368 pub hidden: Vec<String>,
370}
371
372#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
374#[serde(default, deny_unknown_fields)]
375pub struct SmartConfig {
376 pub dir: PathBuf,
378}
379
380impl Default for SmartConfig {
381 fn default() -> Self {
382 Self {
383 dir: PathBuf::from(DEFAULT_SMART_DIR),
384 }
385 }
386}
387
388#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
390#[serde(rename_all = "lowercase")]
391pub enum NotifyLevel {
392 Off,
394 Warning,
396 #[default]
398 Critical,
399}
400
401#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
405#[serde(default, deny_unknown_fields)]
406pub struct AlertsConfig {
407 pub filesystem_warning_percent: f64,
409 pub filesystem_critical_percent: f64,
411 pub filesystem_full_warning_hours: f64,
414 pub cpu_warning_celsius: f32,
417 pub gpu_warning_celsius: f32,
419 pub drive_warning_celsius: f32,
421 pub memory_warning_percent: f64,
423 pub gpu_memory_warning_percent: f64,
427 pub memory_pressure_warning_percent: f64,
431 pub io_pressure_warning_percent: f64,
435 pub ignored_units: Vec<String>,
438}
439
440impl Default for AlertsConfig {
441 fn default() -> Self {
442 Self {
443 filesystem_warning_percent: 90.0,
444 filesystem_critical_percent: 97.0,
445 filesystem_full_warning_hours: 24.0,
446 cpu_warning_celsius: 96.0,
447 gpu_warning_celsius: 85.0,
448 drive_warning_celsius: 70.0,
449 memory_warning_percent: 90.0,
450 gpu_memory_warning_percent: 0.0,
451 memory_pressure_warning_percent: 10.0,
452 io_pressure_warning_percent: 30.0,
453 ignored_units: Vec::new(),
454 }
455 }
456}
457
458#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
460#[serde(default, deny_unknown_fields)]
461pub struct GpuConfig {
462 pub integrated: IntegratedGpu,
465}
466
467#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
470#[serde(rename_all = "lowercase")]
471pub enum IntegratedGpu {
472 #[default]
474 Compact,
475 Hide,
477 Full,
479}
480
481#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
483#[serde(default, deny_unknown_fields)]
484pub struct NotificationsConfig {
485 pub level: NotifyLevel,
486}
487
488#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
490#[serde(default, deny_unknown_fields)]
491pub struct UpdatesConfig {
492 pub check: bool,
494 #[serde(with = "duration_text")]
497 pub interval: Duration,
498 pub notify: bool,
501}
502
503impl Default for UpdatesConfig {
504 fn default() -> Self {
505 Self {
506 check: true,
507 interval: Duration::from_secs(3600),
508 notify: true,
509 }
510 }
511}
512
513impl UpdatesConfig {
514 pub fn check_interval(&self) -> Duration {
516 self.interval.max(Duration::from_secs(60))
517 }
518}
519
520#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
523#[serde(default, deny_unknown_fields)]
524pub struct WatchConfig {
525 pub command: String,
528 pub level: NotifyLevel,
530 pub cleared: bool,
532 #[serde(with = "duration_text")]
534 pub interval: Duration,
535 #[serde(with = "duration_text")]
537 pub offline_after: Duration,
538 #[serde(with = "duration_text")]
540 pub timeout: Duration,
541 pub processes: Vec<String>,
545}
546
547impl Default for WatchConfig {
548 fn default() -> Self {
549 Self {
550 command: String::new(),
551 level: NotifyLevel::Critical,
552 cleared: true,
553 interval: Duration::from_secs(10),
554 offline_after: Duration::from_secs(180),
555 timeout: Duration::from_secs(30),
556 processes: Vec::new(),
557 }
558 }
559}
560
561mod duration_text {
564 use std::time::Duration;
565
566 use serde::de::Error;
567 use serde::{Deserialize, Deserializer, Serializer};
568
569 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
570
571 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
572 serializer.serialize_str(&format(*duration))
573 }
574
575 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
576 let text = String::deserialize(deserializer)?;
577 parse(&text).ok_or_else(|| {
578 D::Error::custom(format!(
579 "invalid duration {text:?}: write a number and s, m, h or d, \
580 e.g. \"30m\", \"6h\" or \"1h30m\""
581 ))
582 })
583 }
584
585 pub fn parse(text: &str) -> Option<Duration> {
587 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
588 if text.is_empty() {
589 return None;
590 }
591 let mut total = 0u64;
592 let mut number = String::new();
593 for c in text.chars() {
594 if c.is_ascii_digit() {
595 number.push(c);
596 continue;
597 }
598 let (_, secs) = UNITS
599 .iter()
600 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
601 let value: u64 = number.parse().ok()?;
602 total = total.checked_add(value.checked_mul(*secs)?)?;
603 number.clear();
604 }
605 number.is_empty().then(|| Duration::from_secs(total))
607 }
608
609 pub fn format(duration: Duration) -> String {
611 let mut secs = duration.as_secs();
612 if secs == 0 {
613 return "0s".to_owned();
614 }
615 let mut text = String::new();
616 for (unit, size) in UNITS {
617 if secs >= size {
618 text.push_str(&format!("{}{unit}", secs / size));
619 secs %= size;
620 }
621 }
622 text
623 }
624}
625
626#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
628#[serde(default, deny_unknown_fields)]
629pub struct LhmConfig {
630 pub url: String,
633}
634
635impl Default for LhmConfig {
636 fn default() -> Self {
637 Self {
638 url: DEFAULT_LHM_URL.to_owned(),
639 }
640 }
641}
642
643#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
645#[serde(default, deny_unknown_fields)]
646pub struct BtrfsConfig {
647 pub scrub_dir: PathBuf,
649}
650
651impl Default for BtrfsConfig {
652 fn default() -> Self {
653 Self {
654 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
655 }
656 }
657}
658
659pub fn create_if_missing(path: &Path) -> io::Result<bool> {
662 if path.exists() {
663 return Ok(false);
664 }
665 if let Some(dir) = path.parent() {
666 fs::create_dir_all(dir)?;
667 }
668 fs::write(path, CONFIG_TEMPLATE)?;
669 Ok(true)
670}
671
672impl Config {
673 pub fn default_path() -> Option<PathBuf> {
675 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
676 }
677
678 pub fn load(path: &Path) -> Result<Self, ConfigError> {
680 let text = match fs::read_to_string(path) {
681 Ok(text) => text,
682 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
683 Err(source) => {
684 return Err(ConfigError::Io {
685 path: path.to_owned(),
686 source,
687 });
688 }
689 };
690 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
691 path: path.to_owned(),
692 source,
693 })?;
694 for host in &config.hosts {
696 config
697 .alerts_for(host)
698 .map_err(|source| ConfigError::HostAlerts {
699 path: path.to_owned(),
700 host: host.name.clone(),
701 source: Box::new(source),
702 })?;
703 }
704 Ok(config)
705 }
706
707 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
710 let Some(overrides) = &host.alerts else {
711 return Ok(self.alerts.clone());
712 };
713 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
715 merged.extend(overrides.clone());
716 merged.try_into()
717 }
718
719 pub fn load_default() -> Result<Self, ConfigError> {
722 match Self::default_path() {
723 Some(path) => Self::load(&path),
724 None => Ok(Self::default()),
725 }
726 }
727
728 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
733 let text = toml::to_string_pretty(self)?;
734 let io_err = |source| ConfigError::Io {
735 path: path.to_owned(),
736 source,
737 };
738 if let Some(parent) = path.parent() {
739 fs::create_dir_all(parent).map_err(io_err)?;
740 }
741 let tmp = path.with_extension("toml.tmp");
742 fs::write(&tmp, text).map_err(io_err)?;
743 fs::rename(&tmp, path).map_err(io_err)
744 }
745
746 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
753 edit_file(path, |document| {
754 if hosts.is_empty() {
755 document.remove("hosts");
756 return Ok(());
757 }
758 let mut tables = toml_edit::ArrayOfTables::new();
759 for host in hosts {
760 let mut table = toml_edit::Table::new();
761 table["name"] = toml_edit::value(host.name.as_str());
762 if let Some(ssh) = &host.ssh {
763 table["ssh"] = toml_edit::value(ssh.as_str());
764 }
765 if let Some(command) = &host.command {
766 table["command"] = toml_edit::value(command.as_str());
767 }
768 if let Some(sections) = &host.sections {
769 let mut array = toml_edit::Array::new();
770 for section in sections {
771 let name = toml::Value::try_from(section)?;
773 if let Some(name) = name.as_str() {
774 array.push(name);
775 }
776 }
777 table["sections"] = toml_edit::value(array);
778 }
779 if let Some(alerts) = &host.alerts {
780 let mut sub = toml_edit::Table::new();
782 for (key, value) in alerts {
783 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
784 sub[key.as_str()] = toml_edit::value(value);
785 }
786 }
787 table["alerts"] = toml_edit::Item::Table(sub);
788 }
789 tables.push(table);
790 }
791 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
792 Ok(())
793 })
794 }
795
796 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
805 edit_file(path, |document| {
806 if !document.contains_key("tui") {
807 if *tui == TuiConfig::default() {
808 return Ok(());
809 }
810 document["tui"] = toml_edit::table();
811 }
812 let Some(table) = document["tui"].as_table_like_mut() else {
813 return Ok(());
815 };
816 if tui.hidden_panels.is_empty() {
817 table.remove("hidden_panels");
818 } else {
819 let names: toml_edit::Array = tui
820 .hidden_panels
821 .iter()
822 .filter_map(|section| {
823 serde_json::to_value(section)
824 .ok()
825 .and_then(|v| v.as_str().map(str::to_owned))
826 })
827 .collect();
828 table.insert("hidden_panels", toml_edit::value(names));
829 }
830 if tui.show_widgets {
831 table.remove("show_widgets");
832 } else {
833 table.insert("show_widgets", toml_edit::value(false));
834 }
835 Ok(())
836 })
837 }
838
839 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
840 edit_file(path, |document| {
841 if !document.contains_key("sensors") {
842 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
843 return Ok(());
844 }
845 document["sensors"] = toml_edit::table();
846 }
847 let Some(table) = document["sensors"].as_table_like_mut() else {
848 return Ok(());
850 };
851 if sensors.hidden.is_empty() {
852 table.remove("hidden");
853 } else {
854 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
855 table.insert("hidden", toml_edit::value(hidden));
856 }
857 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
858 if sensors.labels.is_empty() {
859 table.remove("labels");
860 } else if inline {
861 let labels: toml_edit::InlineTable = sensors
862 .labels
863 .iter()
864 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
865 .collect();
866 table.insert("labels", toml_edit::value(labels));
867 } else {
868 let mut labels = toml_edit::Table::new();
869 for (key, name) in &sensors.labels {
870 labels[key.as_str()] = toml_edit::value(name.as_str());
871 }
872 table.insert("labels", toml_edit::Item::Table(labels));
873 }
874 if table.is_empty() {
875 document.remove("sensors");
876 }
877 Ok(())
878 })
879 }
880
881 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
883 self.label_for(&fan.key).unwrap_or(&fan.label)
884 }
885
886 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
888 self.label_for(&temperature.key)
889 .unwrap_or(&temperature.label)
890 }
891
892 pub fn label_for(&self, key: &str) -> Option<&str> {
894 self.sensors.labels.get(key).map(String::as_str)
895 }
896
897 pub fn set_label(&mut self, key: &str, name: &str) {
899 let name = name.trim();
900 if name.is_empty() {
901 self.sensors.labels.remove(key);
902 } else {
903 self.sensors.labels.insert(key.to_owned(), name.to_owned());
904 }
905 }
906
907 pub fn is_hidden(&self, key: &str) -> bool {
909 self.sensors.hidden.iter().any(|k| k == key)
910 }
911}
912
913fn edit_file(
918 path: &Path,
919 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
920) -> Result<(), ConfigError> {
921 let io_err = |source| ConfigError::Io {
922 path: path.to_owned(),
923 source,
924 };
925 let text = match fs::read_to_string(path) {
926 Ok(text) => text,
927 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
928 Err(err) => return Err(io_err(err)),
929 };
930 let mut document: toml_edit::DocumentMut =
931 text.parse().map_err(|source| ConfigError::Edit {
932 path: path.to_owned(),
933 source,
934 })?;
935 edit(&mut document)?;
936 let edited = document.to_string();
937 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
939 path: path.to_owned(),
940 source,
941 })?;
942 if let Some(parent) = path.parent() {
943 fs::create_dir_all(parent).map_err(io_err)?;
944 }
945 let tmp = path.with_extension("toml.tmp");
946 fs::write(&tmp, edited).map_err(io_err)?;
947 fs::rename(&tmp, path).map_err(io_err)
948}
949
950const WATCH_INTERVAL: Duration = Duration::from_secs(1);
952
953pub struct ConfigWatcher {
956 path: PathBuf,
957 stamp: Option<(Option<SystemTime>, u64)>,
959 last_check: Instant,
960 interval: Duration,
961}
962
963impl ConfigWatcher {
964 pub fn new(path: PathBuf) -> Self {
966 Self {
967 stamp: file_stamp(&path),
968 path,
969 last_check: Instant::now(),
970 interval: WATCH_INTERVAL,
971 }
972 }
973
974 pub fn path(&self) -> &Path {
976 &self.path
977 }
978
979 pub fn mark_seen(&mut self) {
982 self.stamp = file_stamp(&self.path);
983 }
984
985 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
990 if self.last_check.elapsed() < self.interval {
991 return None;
992 }
993 self.last_check = Instant::now();
994 let stamp = file_stamp(&self.path);
995 if stamp == self.stamp {
996 return None;
997 }
998 self.stamp = stamp;
999 stamp?;
1000 Some(Config::load(&self.path))
1001 }
1002}
1003
1004fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
1006 let metadata = fs::metadata(path).ok()?;
1007 Some((metadata.modified().ok(), metadata.len()))
1008}
1009
1010#[cfg(test)]
1011mod tests {
1012 use super::*;
1013
1014 #[test]
1015 fn template_parses_with_every_setting_uncommented() {
1016 let settings: String = CONFIG_TEMPLATE
1018 .lines()
1019 .filter_map(|line| line.strip_prefix('#'))
1020 .filter(|line| !line.starts_with(' ') && !line.is_empty())
1021 .map(|line| format!("{line}\n"))
1022 .collect();
1023 let config: Config = toml::from_str(&settings).unwrap();
1024 assert_eq!(config.hosts[0].name, "nas");
1025 assert_eq!(config.alerts, AlertsConfig::default());
1027 assert_eq!(config.watch, WatchConfig::default());
1028 assert_eq!(config.overview, OverviewConfig::default());
1029 let empty: Config = toml::from_str(CONFIG_TEMPLATE).unwrap();
1031 assert_eq!(empty, Config::default());
1032
1033 let tmp = tempfile::tempdir().unwrap();
1034 let path = tmp.path().join("rigtop/config.toml");
1035 assert!(create_if_missing(&path).unwrap());
1036 assert!(!create_if_missing(&path).unwrap(), "left as it is");
1037 assert_eq!(Config::load(&path).unwrap(), Config::default());
1038 }
1039
1040 #[test]
1041 fn hidden_panels_are_saved_and_loaded() {
1042 let tmp = tempfile::tempdir().unwrap();
1043 let path = tmp.path().join("config.toml");
1044 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1045 let tui = TuiConfig {
1046 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
1047 show_widgets: false,
1048 };
1049 Config::save_tui(&path, &tui).unwrap();
1050 let text = fs::read_to_string(&path).unwrap();
1051 assert!(text.starts_with("# mine"), "{text}");
1052 let config = Config::load(&path).unwrap();
1053 assert_eq!(
1054 config.tui.hidden_panels,
1055 vec![OverviewSection::Gpu, OverviewSection::Network]
1056 );
1057 assert!(!config.tui.show_widgets);
1058 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
1059 Config::save_tui(&path, &TuiConfig::default()).unwrap();
1060 let config = Config::load(&path).unwrap();
1061 assert_eq!(config.tui, TuiConfig::default());
1062 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
1063 }
1064
1065 #[test]
1066 fn watch_settings() {
1067 let config: Config = toml::from_str(
1068 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1069 )
1070 .unwrap();
1071 assert_eq!(config.watch.command, "notify-send x");
1072 assert_eq!(config.watch.level, NotifyLevel::Warning);
1073 assert!(config.watch.cleared);
1074 assert_eq!(config.watch.interval, Duration::from_secs(30));
1075 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1076 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1077 }
1078
1079 #[test]
1080 fn update_checks_can_be_tuned_or_turned_off() {
1081 let config: Config =
1082 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1083 assert!(config.updates.check);
1084 assert_eq!(
1085 config.updates.check_interval(),
1086 Duration::from_secs(6 * 3600)
1087 );
1088 assert!(!config.updates.notify);
1089 let config: Config =
1090 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1091 assert!(!config.updates.check);
1092 assert_eq!(
1093 config.updates.check_interval(),
1094 Duration::from_secs(60),
1095 "at least a minute"
1096 );
1097 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1098 assert!(error.to_string().contains("invalid duration"), "{error}");
1099 }
1100
1101 #[test]
1102 fn durations_read_and_write_as_text() {
1103 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1104 assert_eq!(duration_text::parse("30m"), minutes(30));
1105 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1106 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1107 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1108 assert_eq!(duration_text::parse("2H"), minutes(120));
1109 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1110 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1111 }
1112 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1113 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1114 let text = toml::to_string(&Config::default()).unwrap();
1116 assert!(text.contains("interval = \"1h\""), "{text}");
1117 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1118 }
1119
1120 #[test]
1121 fn watcher_reports_edits_once() {
1122 let tmp = tempfile::tempdir().unwrap();
1123 let path = tmp.path().join("config.toml");
1124 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1125 let mut watcher = ConfigWatcher::new(path.clone());
1126 watcher.interval = Duration::ZERO;
1127 assert!(
1128 watcher.poll().is_none(),
1129 "the initial file counts as loaded"
1130 );
1131
1132 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1134 let config = watcher.poll().unwrap().unwrap();
1135 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1136 assert!(watcher.poll().is_none(), "reported once");
1137
1138 fs::write(&path, "[alerts\n").unwrap();
1139 assert!(
1140 watcher.poll().unwrap().is_err(),
1141 "a broken file is an error"
1142 );
1143 fs::remove_file(&path).unwrap();
1144 assert!(
1145 watcher.poll().is_none(),
1146 "removing the file changes nothing"
1147 );
1148 }
1149
1150 #[test]
1151 fn missing_file_gives_defaults() {
1152 let tmp = tempfile::tempdir().unwrap();
1153 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1154 assert_eq!(config, Config::default());
1155 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1156 }
1157
1158 #[test]
1159 fn parses_labels_hidden_and_smart_dir() {
1160 let tmp = tempfile::tempdir().unwrap();
1161 let path = tmp.path().join("config.toml");
1162 fs::write(
1163 &path,
1164 r#"
1165[sensors]
1166labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1167hidden = ["nct6686:nct6683.2592/temp4"]
1168
1169[smart]
1170dir = "/tmp/smart"
1171
1172[lhm]
1173url = ""
1174
1175[notifications]
1176level = "warning"
1177
1178[alerts]
1179drive_warning_celsius = 65
1180cpu_warning_celsius = 0
1181gpu_memory_warning_percent = 98
1182"#,
1183 )
1184 .unwrap();
1185 let config = Config::load(&path).unwrap();
1186 let fan = Fan {
1187 key: "nct6686:nct6683.2592/fan1".to_owned(),
1188 label: "fan1".to_owned(),
1189 rpm: 900,
1190 };
1191 let other = Fan {
1192 key: "nct6686:nct6683.2592/fan2".to_owned(),
1193 label: "fan2".to_owned(),
1194 rpm: 0,
1195 };
1196 assert_eq!(config.fan_label(&fan), "CPU fan");
1197 assert_eq!(config.fan_label(&other), "fan2");
1199 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1200 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1201 assert_eq!(config.lhm.url, "");
1202 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1203 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1204 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1205 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1206 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1207 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1209 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1210 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1211 }
1212
1213 #[test]
1214 fn parses_hosts_and_overview() {
1215 let config: Config = toml::from_str(
1216 r#"
1217[overview]
1218sections = ["storage", "cpu"]
1219include_local = false
1220
1221[[hosts]]
1222name = "zefat"
1223
1224[[hosts]]
1225name = "nas"
1226ssh = "admin@nas.local"
1227sections = ["storage"]
1228
1229[[widgets]]
1230name = "Clock"
1231builtin = "clock"
1232
1233[[widgets]]
1234name = "Rates"
1235command = "rates.sh"
1236interval_secs = 60
1237"#,
1238 )
1239 .unwrap();
1240 assert_eq!(
1241 config.overview.sections,
1242 vec![OverviewSection::Storage, OverviewSection::Cpu]
1243 );
1244 assert!(!config.overview.include_local);
1245 assert_eq!(config.overview.processes, 10, "default kept");
1246 assert_eq!(config.hosts.len(), 2);
1247 assert_eq!(config.hosts[0].destination(), "zefat");
1248 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1249 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1250 assert_eq!(config.widgets[1].interval_secs, 60);
1251 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1252 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1253 assert_eq!(
1254 config.hosts[1].sections,
1255 Some(vec![OverviewSection::Storage])
1256 );
1257 let defaults = Config::default();
1259 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1260 assert!(defaults.overview.include_local);
1261 }
1262
1263 #[test]
1264 fn save_hosts_replaces_only_the_hosts() {
1265 let tmp = tempfile::tempdir().unwrap();
1266 let path = tmp.path().join("config.toml");
1267 fs::write(
1268 &path,
1269 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1270 )
1271 .unwrap();
1272 let hosts = vec![
1273 HostConfig::from_destination("zefat"),
1274 HostConfig {
1275 name: "nas".to_owned(),
1276 ssh: Some("admin@nas.local".to_owned()),
1277 command: Some("/opt/rigtop --agent".to_owned()),
1278 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1279 alerts: Some(
1280 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1281 .unwrap(),
1282 ),
1283 },
1284 ];
1285 Config::save_hosts(&path, &hosts).unwrap();
1286
1287 let text = fs::read_to_string(&path).unwrap();
1288 assert!(
1289 text.contains("# my settings") && text.contains("# keep me"),
1290 "{text}"
1291 );
1292 assert!(!text.contains("\"old\""), "{text}");
1293 let config = Config::load(&path).unwrap();
1294 assert_eq!(config.hosts, hosts);
1295 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1296
1297 Config::save_hosts(&path, &[]).unwrap();
1299 assert!(Config::load(&path).unwrap().hosts.is_empty());
1300 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1301 }
1302
1303 #[test]
1304 fn host_alerts_replace_the_keys_they_give() {
1305 let tmp = tempfile::tempdir().unwrap();
1306 let path = tmp.path().join("config.toml");
1307 fs::write(
1308 &path,
1309 "[alerts]\nmemory_warning_percent = 80\n\n\
1310 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1311 [[hosts]]\nname = \"nas\"\n",
1312 )
1313 .unwrap();
1314 let config = Config::load(&path).unwrap();
1315 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1316 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1317 assert_eq!(laptop.memory_warning_percent, 80.0);
1319 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1320 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1321
1322 fs::write(
1324 &path,
1325 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1326 )
1327 .unwrap();
1328 let err = Config::load(&path).unwrap_err().to_string();
1329 assert!(
1330 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1331 "{err}"
1332 );
1333 }
1334
1335 #[test]
1336 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1337 let tmp = tempfile::tempdir().unwrap();
1338 let path = tmp.path().join("config.toml");
1339 fs::write(
1340 &path,
1341 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1342 )
1343 .unwrap();
1344 let mut sensors = SensorConfig::default();
1345 sensors
1346 .labels
1347 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1348 sensors
1349 .labels
1350 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1351 sensors.hidden.push("b:c.1/temp4".to_owned());
1352 Config::save_sensors(&path, &sensors).unwrap();
1353
1354 let text = fs::read_to_string(&path).unwrap();
1355 assert!(text.contains("# my settings"), "{text}");
1356 assert!(text.contains("labels = {"), "{text}");
1358 let config = Config::load(&path).unwrap();
1359 assert_eq!(config.sensors, sensors);
1360 assert_eq!(config.hosts[0].name, "zefat");
1361
1362 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1364 let text = fs::read_to_string(&path).unwrap();
1365 assert!(!text.contains("[sensors]"), "{text}");
1366 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1367
1368 let fresh = tmp.path().join("new.toml");
1370 Config::save_sensors(&fresh, &sensors).unwrap();
1371 let text = fs::read_to_string(&fresh).unwrap();
1372 assert!(text.contains("[sensors.labels]"), "{text}");
1373 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1374 }
1375
1376 #[test]
1377 fn save_hosts_creates_a_missing_file() {
1378 let tmp = tempfile::tempdir().unwrap();
1379 let path = tmp.path().join("new/rigtop/config.toml");
1380 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1381 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1382 }
1383
1384 #[test]
1385 fn save_hosts_refuses_to_touch_a_broken_file() {
1386 let tmp = tempfile::tempdir().unwrap();
1387 let path = tmp.path().join("config.toml");
1388 fs::write(&path, "[sensors\n").unwrap();
1389 let err = Config::save_hosts(&path, &[]).unwrap_err();
1390 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1391 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1392 }
1393
1394 #[test]
1395 fn unknown_fields_are_rejected() {
1396 let tmp = tempfile::tempdir().unwrap();
1397 let path = tmp.path().join("config.toml");
1398 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1400 assert!(matches!(
1401 Config::load(&path),
1402 Err(ConfigError::Parse { .. })
1403 ));
1404 }
1405
1406 #[test]
1407 fn save_and_reload_round_trip() {
1408 let tmp = tempfile::tempdir().unwrap();
1409 let path = tmp.path().join("nested/rigtop/config.toml");
1410 let mut config = Config::default();
1411 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1412 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1413 config.set_label("nct6686:nct6683.2592/fan1", "");
1414 config.save(&path).unwrap();
1415
1416 let back = Config::load(&path).unwrap();
1417 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1418 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1420 assert!(!path.with_extension("toml.tmp").exists());
1421 }
1422}