1use std::collections::BTreeMap;
60use std::fs;
61use std::io;
62use std::path::{Path, PathBuf};
63
64use serde::{Deserialize, Serialize};
65
66use crate::snapshot::{Fan, Temperature};
67
68pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
70pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
72pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
74
75#[derive(Debug, thiserror::Error)]
77pub enum ConfigError {
78 #[error("cannot access {path}: {source}")]
80 Io {
81 path: PathBuf,
83 source: io::Error,
85 },
86 #[error("invalid config {path}: {source}")]
88 Parse {
89 path: PathBuf,
91 source: toml::de::Error,
93 },
94 #[error("cannot serialize config: {0}")]
96 Serialize(#[from] toml::ser::Error),
97 #[error("cannot edit {path}: {source}")]
99 Edit {
100 path: PathBuf,
102 source: toml_edit::TomlError,
104 },
105}
106
107#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109#[serde(default, deny_unknown_fields)]
110pub struct Config {
111 pub sensors: SensorConfig,
113 pub smart: SmartConfig,
115 pub btrfs: BtrfsConfig,
117 pub lhm: LhmConfig,
120 pub overview: OverviewConfig,
122 pub notifications: NotificationsConfig,
124 pub hosts: Vec<HostConfig>,
126 pub widgets: Vec<WidgetConfig>,
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
132#[serde(rename_all = "lowercase")]
133pub enum BuiltinWidget {
134 Clock,
136}
137
138#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
141#[serde(default, deny_unknown_fields)]
142pub struct WidgetConfig {
143 pub name: String,
145 pub command: Option<String>,
147 pub builtin: Option<BuiltinWidget>,
149 pub interval_secs: u64,
151 pub timeout_secs: u64,
153}
154
155impl Default for WidgetConfig {
156 fn default() -> Self {
157 Self {
158 name: String::new(),
159 command: None,
160 builtin: None,
161 interval_secs: 5,
162 timeout_secs: 10,
163 }
164 }
165}
166
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
169#[serde(rename_all = "lowercase")]
170pub enum OverviewSection {
171 Cpu,
172 Gpu,
173 Memory,
174 Processes,
175 Network,
176 Storage,
177}
178
179impl OverviewSection {
180 pub const ALL: [OverviewSection; 6] = [
182 OverviewSection::Cpu,
183 OverviewSection::Gpu,
184 OverviewSection::Memory,
185 OverviewSection::Processes,
186 OverviewSection::Network,
187 OverviewSection::Storage,
188 ];
189
190 pub fn label(self) -> &'static str {
192 match self {
193 OverviewSection::Cpu => "CPU",
194 OverviewSection::Gpu => "GPU",
195 OverviewSection::Memory => "Memory",
196 OverviewSection::Processes => "Processes",
197 OverviewSection::Network => "Network",
198 OverviewSection::Storage => "Storage",
199 }
200 }
201}
202
203#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
205#[serde(default, deny_unknown_fields)]
206pub struct OverviewConfig {
207 pub sections: Vec<OverviewSection>,
211 pub include_local: bool,
213 pub processes: usize,
216}
217
218impl Default for OverviewConfig {
219 fn default() -> Self {
220 Self {
221 sections: OverviewSection::ALL.to_vec(),
222 include_local: true,
223 processes: 10,
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
230#[serde(deny_unknown_fields)]
231pub struct HostConfig {
232 pub name: String,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub ssh: Option<String>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
240 pub command: Option<String>,
241 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub sections: Option<Vec<OverviewSection>>,
245}
246
247impl HostConfig {
248 pub fn from_destination(destination: &str) -> Self {
250 Self {
251 name: destination.to_owned(),
252 ssh: None,
253 command: None,
254 sections: None,
255 }
256 }
257
258 pub fn destination(&self) -> &str {
260 self.ssh.as_deref().unwrap_or(&self.name)
261 }
262}
263
264#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
266#[serde(default, deny_unknown_fields)]
267pub struct SensorConfig {
268 pub labels: BTreeMap<String, String>,
271 pub hidden: Vec<String>,
273}
274
275#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
277#[serde(default, deny_unknown_fields)]
278pub struct SmartConfig {
279 pub dir: PathBuf,
281}
282
283impl Default for SmartConfig {
284 fn default() -> Self {
285 Self {
286 dir: PathBuf::from(DEFAULT_SMART_DIR),
287 }
288 }
289}
290
291#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
293#[serde(rename_all = "lowercase")]
294pub enum NotifyLevel {
295 Off,
297 Warning,
299 #[default]
301 Critical,
302}
303
304#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
306#[serde(default, deny_unknown_fields)]
307pub struct NotificationsConfig {
308 pub level: NotifyLevel,
309}
310
311#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
313#[serde(default, deny_unknown_fields)]
314pub struct LhmConfig {
315 pub url: String,
318}
319
320impl Default for LhmConfig {
321 fn default() -> Self {
322 Self {
323 url: DEFAULT_LHM_URL.to_owned(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330#[serde(default, deny_unknown_fields)]
331pub struct BtrfsConfig {
332 pub scrub_dir: PathBuf,
334}
335
336impl Default for BtrfsConfig {
337 fn default() -> Self {
338 Self {
339 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
340 }
341 }
342}
343
344impl Config {
345 pub fn default_path() -> Option<PathBuf> {
347 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
348 }
349
350 pub fn load(path: &Path) -> Result<Self, ConfigError> {
352 let text = match fs::read_to_string(path) {
353 Ok(text) => text,
354 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
355 Err(source) => {
356 return Err(ConfigError::Io {
357 path: path.to_owned(),
358 source,
359 });
360 }
361 };
362 toml::from_str(&text).map_err(|source| ConfigError::Parse {
363 path: path.to_owned(),
364 source,
365 })
366 }
367
368 pub fn load_default() -> Result<Self, ConfigError> {
371 match Self::default_path() {
372 Some(path) => Self::load(&path),
373 None => Ok(Self::default()),
374 }
375 }
376
377 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
382 let text = toml::to_string_pretty(self)?;
383 let io_err = |source| ConfigError::Io {
384 path: path.to_owned(),
385 source,
386 };
387 if let Some(parent) = path.parent() {
388 fs::create_dir_all(parent).map_err(io_err)?;
389 }
390 let tmp = path.with_extension("toml.tmp");
391 fs::write(&tmp, text).map_err(io_err)?;
392 fs::rename(&tmp, path).map_err(io_err)
393 }
394
395 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
402 let io_err = |source| ConfigError::Io {
403 path: path.to_owned(),
404 source,
405 };
406 let text = match fs::read_to_string(path) {
407 Ok(text) => text,
408 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
409 Err(err) => return Err(io_err(err)),
410 };
411 let mut document: toml_edit::DocumentMut =
412 text.parse().map_err(|source| ConfigError::Edit {
413 path: path.to_owned(),
414 source,
415 })?;
416 if hosts.is_empty() {
417 document.remove("hosts");
418 } else {
419 let mut tables = toml_edit::ArrayOfTables::new();
420 for host in hosts {
421 let mut table = toml_edit::Table::new();
422 table["name"] = toml_edit::value(host.name.as_str());
423 if let Some(ssh) = &host.ssh {
424 table["ssh"] = toml_edit::value(ssh.as_str());
425 }
426 if let Some(command) = &host.command {
427 table["command"] = toml_edit::value(command.as_str());
428 }
429 if let Some(sections) = &host.sections {
430 let mut array = toml_edit::Array::new();
431 for section in sections {
432 let name = toml::Value::try_from(section)?;
434 if let Some(name) = name.as_str() {
435 array.push(name);
436 }
437 }
438 table["sections"] = toml_edit::value(array);
439 }
440 tables.push(table);
441 }
442 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
443 }
444 let edited = document.to_string();
445 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
447 path: path.to_owned(),
448 source,
449 })?;
450 if let Some(parent) = path.parent() {
451 fs::create_dir_all(parent).map_err(io_err)?;
452 }
453 let tmp = path.with_extension("toml.tmp");
454 fs::write(&tmp, edited).map_err(io_err)?;
455 fs::rename(&tmp, path).map_err(io_err)
456 }
457
458 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
460 self.label_for(&fan.key).unwrap_or(&fan.label)
461 }
462
463 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
465 self.label_for(&temperature.key)
466 .unwrap_or(&temperature.label)
467 }
468
469 pub fn label_for(&self, key: &str) -> Option<&str> {
471 self.sensors.labels.get(key).map(String::as_str)
472 }
473
474 pub fn set_label(&mut self, key: &str, name: &str) {
476 let name = name.trim();
477 if name.is_empty() {
478 self.sensors.labels.remove(key);
479 } else {
480 self.sensors.labels.insert(key.to_owned(), name.to_owned());
481 }
482 }
483
484 pub fn is_hidden(&self, key: &str) -> bool {
486 self.sensors.hidden.iter().any(|k| k == key)
487 }
488}
489
490#[cfg(test)]
491mod tests {
492 use super::*;
493
494 #[test]
495 fn missing_file_gives_defaults() {
496 let tmp = tempfile::tempdir().unwrap();
497 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
498 assert_eq!(config, Config::default());
499 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
500 }
501
502 #[test]
503 fn parses_labels_hidden_and_smart_dir() {
504 let tmp = tempfile::tempdir().unwrap();
505 let path = tmp.path().join("config.toml");
506 fs::write(
507 &path,
508 r#"
509[sensors]
510labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
511hidden = ["nct6686:nct6683.2592/temp4"]
512
513[smart]
514dir = "/tmp/smart"
515
516[lhm]
517url = ""
518
519[notifications]
520level = "warning"
521"#,
522 )
523 .unwrap();
524 let config = Config::load(&path).unwrap();
525 let fan = Fan {
526 key: "nct6686:nct6683.2592/fan1".to_owned(),
527 label: "fan1".to_owned(),
528 rpm: 900,
529 };
530 let other = Fan {
531 key: "nct6686:nct6683.2592/fan2".to_owned(),
532 label: "fan2".to_owned(),
533 rpm: 0,
534 };
535 assert_eq!(config.fan_label(&fan), "CPU fan");
536 assert_eq!(config.fan_label(&other), "fan2");
538 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
539 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
540 assert_eq!(config.lhm.url, "");
541 assert_eq!(config.notifications.level, NotifyLevel::Warning);
542 assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
543 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
544 }
545
546 #[test]
547 fn parses_hosts_and_overview() {
548 let config: Config = toml::from_str(
549 r#"
550[overview]
551sections = ["storage", "cpu"]
552include_local = false
553
554[[hosts]]
555name = "zefat"
556
557[[hosts]]
558name = "nas"
559ssh = "admin@nas.local"
560sections = ["storage"]
561
562[[widgets]]
563name = "Clock"
564builtin = "clock"
565
566[[widgets]]
567name = "Rates"
568command = "rates.sh"
569interval_secs = 60
570"#,
571 )
572 .unwrap();
573 assert_eq!(
574 config.overview.sections,
575 vec![OverviewSection::Storage, OverviewSection::Cpu]
576 );
577 assert!(!config.overview.include_local);
578 assert_eq!(config.overview.processes, 10, "default kept");
579 assert_eq!(config.hosts.len(), 2);
580 assert_eq!(config.hosts[0].destination(), "zefat");
581 assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
582 assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
583 assert_eq!(config.widgets[1].interval_secs, 60);
584 assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
585 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
586 assert_eq!(
587 config.hosts[1].sections,
588 Some(vec![OverviewSection::Storage])
589 );
590 let defaults = Config::default();
592 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
593 assert!(defaults.overview.include_local);
594 }
595
596 #[test]
597 fn save_hosts_replaces_only_the_hosts() {
598 let tmp = tempfile::tempdir().unwrap();
599 let path = tmp.path().join("config.toml");
600 fs::write(
601 &path,
602 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
603 )
604 .unwrap();
605 let hosts = vec![
606 HostConfig::from_destination("zefat"),
607 HostConfig {
608 name: "nas".to_owned(),
609 ssh: Some("admin@nas.local".to_owned()),
610 command: Some("/opt/rigtop --agent".to_owned()),
611 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
612 },
613 ];
614 Config::save_hosts(&path, &hosts).unwrap();
615
616 let text = fs::read_to_string(&path).unwrap();
617 assert!(
618 text.contains("# my settings") && text.contains("# keep me"),
619 "{text}"
620 );
621 assert!(!text.contains("\"old\""), "{text}");
622 let config = Config::load(&path).unwrap();
623 assert_eq!(config.hosts, hosts);
624 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
625
626 Config::save_hosts(&path, &[]).unwrap();
628 assert!(Config::load(&path).unwrap().hosts.is_empty());
629 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
630 }
631
632 #[test]
633 fn save_hosts_creates_a_missing_file() {
634 let tmp = tempfile::tempdir().unwrap();
635 let path = tmp.path().join("new/rigtop/config.toml");
636 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
637 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
638 }
639
640 #[test]
641 fn save_hosts_refuses_to_touch_a_broken_file() {
642 let tmp = tempfile::tempdir().unwrap();
643 let path = tmp.path().join("config.toml");
644 fs::write(&path, "[sensors\n").unwrap();
645 let err = Config::save_hosts(&path, &[]).unwrap_err();
646 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
647 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
648 }
649
650 #[test]
651 fn unknown_fields_are_rejected() {
652 let tmp = tempfile::tempdir().unwrap();
653 let path = tmp.path().join("config.toml");
654 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
656 assert!(matches!(
657 Config::load(&path),
658 Err(ConfigError::Parse { .. })
659 ));
660 }
661
662 #[test]
663 fn save_and_reload_round_trip() {
664 let tmp = tempfile::tempdir().unwrap();
665 let path = tmp.path().join("nested/rigtop/config.toml");
666 let mut config = Config::default();
667 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
668 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
669 config.set_label("nct6686:nct6683.2592/fan1", "");
670 config.save(&path).unwrap();
671
672 let back = Config::load(&path).unwrap();
673 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
674 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
676 assert!(!path.with_extension("toml.tmp").exists());
677 }
678}