use std::collections::BTreeMap;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::snapshot::{Fan, Temperature};
pub const DEFAULT_SMART_DIR: &str = "/var/lib/rigtop/smart";
pub const DEFAULT_SCRUB_DIR: &str = "/var/lib/rigtop/btrfs";
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("cannot access {path}: {source}")]
Io {
path: PathBuf,
source: io::Error,
},
#[error("invalid config {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("cannot serialize config: {0}")]
Serialize(#[from] toml::ser::Error),
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub sensors: SensorConfig,
pub smart: SmartConfig,
pub btrfs: BtrfsConfig,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SensorConfig {
pub labels: BTreeMap<String, String>,
pub hidden: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct SmartConfig {
pub dir: PathBuf,
}
impl Default for SmartConfig {
fn default() -> Self {
Self {
dir: PathBuf::from(DEFAULT_SMART_DIR),
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct BtrfsConfig {
pub scrub_dir: PathBuf,
}
impl Default for BtrfsConfig {
fn default() -> Self {
Self {
scrub_dir: PathBuf::from(DEFAULT_SCRUB_DIR),
}
}
}
impl Config {
pub fn default_path() -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("rigtop").join("config.toml"))
}
pub fn load(path: &Path) -> Result<Self, ConfigError> {
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(Self::default()),
Err(source) => {
return Err(ConfigError::Io {
path: path.to_owned(),
source,
});
}
};
toml::from_str(&text).map_err(|source| ConfigError::Parse {
path: path.to_owned(),
source,
})
}
pub fn load_default() -> Result<Self, ConfigError> {
match Self::default_path() {
Some(path) => Self::load(&path),
None => Ok(Self::default()),
}
}
pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
let text = toml::to_string_pretty(self)?;
let io_err = |source| ConfigError::Io {
path: path.to_owned(),
source,
};
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).map_err(io_err)?;
}
let tmp = path.with_extension("toml.tmp");
fs::write(&tmp, text).map_err(io_err)?;
fs::rename(&tmp, path).map_err(io_err)
}
pub fn fan_label<'a>(&'a self, fan: &'a Fan) -> &'a str {
self.label_for(&fan.key).unwrap_or(&fan.label)
}
pub fn temperature_label<'a>(&'a self, temperature: &'a Temperature) -> &'a str {
self.label_for(&temperature.key)
.unwrap_or(&temperature.label)
}
pub fn label_for(&self, key: &str) -> Option<&str> {
self.sensors.labels.get(key).map(String::as_str)
}
pub fn set_label(&mut self, key: &str, name: &str) {
let name = name.trim();
if name.is_empty() {
self.sensors.labels.remove(key);
} else {
self.sensors.labels.insert(key.to_owned(), name.to_owned());
}
}
pub fn is_hidden(&self, key: &str) -> bool {
self.sensors.hidden.iter().any(|k| k == key)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn missing_file_gives_defaults() {
let tmp = tempfile::tempdir().unwrap();
let config = Config::load(&tmp.path().join("none.toml")).unwrap();
assert_eq!(config, Config::default());
assert_eq!(config.smart.dir, PathBuf::from(DEFAULT_SMART_DIR));
}
#[test]
fn parses_labels_hidden_and_smart_dir() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
fs::write(
&path,
r#"
[sensors]
labels = { "nct6686:nct6683.2592/fan1" = "CPU fan" }
hidden = ["nct6686:nct6683.2592/temp4"]
[smart]
dir = "/tmp/smart"
"#,
)
.unwrap();
let config = Config::load(&path).unwrap();
let fan = Fan {
key: "nct6686:nct6683.2592/fan1".to_owned(),
label: "fan1".to_owned(),
rpm: 900,
};
let other = Fan {
key: "nct6686:nct6683.2592/fan2".to_owned(),
label: "fan2".to_owned(),
rpm: 0,
};
assert_eq!(config.fan_label(&fan), "CPU fan");
assert_eq!(config.fan_label(&other), "fan2");
assert!(config.is_hidden("nct6686:nct6683.2592/temp4"));
assert_eq!(config.smart.dir, PathBuf::from("/tmp/smart"));
}
#[test]
fn unknown_fields_are_rejected() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
fs::write(&path, "[sensors]\nlabel = {}\n").unwrap();
assert!(matches!(
Config::load(&path),
Err(ConfigError::Parse { .. })
));
}
#[test]
fn save_and_reload_round_trip() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("nested/rigtop/config.toml");
let mut config = Config::default();
config.set_label("k10temp:0000:00:18.3/temp3", " CCD1 ");
config.set_label("nct6686:nct6683.2592/fan1", "Rear fan");
config.set_label("nct6686:nct6683.2592/fan1", "");
config.save(&path).unwrap();
let back = Config::load(&path).unwrap();
assert_eq!(back.label_for("k10temp:0000:00:18.3/temp3"), Some("CCD1"));
assert_eq!(back.label_for("nct6686:nct6683.2592/fan1"), None);
assert!(!path.with_extension("toml.tmp").exists());
}
}