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";
pub const DEFAULT_LHM_URL: &str = "http://127.0.0.1:8085/data.json";
#[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),
#[error("cannot edit {path}: {source}")]
Edit {
path: PathBuf,
source: toml_edit::TomlError,
},
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Config {
pub sensors: SensorConfig,
pub smart: SmartConfig,
pub btrfs: BtrfsConfig,
pub lhm: LhmConfig,
pub overview: OverviewConfig,
pub notifications: NotificationsConfig,
pub hosts: Vec<HostConfig>,
pub widgets: Vec<WidgetConfig>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BuiltinWidget {
Clock,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct WidgetConfig {
pub name: String,
pub command: Option<String>,
pub builtin: Option<BuiltinWidget>,
pub interval_secs: u64,
pub timeout_secs: u64,
}
impl Default for WidgetConfig {
fn default() -> Self {
Self {
name: String::new(),
command: None,
builtin: None,
interval_secs: 5,
timeout_secs: 10,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum OverviewSection {
Cpu,
Gpu,
Memory,
Processes,
Network,
Storage,
}
impl OverviewSection {
pub const ALL: [OverviewSection; 6] = [
OverviewSection::Cpu,
OverviewSection::Gpu,
OverviewSection::Memory,
OverviewSection::Processes,
OverviewSection::Network,
OverviewSection::Storage,
];
pub fn label(self) -> &'static str {
match self {
OverviewSection::Cpu => "CPU",
OverviewSection::Gpu => "GPU",
OverviewSection::Memory => "Memory",
OverviewSection::Processes => "Processes",
OverviewSection::Network => "Network",
OverviewSection::Storage => "Storage",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct OverviewConfig {
pub sections: Vec<OverviewSection>,
pub include_local: bool,
pub processes: usize,
}
impl Default for OverviewConfig {
fn default() -> Self {
Self {
sections: OverviewSection::ALL.to_vec(),
include_local: true,
processes: 10,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HostConfig {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ssh: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub command: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sections: Option<Vec<OverviewSection>>,
}
impl HostConfig {
pub fn from_destination(destination: &str) -> Self {
Self {
name: destination.to_owned(),
ssh: None,
command: None,
sections: None,
}
}
pub fn destination(&self) -> &str {
self.ssh.as_deref().unwrap_or(&self.name)
}
}
#[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, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NotifyLevel {
Off,
Warning,
#[default]
Critical,
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NotificationsConfig {
pub level: NotifyLevel,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct LhmConfig {
pub url: String,
}
impl Default for LhmConfig {
fn default() -> Self {
Self {
url: DEFAULT_LHM_URL.to_owned(),
}
}
}
#[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 save_hosts(path: &Path, hosts: &[HostConfig]) -> Result<(), ConfigError> {
let io_err = |source| ConfigError::Io {
path: path.to_owned(),
source,
};
let text = match fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == io::ErrorKind::NotFound => String::new(),
Err(err) => return Err(io_err(err)),
};
let mut document: toml_edit::DocumentMut =
text.parse().map_err(|source| ConfigError::Edit {
path: path.to_owned(),
source,
})?;
if hosts.is_empty() {
document.remove("hosts");
} else {
let mut tables = toml_edit::ArrayOfTables::new();
for host in hosts {
let mut table = toml_edit::Table::new();
table["name"] = toml_edit::value(host.name.as_str());
if let Some(ssh) = &host.ssh {
table["ssh"] = toml_edit::value(ssh.as_str());
}
if let Some(command) = &host.command {
table["command"] = toml_edit::value(command.as_str());
}
if let Some(sections) = &host.sections {
let mut array = toml_edit::Array::new();
for section in sections {
let name = toml::Value::try_from(section)?;
if let Some(name) = name.as_str() {
array.push(name);
}
}
table["sections"] = toml_edit::value(array);
}
tables.push(table);
}
document["hosts"] = toml_edit::Item::ArrayOfTables(tables);
}
let edited = document.to_string();
toml::from_str::<Config>(&edited).map_err(|source| ConfigError::Parse {
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, edited).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"
[lhm]
url = ""
[notifications]
level = "warning"
"#,
)
.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"));
assert_eq!(config.lhm.url, "");
assert_eq!(config.notifications.level, NotifyLevel::Warning);
assert_eq!(Config::default().notifications.level, NotifyLevel::Critical);
assert_eq!(Config::default().lhm.url, DEFAULT_LHM_URL);
}
#[test]
fn parses_hosts_and_overview() {
let config: Config = toml::from_str(
r#"
[overview]
sections = ["storage", "cpu"]
include_local = false
[[hosts]]
name = "zefat"
[[hosts]]
name = "nas"
ssh = "admin@nas.local"
sections = ["storage"]
[[widgets]]
name = "Clock"
builtin = "clock"
[[widgets]]
name = "Rates"
command = "rates.sh"
interval_secs = 60
"#,
)
.unwrap();
assert_eq!(
config.overview.sections,
vec![OverviewSection::Storage, OverviewSection::Cpu]
);
assert!(!config.overview.include_local);
assert_eq!(config.overview.processes, 10, "default kept");
assert_eq!(config.hosts.len(), 2);
assert_eq!(config.hosts[0].destination(), "zefat");
assert_eq!(config.widgets[0].builtin, Some(BuiltinWidget::Clock));
assert_eq!(config.widgets[1].command.as_deref(), Some("rates.sh"));
assert_eq!(config.widgets[1].interval_secs, 60);
assert_eq!(config.widgets[1].timeout_secs, 10, "default kept");
assert_eq!(config.hosts[1].destination(), "admin@nas.local");
assert_eq!(
config.hosts[1].sections,
Some(vec![OverviewSection::Storage])
);
let defaults = Config::default();
assert_eq!(defaults.overview.sections, OverviewSection::ALL.to_vec());
assert!(defaults.overview.include_local);
}
#[test]
fn save_hosts_replaces_only_the_hosts() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
fs::write(
&path,
"# my settings\n[sensors]\nlabels = { \"a/fan1\" = \"CPU fan\" } # keep me\n\n[[hosts]]\nname = \"old\"\n",
)
.unwrap();
let hosts = vec![
HostConfig::from_destination("zefat"),
HostConfig {
name: "nas".to_owned(),
ssh: Some("admin@nas.local".to_owned()),
command: Some("/opt/rigtop --agent".to_owned()),
sections: Some(vec![OverviewSection::Storage, OverviewSection::Cpu]),
},
];
Config::save_hosts(&path, &hosts).unwrap();
let text = fs::read_to_string(&path).unwrap();
assert!(
text.contains("# my settings") && text.contains("# keep me"),
"{text}"
);
assert!(!text.contains("\"old\""), "{text}");
let config = Config::load(&path).unwrap();
assert_eq!(config.hosts, hosts);
assert_eq!(config.label_for("a/fan1"), Some("CPU fan"));
Config::save_hosts(&path, &[]).unwrap();
assert!(Config::load(&path).unwrap().hosts.is_empty());
assert!(fs::read_to_string(&path).unwrap().contains("# keep me"));
}
#[test]
fn save_hosts_creates_a_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("new/rigtop/config.toml");
Config::save_hosts(&path, &[HostConfig::from_destination("zefat")]).unwrap();
assert_eq!(Config::load(&path).unwrap().hosts[0].name, "zefat");
}
#[test]
fn save_hosts_refuses_to_touch_a_broken_file() {
let tmp = tempfile::tempdir().unwrap();
let path = tmp.path().join("config.toml");
fs::write(&path, "[sensors\n").unwrap();
let err = Config::save_hosts(&path, &[]).unwrap_err();
assert!(matches!(err, ConfigError::Edit { .. }), "{err}");
assert_eq!(fs::read_to_string(&path).unwrap(), "[sensors\n");
}
#[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());
}
}