1use std::collections::BTreeMap;
46use std::fs;
47use std::io;
48use std::path::{Path, PathBuf};
49
50use serde::{Deserialize, Serialize};
51
52use crate::snapshot::{Fan, Temperature};
53
54pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
56pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
58pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
60
61#[derive(Debug, thiserror::Error)]
63pub enum ConfigError {
64 #[error("cannot access {path}: {source}")]
66 Io {
67 path: PathBuf,
69 source: io::Error,
71 },
72 #[error("invalid config {path}: {source}")]
74 Parse {
75 path: PathBuf,
77 source: toml::de::Error,
79 },
80 #[error("cannot serialize config: {0}")]
82 Serialize(#[from] toml::ser::Error),
83 #[error("cannot edit {path}: {source}")]
85 Edit {
86 path: PathBuf,
88 source: toml_edit::TomlError,
90 },
91}
92
93#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
95#[serde(default, deny_unknown_fields)]
96pub struct Config {
97 pub sensors: SensorConfig,
99 pub smart: SmartConfig,
101 pub btrfs: BtrfsConfig,
103 pub lhm: LhmConfig,
106 pub overview: OverviewConfig,
108 pub hosts: Vec<HostConfig>,
110}
111
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
114#[serde(rename_all = "lowercase")]
115pub enum OverviewSection {
116 Cpu,
117 Gpu,
118 Memory,
119 Processes,
120 Network,
121 Storage,
122}
123
124impl OverviewSection {
125 pub const ALL: [OverviewSection; 6] = [
127 OverviewSection::Cpu,
128 OverviewSection::Gpu,
129 OverviewSection::Memory,
130 OverviewSection::Processes,
131 OverviewSection::Network,
132 OverviewSection::Storage,
133 ];
134
135 pub fn label(self) -> &'static str {
137 match self {
138 OverviewSection::Cpu => "CPU",
139 OverviewSection::Gpu => "GPU",
140 OverviewSection::Memory => "Memory",
141 OverviewSection::Processes => "Processes",
142 OverviewSection::Network => "Network",
143 OverviewSection::Storage => "Storage",
144 }
145 }
146}
147
148#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
150#[serde(default, deny_unknown_fields)]
151pub struct OverviewConfig {
152 pub sections: Vec<OverviewSection>,
156 pub include_local: bool,
158 pub processes: usize,
161}
162
163impl Default for OverviewConfig {
164 fn default() -> Self {
165 Self {
166 sections: OverviewSection::ALL.to_vec(),
167 include_local: true,
168 processes: 10,
169 }
170 }
171}
172
173#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
175#[serde(deny_unknown_fields)]
176pub struct HostConfig {
177 pub name: String,
179 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub ssh: Option<String>,
183 #[serde(default, skip_serializing_if = "Option::is_none")]
185 pub command: Option<String>,
186 #[serde(default, skip_serializing_if = "Option::is_none")]
189 pub sections: Option<Vec<OverviewSection>>,
190}
191
192impl HostConfig {
193 pub fn from_destination(destination: &str) -> Self {
195 Self {
196 name: destination.to_owned(),
197 ssh: None,
198 command: None,
199 sections: None,
200 }
201 }
202
203 pub fn destination(&self) -> &str {
205 self.ssh.as_deref().unwrap_or(&self.name)
206 }
207}
208
209#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
211#[serde(default, deny_unknown_fields)]
212pub struct SensorConfig {
213 pub labels: BTreeMap<String, String>,
216 pub hidden: Vec<String>,
218}
219
220#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
222#[serde(default, deny_unknown_fields)]
223pub struct SmartConfig {
224 pub dir: PathBuf,
226}
227
228impl Default for SmartConfig {
229 fn default() -> Self {
230 Self {
231 dir: PathBuf::from(DEFAULT_SMART_DIR),
232 }
233 }
234}
235
236#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
238#[serde(default, deny_unknown_fields)]
239pub struct LhmConfig {
240 pub url: String,
243}
244
245impl Default for LhmConfig {
246 fn default() -> Self {
247 Self {
248 url: DEFAULT_LHM_URL.to_owned(),
249 }
250 }
251}
252
253#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
255#[serde(default, deny_unknown_fields)]
256pub struct BtrfsConfig {
257 pub scrub_dir: PathBuf,
259}
260
261impl Default for BtrfsConfig {
262 fn default() -> Self {
263 Self {
264 scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
265 }
266 }
267}
268
269impl Config {
270 pub fn default_path() -> Option<PathBuf> {
272 dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
273 }
274
275 pub fn load(path: &Path) -> Result<Self, ConfigError> {
277 let text = match fs::read_to_string(path) {
278 Ok(text) => text,
279 Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
280 Err(source) => {
281 return Err(ConfigError::Io {
282 path: path.to_owned(),
283 source,
284 });
285 }
286 };
287 toml::from_str(&text).map_err(|source| ConfigError::Parse {
288 path: path.to_owned(),
289 source,
290 })
291 }
292
293 pub fn load_default() -> Result<Self, ConfigError> {
296 match Self::default_path() {
297 Some(path) => Self::load(&path),
298 None => Ok(Self::default()),
299 }
300 }
301
302 pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
307 let text = toml::to_string_pretty(self)?;
308 let io_err = |source| ConfigError::Io {
309 path: path.to_owned(),
310 source,
311 };
312 if let Some(parent) = path.parent() {
313 fs::create_dir_all(parent).map_err(io_err)?;
314 }
315 let tmp = path.with_extension("toml.tmp");
316 fs::write(&tmp, text).map_err(io_err)?;
317 fs::rename(&tmp, path).map_err(io_err)
318 }
319
320 pub fn save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
327 let io_err = |source| ConfigError::Io {
328 path: path.to_owned(),
329 source,
330 };
331 let text = match fs::read_to_string(path) {
332 Ok(text) => text,
333 Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
334 Err(err) => return Err(io_err(err)),
335 };
336 let mut document: toml_edit::DocumentMut =
337 text.parse().map_err(|source| ConfigError::Edit {
338 path: path.to_owned(),
339 source,
340 })?;
341 if hosts.is_empty() {
342 document.remove("hosts");
343 } else {
344 let mut tables = toml_edit::ArrayOfTables::new();
345 for host in hosts {
346 let mut table = toml_edit::Table::new();
347 table["name"] = toml_edit::value(host.name.as_str());
348 if let Some(ssh) = &host.ssh {
349 table["ssh"] = toml_edit::value(ssh.as_str());
350 }
351 if let Some(command) = &host.command {
352 table["command"] = toml_edit::value(command.as_str());
353 }
354 if let Some(sections) = &host.sections {
355 let mut array = toml_edit::Array::new();
356 for section in sections {
357 let name = toml::Value::try_from(section)?;
359 if let Some(name) = name.as_str() {
360 array.push(name);
361 }
362 }
363 table["sections"] = toml_edit::value(array);
364 }
365 tables.push(table);
366 }
367 document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
368 }
369 let edited = document.to_string();
370 toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
372 path: path.to_owned(),
373 source,
374 })?;
375 if let Some(parent) = path.parent() {
376 fs::create_dir_all(parent).map_err(io_err)?;
377 }
378 let tmp = path.with_extension("toml.tmp");
379 fs::write(&tmp, edited).map_err(io_err)?;
380 fs::rename(&tmp, path).map_err(io_err)
381 }
382
383 pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
385 self.label_for(&fan.key).unwrap_or(&fan.label)
386 }
387
388 pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
390 self.label_for(&temperature.key)
391 .unwrap_or(&temperature.label)
392 }
393
394 pub fn label_for(&self, key: &str) -> Option<&str> {
396 self.sensors.labels.get(key).map(String::as_str)
397 }
398
399 pub fn set_label(&mut self, key: &str, name: &str) {
401 let name = name.trim();
402 if name.is_empty() {
403 self.sensors.labels.remove(key);
404 } else {
405 self.sensors.labels.insert(key.to_owned(), name.to_owned());
406 }
407 }
408
409 pub fn is_hidden(&self, key: &str) -> bool {
411 self.sensors.hidden.iter().any(|k| k == key)
412 }
413}
414
415#[cfg(test)]
416mod tests {
417 use super::*;
418
419 #[test]
420 fn missing_file_gives_defaults() {
421 let tmp = tempfile::tempdir().unwrap();
422 let config = Config::load(&tmp.path().join("none.toml")).unwrap();
423 assert_eq!(config, Config::default());
424 assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
425 }
426
427 #[test]
428 fn parses_labels_hidden_and_smart_dir() {
429 let tmp = tempfile::tempdir().unwrap();
430 let path = tmp.path().join("config.toml");
431 fs::write(
432 &path,
433 r#"
434[sensors]
435labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
436hidden = ["nct6686:nct6683.2592/temp4"]
437
438[smart]
439dir = "/tmp/smart"
440
441[lhm]
442url = ""
443"#,
444 )
445 .unwrap();
446 let config = Config::load(&path).unwrap();
447 let fan = Fan {
448 key: "nct6686:nct6683.2592/fan1".to_owned(),
449 label: "fan1".to_owned(),
450 rpm: 900,
451 };
452 let other = Fan {
453 key: "nct6686:nct6683.2592/fan2".to_owned(),
454 label: "fan2".to_owned(),
455 rpm: 0,
456 };
457 assert_eq!(config.fan_label(&fan), "CPU fan");
458 assert_eq!(config.fan_label(&other), "fan2");
460 assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
461 assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
462 assert_eq!(config.lhm.url, "");
463 assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
464 }
465
466 #[test]
467 fn parses_hosts_and_overview() {
468 let config: Config = toml::from_str(
469 r#"
470[overview]
471sections = ["storage", "cpu"]
472include_local = false
473
474[[hosts]]
475name = "zefat"
476
477[[hosts]]
478name = "nas"
479ssh = "admin@nas.local"
480sections = ["storage"]
481"#,
482 )
483 .unwrap();
484 assert_eq!(
485 config.overview.sections,
486 vec![OverviewSection::Storage, OverviewSection::Cpu]
487 );
488 assert!(!config.overview.include_local);
489 assert_eq!(config.overview.processes, 10, "default kept");
490 assert_eq!(config.hosts.len(), 2);
491 assert_eq!(config.hosts[0].destination(), "zefat");
492 assert_eq!(config.hosts[1].destination(), "admin@nas.local");
493 assert_eq!(
494 config.hosts[1].sections,
495 Some(vec![OverviewSection::Storage])
496 );
497 let defaults = Config::default();
499 assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
500 assert!(defaults.overview.include_local);
501 }
502
503 #[test]
504 fn save_hosts_replaces_only_the_hosts() {
505 let tmp = tempfile::tempdir().unwrap();
506 let path = tmp.path().join("config.toml");
507 fs::write(
508 &path,
509 "# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
510 )
511 .unwrap();
512 let hosts = vec![
513 HostConfig::from_destination("zefat"),
514 HostConfig {
515 name: "nas".to_owned(),
516 ssh: Some("admin@nas.local".to_owned()),
517 command: Some("/opt/rigtop --agent".to_owned()),
518 sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
519 },
520 ];
521 Config::save_hosts(&path, &hosts).unwrap();
522
523 let text = fs::read_to_string(&path).unwrap();
524 assert!(
525 text.contains("# my settings") && text.contains("# keep me"),
526 "{text}"
527 );
528 assert!(!text.contains("\"old\""), "{text}");
529 let config = Config::load(&path).unwrap();
530 assert_eq!(config.hosts, hosts);
531 assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
532
533 Config::save_hosts(&path, &[]).unwrap();
535 assert!(Config::load(&path).unwrap().hosts.is_empty());
536 assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
537 }
538
539 #[test]
540 fn save_hosts_creates_a_missing_file() {
541 let tmp = tempfile::tempdir().unwrap();
542 let path = tmp.path().join("new/rigtop/config.toml");
543 Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
544 assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
545 }
546
547 #[test]
548 fn save_hosts_refuses_to_touch_a_broken_file() {
549 let tmp = tempfile::tempdir().unwrap();
550 let path = tmp.path().join("config.toml");
551 fs::write(&path, "[sensors\n").unwrap();
552 let err = Config::save_hosts(&path, &[]).unwrap_err();
553 assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
554 assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
555 }
556
557 #[test]
558 fn unknown_fields_are_rejected() {
559 let tmp = tempfile::tempdir().unwrap();
560 let path = tmp.path().join("config.toml");
561 fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
563 assert!(matches!(
564 Config::load(&path),
565 Err(ConfigError::Parse { .. })
566 ));
567 }
568
569 #[test]
570 fn save_and_reload_round_trip() {
571 let tmp = tempfile::tempdir().unwrap();
572 let path = tmp.path().join("nested/rigtop/config.toml");
573 let mut config = Config::default();
574 config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
575 config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
576 config.set_label("nct6686:nct6683.2592/fan1", "");
577 config.save(&path).unwrap();
578
579 let back = Config::load(&path).unwrap();
580 assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
581 assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
583 assert!(!path.with_extension("toml.tmp").exists());
584 }
585}