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 DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
111pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
113pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
115
116#[derive(Debug, thiserror::Error)]
118pub enum ConfigError {
119 #[error("cannot access {path}: {source}")]
121 Io {
122 path: PathBuf,
124 source: io::Error,
126 },
127 #[error("invalid config {path}: {source}")]
129 Parse {
130 path: PathBuf,
132 source: toml::de::Error,
134 },
135 #[error("cannot serialize config: {0}")]
137 Serialize(#[from] toml::ser::Error),
138 #[error("invalid alerts of host {host} in {path}: {source}")]
141 HostAlerts {
142 path: PathBuf,
144 host: String,
146 source: Box<toml::de::Error>,
148 },
149 #[error("cannot edit {path}: {source}")]
151 Edit {
152 path: PathBuf,
154 source: toml_edit::TomlError,
156 },
157}
158
159#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
161#[serde(default, deny_unknown_fields)]
162pub struct Config {
163 pub sensors: SensorConfig,
165 pub smart: SmartConfig,
167 pub btrfs: BtrfsConfig,
169 pub lhm: LhmConfig,
172 pub overview: OverviewConfig,
174 pub tui: TuiConfig,
176 pub alerts: AlertsConfig,
178 pub notifications: NotificationsConfig,
180 pub updates: UpdatesConfig,
182 pub watch: WatchConfig,
184 pub hosts: Vec<HostConfig>,
186 pub widgets: Vec<WidgetConfig>,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
192#[serde(rename_all = "lowercase")]
193pub enum BuiltinWidget {
194 Clock,
196}
197
198#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
201#[serde(default, deny_unknown_fields)]
202pub struct WidgetConfig {
203 pub name: String,
205 pub command: Option<String>,
207 pub builtin: Option<BuiltinWidget>,
209 pub interval_secs: u64,
211 pub timeout_secs: u64,
213}
214
215impl Default for WidgetConfig {
216 fn default() -> Self {
217 Self {
218 name: String::new(),
219 command: None,
220 builtin: None,
221 interval_secs: 5,
222 timeout_secs: 10,
223 }
224 }
225}
226
227#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
229#[serde(rename_all = "lowercase")]
230pub enum OverviewSection {
231 Cpu,
232 Gpu,
233 Memory,
234 Processes,
235 Network,
236 Storage,
237}
238
239impl OverviewSection {
240 pub const ALL: [OverviewSection; 6] = [
242 OverviewSection::Cpu,
243 OverviewSection::Gpu,
244 OverviewSection::Memory,
245 OverviewSection::Processes,
246 OverviewSection::Network,
247 OverviewSection::Storage,
248 ];
249
250 pub fn label(self) -> &'static str {
252 match self {
253 OverviewSection::Cpu => "CPU",
254 OverviewSection::Gpu => "GPU",
255 OverviewSection::Memory => "Memory",
256 OverviewSection::Processes => "Processes",
257 OverviewSection::Network => "Network",
258 OverviewSection::Storage => "Storage",
259 }
260 }
261}
262
263#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
265#[serde(default, deny_unknown_fields)]
266pub struct TuiConfig {
267 pub hidden_panels: Vec<OverviewSection>,
270 pub show_widgets: bool,
272}
273
274impl Default for TuiConfig {
275 fn default() -> Self {
276 Self {
277 hidden_panels: Vec::new(),
278 show_widgets: true,
279 }
280 }
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
285#[serde(default, deny_unknown_fields)]
286pub struct OverviewConfig {
287 pub sections: Vec<OverviewSection>,
291 pub include_local: bool,
293 pub processes: usize,
296 pub alerts_first: bool,
299}
300
301impl Default for OverviewConfig {
302 fn default() -> Self {
303 Self {
304 sections: OverviewSection::ALL.to_vec(),
305 include_local: true,
306 processes: 10,
307 alerts_first: true,
308 }
309 }
310}
311
312#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
314#[serde(deny_unknown_fields)]
315pub struct HostConfig {
316 pub name: String,
318 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub ssh: Option<String>,
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub command: Option<String>,
325 #[serde(default, skip_serializing_if = "Option::is_none")]
328 pub sections: Option<Vec<OverviewSection>>,
329 #[serde(default, skip_serializing_if = "Option::is_none")]
333 pub alerts: Option<toml::Table>,
334}
335
336impl HostConfig {
337 pub fn from_destination(destination: &str) -> Self {
339 Self {
340 name: destination.to_owned(),
341 ssh: None,
342 command: None,
343 sections: None,
344 alerts: None,
345 }
346 }
347
348 pub fn destination(&self) -> &str {
350 self.ssh.as_deref().unwrap_or(&self.name)
351 }
352}
353
354#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
356#[serde(default, deny_unknown_fields)]
357pub struct SensorConfig {
358 pub labels: BTreeMap<String, String>,
361 pub hidden: Vec<String>,
363}
364
365#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
367#[serde(default, deny_unknown_fields)]
368pub struct SmartConfig {
369 pub dir: PathBuf,
371}
372
373impl Default for SmartConfig {
374 fn default() -> Self {
375 Self {
376 dir: PathBuf::from(DEFAULT_SMART_DIR),
377 }
378 }
379}
380
381#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
383#[serde(rename_all = "lowercase")]
384pub enum NotifyLevel {
385 Off,
387 Warning,
389 #[default]
391 Critical,
392}
393
394#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
398#[serde(default, deny_unknown_fields)]
399pub struct AlertsConfig {
400 pub filesystem_warning_percent: f64,
402 pub filesystem_critical_percent: f64,
404 pub filesystem_full_warning_hours: f64,
407 pub cpu_warning_celsius: f32,
410 pub gpu_warning_celsius: f32,
412 pub drive_warning_celsius: f32,
414 pub memory_warning_percent: f64,
416 pub gpu_memory_warning_percent: f64,
420 pub memory_pressure_warning_percent: f64,
424 pub io_pressure_warning_percent: f64,
428 pub ignored_units: Vec<String>,
431}
432
433impl Default for AlertsConfig {
434 fn default() -> Self {
435 Self {
436 filesystem_warning_percent: 90.0,
437 filesystem_critical_percent: 97.0,
438 filesystem_full_warning_hours: 24.0,
439 cpu_warning_celsius: 96.0,
440 gpu_warning_celsius: 85.0,
441 drive_warning_celsius: 70.0,
442 memory_warning_percent: 90.0,
443 gpu_memory_warning_percent: 0.0,
444 memory_pressure_warning_percent: 10.0,
445 io_pressure_warning_percent: 30.0,
446 ignored_units: Vec::new(),
447 }
448 }
449}
450
451#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
453#[serde(default, deny_unknown_fields)]
454pub struct NotificationsConfig {
455 pub level: NotifyLevel,
456}
457
458#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
460#[serde(default, deny_unknown_fields)]
461pub struct UpdatesConfig {
462 pub check: bool,
464 #[serde(with = "duration_text")]
467 pub interval: Duration,
468 pub notify: bool,
471}
472
473impl Default for UpdatesConfig {
474 fn default() -> Self {
475 Self {
476 check: true,
477 interval: Duration::from_secs(3600),
478 notify: true,
479 }
480 }
481}
482
483impl UpdatesConfig {
484 pub fn check_interval(&self) -> Duration {
486 self.interval.max(Duration::from_secs(60))
487 }
488}
489
490#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
493#[serde(default, deny_unknown_fields)]
494pub struct WatchConfig {
495 pub command: String,
498 pub level: NotifyLevel,
500 pub cleared: bool,
502 #[serde(with = "duration_text")]
504 pub interval: Duration,
505 #[serde(with = "duration_text")]
507 pub offline_after: Duration,
508 #[serde(with = "duration_text")]
510 pub timeout: Duration,
511 pub processes: Vec<String>,
515}
516
517impl Default for WatchConfig {
518 fn default() -> Self {
519 Self {
520 command: String::new(),
521 level: NotifyLevel::Critical,
522 cleared: true,
523 interval: Duration::from_secs(10),
524 offline_after: Duration::from_secs(180),
525 timeout: Duration::from_secs(30),
526 processes: Vec::new(),
527 }
528 }
529}
530
531mod duration_text {
534 use std::time::Duration;
535
536 use serde::de::Error;
537 use serde::{Deserialize, Deserializer, Serializer};
538
539 const UNITS: [(char, u64); 4] = [('d', 86_400), ('h', 3600), ('m', 60), ('s', 1)];
540
541 pub fn serialize<S: Serializer>(duration: &Duration, serializer: S) -> Result<S::Ok, S::Error> {
542 serializer.serialize_str(&format(*duration))
543 }
544
545 pub fn deserialize<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Duration, D::Error> {
546 let text = String::deserialize(deserializer)?;
547 parse(&text).ok_or_else(|| {
548 D::Error::custom(format!(
549 "invalid duration {text:?}: write a number and s, m, h or d, \
550 e.g. \"30m\", \"6h\" or \"1h30m\""
551 ))
552 })
553 }
554
555 pub fn parse(text: &str) -> Option<Duration> {
557 let text: String = text.chars().filter(|c| !c.is_whitespace()).collect();
558 if text.is_empty() {
559 return None;
560 }
561 let mut total = 0u64;
562 let mut number = String::new();
563 for c in text.chars() {
564 if c.is_ascii_digit() {
565 number.push(c);
566 continue;
567 }
568 let (_, secs) = UNITS
569 .iter()
570 .find(|(unit, _)| *unit == c.to_ascii_lowercase())?;
571 let value: u64 = number.parse().ok()?;
572 total = total.checked_add(value.checked_mul(*secs)?)?;
573 number.clear();
574 }
575 number.is_empty().then(|| Duration::from_secs(total))
577 }
578
579 pub fn format(duration: Duration) -> String {
581 let mut secs = duration.as_secs();
582 if secs == 0 {
583 return "0s".to_owned();
584 }
585 let mut text = String::new();
586 for (unit, size) in UNITS {
587 if secs >= size {
588 text.push_str(&format!("{}{unit}", secs / size));
589 secs %= size;
590 }
591 }
592 text
593 }
594}
595
596#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
598#[serde(default, deny_unknown_fields)]
599pub struct LhmConfig {
600 pub url: String,
603}
604
605impl Default for LhmConfig {
606 fn default() -> Self {
607 Self {
608 url: DEFAULT_LHM_URL.to_owned(),
609 }
610 }
611}
612
613#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
615#[serde(default, deny_unknown_fields)]
616pub struct BtrfsConfig {
617 pub scrub_dir: PathBuf,
619}
620
621impl Default for BtrfsConfig {
622 fn default() -> Self {
623 Self {
624 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
625 }
626 }
627}
628
629impl Config {
630 pub fn default_path() -> Option<PathBuf> {
632 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
633 }
634
635 pub fn load(path: &Path) -> Result<Self, ConfigError> {
637 let text = match fs::read_to_string(path) {
638 Ok(text) => text,
639 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
640 Err(source) => {
641 return Err(ConfigError::Io {
642 path: path.to_owned(),
643 source,
644 });
645 }
646 };
647 let config: Self = toml::from_str(&text).map_err(|source| ConfigError::Parse {
648 path: path.to_owned(),
649 source,
650 })?;
651 for host in &config.hosts {
653 config
654 .alerts_for(host)
655 .map_err(|source| ConfigError::HostAlerts {
656 path: path.to_owned(),
657 host: host.name.clone(),
658 source: Box::new(source),
659 })?;
660 }
661 Ok(config)
662 }
663
664 pub fn alerts_for(&self, host: &HostConfig) -> Result<AlertsConfig, toml::de::Error> {
667 let Some(overrides) = &host.alerts else {
668 return Ok(self.alerts.clone());
669 };
670 let mut merged = toml::Table::try_from(&self.alerts).unwrap_or_default();
672 merged.extend(overrides.clone());
673 merged.try_into()
674 }
675
676 pub fn load_default() -> Result<Self, ConfigError> {
679 match Self::default_path() {
680 Some(path) => Self::load(&path),
681 None => Ok(Self::default()),
682 }
683 }
684
685 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
690 let text = toml::to_string_pretty(self)?;
691 let io_err = |source| ConfigError::Io {
692 path: path.to_owned(),
693 source,
694 };
695 if let Some(parent) = path.parent() {
696 fs::create_dir_all(parent).map_err(io_err)?;
697 }
698 let tmp = path.with_extension("toml.tmp");
699 fs::write(&tmp, text).map_err(io_err)?;
700 fs::rename(&tmp, path).map_err(io_err)
701 }
702
703 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
710 edit_file(path, |document| {
711 if hosts.is_empty() {
712 document.remove("hosts");
713 return Ok(());
714 }
715 let mut tables = toml_edit::ArrayOfTables::new();
716 for host in hosts {
717 let mut table = toml_edit::Table::new();
718 table["name"] = toml_edit::value(host.name.as_str());
719 if let Some(ssh) = &host.ssh {
720 table["ssh"] = toml_edit::value(ssh.as_str());
721 }
722 if let Some(command) = &host.command {
723 table["command"] = toml_edit::value(command.as_str());
724 }
725 if let Some(sections) = &host.sections {
726 let mut array = toml_edit::Array::new();
727 for section in sections {
728 let name = toml::Value::try_from(section)?;
730 if let Some(name) = name.as_str() {
731 array.push(name);
732 }
733 }
734 table["sections"] = toml_edit::value(array);
735 }
736 if let Some(alerts) = &host.alerts {
737 let mut sub = toml_edit::Table::new();
739 for (key, value) in alerts {
740 if let Ok(value) = value.to_string().parse::<toml_edit::Value>() {
741 sub[key.as_str()] = toml_edit::value(value);
742 }
743 }
744 table["alerts"] = toml_edit::Item::Table(sub);
745 }
746 tables.push(table);
747 }
748 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
749 Ok(())
750 })
751 }
752
753 pub fn save_tui(path: &Path, tui: &TuiConfig) -> Result<(), ConfigError> {
762 edit_file(path, |document| {
763 if !document.contains_key("tui") {
764 if *tui == TuiConfig::default() {
765 return Ok(());
766 }
767 document["tui"] = toml_edit::table();
768 }
769 let Some(table) = document["tui"].as_table_like_mut() else {
770 return Ok(());
772 };
773 if tui.hidden_panels.is_empty() {
774 table.remove("hidden_panels");
775 } else {
776 let names: toml_edit::Array = tui
777 .hidden_panels
778 .iter()
779 .filter_map(|section| {
780 serde_json::to_value(section)
781 .ok()
782 .and_then(|v| v.as_str().map(str::to_owned))
783 })
784 .collect();
785 table.insert("hidden_panels", toml_edit::value(names));
786 }
787 if tui.show_widgets {
788 table.remove("show_widgets");
789 } else {
790 table.insert("show_widgets", toml_edit::value(false));
791 }
792 Ok(())
793 })
794 }
795
796 pub fn save_sensors(path: &Path, sensors: &SensorConfig) -> Result<(), ConfigError> {
797 edit_file(path, |document| {
798 if !document.contains_key("sensors") {
799 if sensors.labels.is_empty() && sensors.hidden.is_empty() {
800 return Ok(());
801 }
802 document["sensors"] = toml_edit::table();
803 }
804 let Some(table) = document["sensors"].as_table_like_mut() else {
805 return Ok(());
807 };
808 if sensors.hidden.is_empty() {
809 table.remove("hidden");
810 } else {
811 let hidden: toml_edit::Array = sensors.hidden.iter().map(String::as_str).collect();
812 table.insert("hidden", toml_edit::value(hidden));
813 }
814 let inline = table.get("labels").is_some_and(|l| l.is_inline_table());
815 if sensors.labels.is_empty() {
816 table.remove("labels");
817 } else if inline {
818 let labels: toml_edit::InlineTable = sensors
819 .labels
820 .iter()
821 .map(|(k, v)| (k.as_str(), toml_edit::Value::from(v.as_str())))
822 .collect();
823 table.insert("labels", toml_edit::value(labels));
824 } else {
825 let mut labels = toml_edit::Table::new();
826 for (key, name) in &sensors.labels {
827 labels[key.as_str()] = toml_edit::value(name.as_str());
828 }
829 table.insert("labels", toml_edit::Item::Table(labels));
830 }
831 if table.is_empty() {
832 document.remove("sensors");
833 }
834 Ok(())
835 })
836 }
837
838 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
840 self.label_for(&fan.key).unwrap_or(&fan.label)
841 }
842
843 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
845 self.label_for(&temperature.key)
846 .unwrap_or(&temperature.label)
847 }
848
849 pub fn label_for(&self, key: &str) -> Option<&str> {
851 self.sensors.labels.get(key).map(String::as_str)
852 }
853
854 pub fn set_label(&mut self, key: &str, name: &str) {
856 let name = name.trim();
857 if name.is_empty() {
858 self.sensors.labels.remove(key);
859 } else {
860 self.sensors.labels.insert(key.to_owned(), name.to_owned());
861 }
862 }
863
864 pub fn is_hidden(&self, key: &str) -> bool {
866 self.sensors.hidden.iter().any(|k| k == key)
867 }
868}
869
870fn edit_file(
875 path: &Path,
876 edit: impl FnOnce(&mut toml_edit::DocumentMut) -> Result<(), ConfigError>,
877) -> Result<(), ConfigError> {
878 let io_err = |source| ConfigError::Io {
879 path: path.to_owned(),
880 source,
881 };
882 let text = match fs::read_to_string(path) {
883 Ok(text) => text,
884 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
885 Err(err) => return Err(io_err(err)),
886 };
887 let mut document: toml_edit::DocumentMut =
888 text.parse().map_err(|source| ConfigError::Edit {
889 path: path.to_owned(),
890 source,
891 })?;
892 edit(&mut document)?;
893 let edited = document.to_string();
894 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
896 path: path.to_owned(),
897 source,
898 })?;
899 if let Some(parent) = path.parent() {
900 fs::create_dir_all(parent).map_err(io_err)?;
901 }
902 let tmp = path.with_extension("toml.tmp");
903 fs::write(&tmp, edited).map_err(io_err)?;
904 fs::rename(&tmp, path).map_err(io_err)
905}
906
907const WATCH_INTERVAL: Duration = Duration::from_secs(1);
909
910pub struct ConfigWatcher {
913 path: PathBuf,
914 stamp: Option<(Option<SystemTime>, u64)>,
916 last_check: Instant,
917 interval: Duration,
918}
919
920impl ConfigWatcher {
921 pub fn new(path: PathBuf) -> Self {
923 Self {
924 stamp: file_stamp(&path),
925 path,
926 last_check: Instant::now(),
927 interval: WATCH_INTERVAL,
928 }
929 }
930
931 pub fn path(&self) -> &Path {
933 &self.path
934 }
935
936 pub fn mark_seen(&mut self) {
939 self.stamp = file_stamp(&self.path);
940 }
941
942 pub fn poll(&mut self) -> Option<Result<Config, ConfigError>> {
947 if self.last_check.elapsed() < self.interval {
948 return None;
949 }
950 self.last_check = Instant::now();
951 let stamp = file_stamp(&self.path);
952 if stamp == self.stamp {
953 return None;
954 }
955 self.stamp = stamp;
956 stamp?;
957 Some(Config::load(&self.path))
958 }
959}
960
961fn file_stamp(path: &Path) -> Option<(Option<SystemTime>, u64)> {
963 let metadata = fs::metadata(path).ok()?;
964 Some((metadata.modified().ok(), metadata.len()))
965}
966
967#[cfg(test)]
968mod tests {
969 use super::*;
970
971 #[test]
972 fn hidden_panels_are_saved_and_loaded() {
973 let tmp = tempfile::tempdir().unwrap();
974 let path = tmp.path().join("config.toml");
975 fs::write(&path, "# mine\n[alerts]\ncpu_warning_celsius = 90\n").unwrap();
976 let tui = TuiConfig {
977 hidden_panels: vec![OverviewSection::Gpu, OverviewSection::Network],
978 show_widgets: false,
979 };
980 Config::save_tui(&path, &tui).unwrap();
981 let text = fs::read_to_string(&path).unwrap();
982 assert!(text.starts_with("# mine"), "{text}");
983 let config = Config::load(&path).unwrap();
984 assert_eq!(
985 config.tui.hidden_panels,
986 vec![OverviewSection::Gpu, OverviewSection::Network]
987 );
988 assert!(!config.tui.show_widgets);
989 assert_eq!(config.alerts.cpu_warning_celsius, 90.0);
990 Config::save_tui(&path, &TuiConfig::default()).unwrap();
991 let config = Config::load(&path).unwrap();
992 assert_eq!(config.tui, TuiConfig::default());
993 assert!(!fs::read_to_string(&path).unwrap().contains("show_widgets"));
994 }
995
996 #[test]
997 fn watch_settings() {
998 let config: Config = toml::from_str(
999 "[watch]\ncommand = \"notify-send x\"\nlevel = \"warning\"\ninterval = \"30s\"\n",
1000 )
1001 .unwrap();
1002 assert_eq!(config.watch.command, "notify-send x");
1003 assert_eq!(config.watch.level, NotifyLevel::Warning);
1004 assert!(config.watch.cleared);
1005 assert_eq!(config.watch.interval, Duration::from_secs(30));
1006 assert_eq!(config.watch.offline_after, Duration::from_secs(180));
1007 assert!(toml::from_str::<Config>("[watch]\ncomand = \"x\"\n").is_err());
1008 }
1009
1010 #[test]
1011 fn update_checks_can_be_tuned_or_turned_off() {
1012 let config: Config =
1013 toml::from_str("[updates]\ninterval = \"6h\"\nnotify = false\n").unwrap();
1014 assert!(config.updates.check);
1015 assert_eq!(
1016 config.updates.check_interval(),
1017 Duration::from_secs(6 * 3600)
1018 );
1019 assert!(!config.updates.notify);
1020 let config: Config =
1021 toml::from_str("[updates]\ncheck = false\ninterval = \"10s\"\n").unwrap();
1022 assert!(!config.updates.check);
1023 assert_eq!(
1024 config.updates.check_interval(),
1025 Duration::from_secs(60),
1026 "at least a minute"
1027 );
1028 let error = toml::from_str::<Config>("[updates]\ninterval = \"6 hours\"\n").unwrap_err();
1029 assert!(error.to_string().contains("invalid duration"), "{error}");
1030 }
1031
1032 #[test]
1033 fn durations_read_and_write_as_text() {
1034 let minutes = |m: u64| Some(Duration::from_secs(m * 60));
1035 assert_eq!(duration_text::parse("30m"), minutes(30));
1036 assert_eq!(duration_text::parse("1h30m"), minutes(90));
1037 assert_eq!(duration_text::parse("1h 30m"), minutes(90));
1038 assert_eq!(duration_text::parse("1d"), minutes(24 * 60));
1039 assert_eq!(duration_text::parse("2H"), minutes(120));
1040 for bad in ["", "6", "h", "1.5h", "1w", "-1h", "6 hours"] {
1041 assert_eq!(duration_text::parse(bad), None, "{bad:?}");
1042 }
1043 assert_eq!(duration_text::format(Duration::from_secs(5400)), "1h30m");
1044 assert_eq!(duration_text::format(Duration::from_secs(86_400)), "1d");
1045 let text = toml::to_string(&Config::default()).unwrap();
1047 assert!(text.contains("interval = \"1h\""), "{text}");
1048 assert_eq!(toml::from_str::<Config>(&text).unwrap(), Config::default());
1049 }
1050
1051 #[test]
1052 fn watcher_reports_edits_once() {
1053 let tmp = tempfile::tempdir().unwrap();
1054 let path = tmp.path().join("config.toml");
1055 fs::write(&path, "[alerts]\ncpu_warning_celsius = 90\n").unwrap();
1056 let mut watcher = ConfigWatcher::new(path.clone());
1057 watcher.interval = Duration::ZERO;
1058 assert!(
1059 watcher.poll().is_none(),
1060 "the initial file counts as loaded"
1061 );
1062
1063 fs::write(&path, "[alerts]\ncpu_warning_celsius = 100\n").unwrap();
1065 let config = watcher.poll().unwrap().unwrap();
1066 assert_eq!(config.alerts.cpu_warning_celsius, 100.0);
1067 assert!(watcher.poll().is_none(), "reported once");
1068
1069 fs::write(&path, "[alerts\n").unwrap();
1070 assert!(
1071 watcher.poll().unwrap().is_err(),
1072 "a broken file is an error"
1073 );
1074 fs::remove_file(&path).unwrap();
1075 assert!(
1076 watcher.poll().is_none(),
1077 "removing the file changes nothing"
1078 );
1079 }
1080
1081 #[test]
1082 fn missing_file_gives_defaults() {
1083 let tmp = tempfile::tempdir().unwrap();
1084 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
1085 assert_eq!(config, Config::default());
1086 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
1087 }
1088
1089 #[test]
1090 fn parses_labels_hidden_and_smart_dir() {
1091 let tmp = tempfile::tempdir().unwrap();
1092 let path = tmp.path().join("config.toml");
1093 fs::write(
1094 &path,
1095 r#"
1096[sensors]
1097labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
1098hidden = ["nct6686:nct6683.2592/temp4"]
1099
1100[smart]
1101dir = "/tmp/smart"
1102
1103[lhm]
1104url = ""
1105
1106[notifications]
1107level = "warning"
1108
1109[alerts]
1110drive_warning_celsius = 65
1111cpu_warning_celsius = 0
1112gpu_memory_warning_percent = 98
1113"#,
1114 )
1115 .unwrap();
1116 let config = Config::load(&path).unwrap();
1117 let fan = Fan {
1118 key: "nct6686:nct6683.2592/fan1".to_owned(),
1119 label: "fan1".to_owned(),
1120 rpm: 900,
1121 };
1122 let other = Fan {
1123 key: "nct6686:nct6683.2592/fan2".to_owned(),
1124 label: "fan2".to_owned(),
1125 rpm: 0,
1126 };
1127 assert_eq!(config.fan_label(&fan), "CPU fan");
1128 assert_eq!(config.fan_label(&other), "fan2");
1130 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
1131 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
1132 assert_eq!(config.lhm.url, "");
1133 assert_eq!(config.notifications.level, NotifyLevel::Warning);
1134 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
1135 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
1136 assert_eq!(config.alerts.drive_warning_celsius, 65.0);
1137 assert_eq!(config.alerts.cpu_warning_celsius, 0.0);
1138 assert_eq!(config.alerts.gpu_warning_celsius, 85.0);
1140 assert_eq!(config.alerts.memory_warning_percent, 90.0);
1141 assert_eq!(config.alerts.gpu_memory_warning_percent, 98.0);
1142 }
1143
1144 #[test]
1145 fn parses_hosts_and_overview() {
1146 let config: Config = toml::from_str(
1147 r#"
1148[overview]
1149sections = ["storage", "cpu"]
1150include_local = false
1151
1152[[hosts]]
1153name = "zefat"
1154
1155[[hosts]]
1156name = "nas"
1157ssh = "admin@nas.local"
1158sections = ["storage"]
1159
1160[[widgets]]
1161name = "Clock"
1162builtin = "clock"
1163
1164[[widgets]]
1165name = "Rates"
1166command = "rates.sh"
1167interval_secs = 60
1168"#,
1169 )
1170 .unwrap();
1171 assert_eq!(
1172 config.overview.sections,
1173 vec![OverviewSection::Storage, OverviewSection::Cpu]
1174 );
1175 assert!(!config.overview.include_local);
1176 assert_eq!(config.overview.processes, 10, "default kept");
1177 assert_eq!(config.hosts.len(), 2);
1178 assert_eq!(config.hosts[0].destination(), "zefat");
1179 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
1180 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
1181 assert_eq!(config.widgets[1].interval_secs, 60);
1182 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
1183 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
1184 assert_eq!(
1185 config.hosts[1].sections,
1186 Some(vec![OverviewSection::Storage])
1187 );
1188 let defaults = Config::default();
1190 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
1191 assert!(defaults.overview.include_local);
1192 }
1193
1194 #[test]
1195 fn save_hosts_replaces_only_the_hosts() {
1196 let tmp = tempfile::tempdir().unwrap();
1197 let path = tmp.path().join("config.toml");
1198 fs::write(
1199 &path,
1200 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
1201 )
1202 .unwrap();
1203 let hosts = vec![
1204 HostConfig::from_destination("zefat"),
1205 HostConfig {
1206 name: "nas".to_owned(),
1207 ssh: Some("admin@nas.local".to_owned()),
1208 command: Some("/opt/rigtop --agent".to_owned()),
1209 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
1210 alerts: Some(
1211 toml::from_str("cpu_warning_celsius = 90.0\nignored_units = [\"a.service\"]")
1212 .unwrap(),
1213 ),
1214 },
1215 ];
1216 Config::save_hosts(&path, &hosts).unwrap();
1217
1218 let text = fs::read_to_string(&path).unwrap();
1219 assert!(
1220 text.contains("# my settings") && text.contains("# keep me"),
1221 "{text}"
1222 );
1223 assert!(!text.contains("\"old\""), "{text}");
1224 let config = Config::load(&path).unwrap();
1225 assert_eq!(config.hosts, hosts);
1226 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
1227
1228 Config::save_hosts(&path, &[]).unwrap();
1230 assert!(Config::load(&path).unwrap().hosts.is_empty());
1231 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
1232 }
1233
1234 #[test]
1235 fn host_alerts_replace_the_keys_they_give() {
1236 let tmp = tempfile::tempdir().unwrap();
1237 let path = tmp.path().join("config.toml");
1238 fs::write(
1239 &path,
1240 "[alerts]\nmemory_warning_percent = 80\n\n\
1241 [[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsius = 100\n\n\
1242 [[hosts]]\nname = \"nas\"\n",
1243 )
1244 .unwrap();
1245 let config = Config::load(&path).unwrap();
1246 let laptop = config.alerts_for(&config.hosts[0]).unwrap();
1247 assert_eq!(laptop.cpu_warning_celsius, 100.0);
1248 assert_eq!(laptop.memory_warning_percent, 80.0);
1250 assert_eq!(laptop.gpu_warning_celsius, 85.0);
1251 assert_eq!(config.alerts_for(&config.hosts[1]).unwrap(), config.alerts);
1252
1253 fs::write(
1255 &path,
1256 "[[hosts]]\nname = \"laptop\"\n[hosts.alerts]\ncpu_warning_celsus = 100\n",
1257 )
1258 .unwrap();
1259 let err = Config::load(&path).unwrap_err().to_string();
1260 assert!(
1261 err.contains("laptop") && err.contains("cpu_warning_celsus"),
1262 "{err}"
1263 );
1264 }
1265
1266 #[test]
1267 fn save_sensors_replaces_only_names_and_hidden_sensors() {
1268 let tmp = tempfile::tempdir().unwrap();
1269 let path = tmp.path().join("config.toml");
1270 fs::write(
1271 &path,
1272 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # inline\n\n[[hosts]]\nname = \"zefat\"\n",
1273 )
1274 .unwrap();
1275 let mut sensors = SensorConfig::default();
1276 sensors
1277 .labels
1278 .insert("a/fan1".to_owned(), "CPU fan".to_owned());
1279 sensors
1280 .labels
1281 .insert("b:c.1/temp2".to_owned(), "VRM".to_owned());
1282 sensors.hidden.push("b:c.1/temp4".to_owned());
1283 Config::save_sensors(&path, &sensors).unwrap();
1284
1285 let text = fs::read_to_string(&path).unwrap();
1286 assert!(text.contains("# my settings"), "{text}");
1287 assert!(text.contains("labels = {"), "{text}");
1289 let config = Config::load(&path).unwrap();
1290 assert_eq!(config.sensors, sensors);
1291 assert_eq!(config.hosts[0].name, "zefat");
1292
1293 Config::save_sensors(&path, &SensorConfig::default()).unwrap();
1295 let text = fs::read_to_string(&path).unwrap();
1296 assert!(!text.contains("[sensors]"), "{text}");
1297 assert_eq!(Config::load(&path).unwrap().hosts.len(), 1);
1298
1299 let fresh = tmp.path().join("new.toml");
1301 Config::save_sensors(&fresh, &sensors).unwrap();
1302 let text = fs::read_to_string(&fresh).unwrap();
1303 assert!(text.contains("[sensors.labels]"), "{text}");
1304 assert_eq!(Config::load(&fresh).unwrap().sensors, sensors);
1305 }
1306
1307 #[test]
1308 fn save_hosts_creates_a_missing_file() {
1309 let tmp = tempfile::tempdir().unwrap();
1310 let path = tmp.path().join("new/rigtop/config.toml");
1311 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
1312 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
1313 }
1314
1315 #[test]
1316 fn save_hosts_refuses_to_touch_a_broken_file() {
1317 let tmp = tempfile::tempdir().unwrap();
1318 let path = tmp.path().join("config.toml");
1319 fs::write(&path, "[sensors\n").unwrap();
1320 let err = Config::save_hosts(&path, &[]).unwrap_err();
1321 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
1322 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
1323 }
1324
1325 #[test]
1326 fn unknown_fields_are_rejected() {
1327 let tmp = tempfile::tempdir().unwrap();
1328 let path = tmp.path().join("config.toml");
1329 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
1331 assert!(matches!(
1332 Config::load(&path),
1333 Err(ConfigError::Parse { .. })
1334 ));
1335 }
1336
1337 #[test]
1338 fn save_and_reload_round_trip() {
1339 let tmp = tempfile::tempdir().unwrap();
1340 let path = tmp.path().join("nested/rigtop/config.toml");
1341 let mut config = Config::default();
1342 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
1343 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
1344 config.set_label("nct6686:nct6683.2592/fan1", "");
1345 config.save(&path).unwrap();
1346
1347 let back = Config::load(&path).unwrap();
1348 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
1349 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
1351 assert!(!path.with_extension("toml.tmp").exists());
1352 }
1353}