use std::collections::BTreeSet;
use std::io;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
use crate::common::config::{AlertConfig, EnergyConfig};
use crate::common::config_apply;
use crate::common::config_env;
use crate::common::config_schema::{KNOWN_TOP_LEVEL, RawConfig};
use crate::common::paths;
pub(crate) const MAX_CONFIG_BYTES: u64 = 1 << 20;
pub use crate::common::config_schema::SocketSetting;
pub const SUPPORTED_SCHEMA_VERSION: u32 = 1;
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("config file I/O error at {path}: {source}")]
Io {
path: PathBuf,
#[source]
source: std::io::Error,
},
#[error("config file parse error: {0}")]
Parse(String),
#[error(
"config file schema_version = {found} is not supported \
(this build understands schema_version = {supported})"
)]
SchemaVersion { found: u32, supported: u32 },
#[error("config file semantic error: {0}")]
Semantic(String),
#[error("config file unknown key: {0}")]
UnknownKey(String),
}
pub fn escape_printable(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for c in s.chars() {
let code = c as u32;
if code < 0x20 || code == 0x7f {
out.push_str(&format!("\\u{{{code:04X}}}"));
} else {
out.push(c);
}
}
out
}
#[derive(Debug, Clone)]
pub struct Settings {
pub general: GeneralSettings,
pub local: LocalSettings,
pub view: ViewSettings,
pub api: ApiSettings,
pub alerts: AlertConfig,
pub energy: EnergyConfig,
pub display: DisplaySettings,
pub record: RecordSettings,
pub snapshot: SnapshotSettings,
pub source_path: Option<PathBuf>,
pub unknown_keys: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct GeneralSettings {
pub default_mode: String,
pub theme: String,
pub locale: String,
}
#[derive(Debug, Clone, Default)]
pub struct LocalSettings {
pub interval_secs: Option<u64>,
}
#[derive(Debug, Clone, Default)]
pub struct ViewSettings {
pub hostfile: Option<String>,
pub hosts: Vec<String>,
pub interval_secs: Option<u64>,
pub ssh: Vec<String>,
pub ssh_hostfile: Option<String>,
pub ssh_key: Option<String>,
pub ssh_config: Option<String>,
pub ssh_strict_host_key: Option<String>,
pub ssh_timeout_secs: Option<u64>,
pub ssh_fallback: Option<String>,
pub ssh_known_hosts: Option<String>,
pub ssh_concurrency: Option<usize>,
}
#[derive(Debug, Clone)]
pub struct ApiSettings {
pub port: u16,
pub socket: SocketSetting,
pub processes: bool,
pub interval_secs: u64,
}
#[derive(Debug, Clone)]
pub struct DisplaySettings {
pub color_scheme: String,
pub gauge_style: String,
pub show_led_grid: bool,
}
impl Default for DisplaySettings {
fn default() -> Self {
Self {
color_scheme: "default".to_string(),
gauge_style: "blocks".to_string(),
show_led_grid: true,
}
}
}
#[derive(Debug, Clone)]
pub struct RecordSettings {
pub output_dir: Option<String>,
pub compress: String,
}
#[derive(Debug, Clone)]
pub struct SnapshotSettings {
pub default_format: String,
pub default_pretty: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
general: GeneralSettings {
default_mode: "local".to_string(),
theme: "auto".to_string(),
locale: "en".to_string(),
},
local: LocalSettings::default(),
view: ViewSettings::default(),
api: ApiSettings {
port: 9090,
socket: SocketSetting::Unset,
processes: false,
interval_secs: 3,
},
alerts: AlertConfig::default(),
energy: EnergyConfig::default(),
display: DisplaySettings {
color_scheme: "default".to_string(),
gauge_style: "blocks".to_string(),
show_led_grid: true,
},
record: RecordSettings {
output_dir: None,
compress: "zstd".to_string(),
},
snapshot: SnapshotSettings {
default_format: "json".to_string(),
default_pretty: true,
},
source_path: None,
unknown_keys: Vec::new(),
}
}
}
#[derive(Debug)]
pub struct LoadOutcome {
pub settings: Settings,
pub warnings: Vec<String>,
}
pub fn load(explicit: Option<&Path>) -> Result<LoadOutcome, ConfigError> {
let mut warnings = Vec::new();
let (raw, source_path, unknown_keys): (RawConfig, Option<PathBuf>, Vec<String>) =
if let Some(path) = explicit {
let contents = read_config_capped(path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
let (raw, unknown) = parse_toml(&contents)?;
check_schema_version(&raw)?;
(raw, Some(path.to_path_buf()), unknown)
} else if let Some(path) = paths::discover_existing_config() {
match read_config_capped(&path) {
Ok(contents) => {
let (raw, unknown) = parse_toml(&contents)?;
check_schema_version(&raw)?;
(raw, Some(path), unknown)
}
Err(e) => {
warnings.push(format!(
"config: could not read {} ({e}); using defaults",
path.display()
));
(RawConfig::default(), None, Vec::new())
}
}
} else {
(RawConfig::default(), None, Vec::new())
};
let mut settings = Settings {
source_path,
unknown_keys,
..Settings::default()
};
config_apply::apply_file(&raw, &mut settings)?;
config_env::apply_env(&mut settings, &mut warnings);
Ok(LoadOutcome { settings, warnings })
}
fn check_schema_version(raw: &RawConfig) -> Result<(), ConfigError> {
if let Some(v) = raw.schema_version
&& v != SUPPORTED_SCHEMA_VERSION
{
return Err(ConfigError::SchemaVersion {
found: v,
supported: SUPPORTED_SCHEMA_VERSION,
});
}
Ok(())
}
pub fn validate_file(path: &Path, strict: bool) -> Result<Settings, ConfigError> {
let contents = read_config_capped(path).map_err(|e| ConfigError::Io {
path: path.to_path_buf(),
source: e,
})?;
let (raw, unknown_keys) = parse_toml(&contents)?;
check_schema_version(&raw)?;
if strict && !unknown_keys.is_empty() {
return Err(ConfigError::UnknownKey(unknown_keys.join(", ")));
}
let mut settings = Settings {
source_path: Some(path.to_path_buf()),
unknown_keys,
..Settings::default()
};
config_apply::apply_file(&raw, &mut settings)?;
Ok(settings)
}
pub fn parse_toml(contents: &str) -> Result<(RawConfig, Vec<String>), ConfigError> {
let value: TomlValue =
toml::from_str(contents).map_err(|e| ConfigError::Parse(e.to_string()))?;
let raw: RawConfig = value
.clone()
.try_into()
.map_err(|e: toml::de::Error| ConfigError::Parse(e.to_string()))?;
let mut unknown = BTreeSet::new();
if let TomlValue::Table(top) = &value {
for key in top.keys() {
if !KNOWN_TOP_LEVEL.contains(&key.as_str()) {
unknown.insert(escape_printable(key));
}
}
scan_unknown_subkeys(top, &mut unknown);
}
Ok((raw, unknown.into_iter().collect()))
}
fn read_config_capped(path: &Path) -> io::Result<String> {
use std::io::Read;
match std::fs::symlink_metadata(path) {
Ok(md) if md.file_type().is_symlink() => {
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
format!(
"refusing to read config at {} — path is a symlink",
path.display()
),
));
}
Ok(_) => {}
Err(_) => {}
}
let f = std::fs::File::open(path)?;
if let Ok(md) = f.metadata()
&& md.len() > MAX_CONFIG_BYTES
{
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("config file > {MAX_CONFIG_BYTES} bytes; refusing to read"),
));
}
let mut buf = String::new();
f.take(MAX_CONFIG_BYTES + 1).read_to_string(&mut buf)?;
if buf.len() as u64 > MAX_CONFIG_BYTES {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
format!("config file > {MAX_CONFIG_BYTES} bytes; refusing to parse"),
));
}
Ok(buf)
}
fn scan_unknown_subkeys(top: &toml::map::Map<String, TomlValue>, out: &mut BTreeSet<String>) {
use toml::map::Map;
let check = |name: &str, known: &[&str], out: &mut BTreeSet<String>, top: &Map<_, _>| {
if let Some(TomlValue::Table(sec)) = top.get(name) {
for k in sec.keys() {
if !known.contains(&k.as_str()) {
out.insert(format!("{name}.{}", escape_printable(k)));
}
}
}
};
check("general", &["default_mode", "theme", "locale"], out, top);
check("local", &["interval_secs"], out, top);
check("view", &["hostfile", "hosts", "interval_secs"], out, top);
check(
"api",
&["port", "socket", "processes", "interval_secs"],
out,
top,
);
check(
"alerts",
&[
"enabled",
"temp_warn_c",
"temp_crit_c",
"util_idle_pct",
"util_idle_warn_mins",
"hysteresis_c",
"bell_on_critical",
"webhook_url",
"power_crit_w",
],
out,
top,
);
check(
"energy",
&[
"price_per_kwh",
"currency",
"show_cost",
"wal_path",
"gap_interpolate_seconds",
"wal_enabled",
],
out,
top,
);
check(
"display",
&["color_scheme", "gauge_style", "show_led_grid"],
out,
top,
);
check("record", &["output_dir", "compress"], out, top);
check("snapshot", &["default_format", "default_pretty"], out, top);
}
#[cfg(test)]
#[path = "config_file_tests.rs"]
mod tests;