use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
macro_rules! validate {
($errors:expr, $field:expr, $msg:expr) => {
$errors.push(ValidationError {
field: $field.to_string(),
message: $msg.to_string(),
})
};
($errors:expr, $field:expr, $($arg:tt)+) => {
$errors.push(ValidationError {
field: $field.to_string(),
message: format!($($arg)+),
})
};
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Config {
pub server: ServerConfig,
pub storage: StorageConfig,
#[serde(default)]
pub security: SecurityConfig,
#[serde(default)]
pub devices: Vec<DeviceConfig>,
#[serde(default)]
pub prometheus: PrometheusConfig,
#[serde(default)]
pub mqtt: MqttConfig,
#[serde(default)]
pub notifications: NotificationConfig,
#[serde(default)]
pub webhooks: WebhookConfig,
#[serde(default)]
pub influxdb: InfluxDbConfig,
}
impl Config {
pub fn load_default() -> Result<Self, ConfigError> {
let path = default_config_path();
if path.exists() {
Self::load(&path)
} else {
Ok(Self::default())
}
}
pub fn load<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let content = std::fs::read_to_string(path.as_ref()).map_err(|e| ConfigError::Read {
path: path.as_ref().to_path_buf(),
source: e,
})?;
toml::from_str(&content).map_err(|e| ConfigError::Parse {
path: path.as_ref().to_path_buf(),
source: e,
})
}
pub fn save<P: AsRef<Path>>(&self, path: P) -> Result<(), ConfigError> {
let content = toml::to_string_pretty(self).map_err(ConfigError::Serialize)?;
if let Some(parent) = path.as_ref().parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
path: parent.to_path_buf(),
source: e,
})?;
}
std::fs::write(path.as_ref(), content).map_err(|e| ConfigError::Write {
path: path.as_ref().to_path_buf(),
source: e,
})?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o600);
let _ = std::fs::set_permissions(path.as_ref(), perms);
}
Ok(())
}
pub fn validate(&self) -> Result<(), ConfigError> {
let mut errors = Vec::new();
errors.extend(self.server.validate());
errors.extend(self.storage.validate());
errors.extend(self.security.validate());
let mut seen_addresses = std::collections::HashSet::new();
for (i, device) in self.devices.iter().enumerate() {
let prefix = format!("devices[{}]", i);
errors.extend(device.validate(&prefix));
let addr_lower = device.address.to_lowercase();
if !seen_addresses.insert(addr_lower.clone()) {
validate!(
errors,
format!("{}.address", prefix),
"duplicate device address '{}'",
device.address
);
}
}
errors.extend(self.prometheus.validate());
errors.extend(self.mqtt.validate());
errors.extend(self.webhooks.validate());
errors.extend(self.influxdb.validate());
if errors.is_empty() {
Ok(())
} else {
Err(ConfigError::Validation(errors))
}
}
pub fn load_validated<P: AsRef<Path>>(path: P) -> Result<Self, ConfigError> {
let config = Self::load(path)?;
config.validate()?;
Ok(config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct ServerConfig {
pub bind: String,
#[serde(default = "default_broadcast_buffer")]
pub broadcast_buffer: usize,
}
pub const DEFAULT_BROADCAST_BUFFER: usize = 100;
fn default_broadcast_buffer() -> usize {
DEFAULT_BROADCAST_BUFFER
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
bind: "127.0.0.1:8080".to_string(),
broadcast_buffer: DEFAULT_BROADCAST_BUFFER,
}
}
}
impl ServerConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.bind.is_empty() {
validate!(errors, "server.bind", "bind address cannot be empty");
} else {
let parts: Vec<&str> = self.bind.rsplitn(2, ':').collect();
if parts.len() != 2 {
validate!(
errors,
"server.bind",
"invalid bind address '{}': expected format 'host:port'",
self.bind
);
} else {
let port_str = parts[0];
match port_str.parse::<u16>() {
Ok(0) => {
validate!(errors, "server.bind", "port cannot be 0");
}
Err(_) => {
validate!(
errors,
"server.bind",
"invalid port '{}': must be a number 1-65535",
port_str
);
}
Ok(_) => {} }
}
}
if self.broadcast_buffer == 0 {
validate!(
errors,
"server.broadcast_buffer",
"broadcast buffer must be greater than 0"
);
} else if self.broadcast_buffer > 10_000 {
validate!(
errors,
"server.broadcast_buffer",
"broadcast buffer {} exceeds maximum of 10000",
self.broadcast_buffer
);
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct StorageConfig {
pub path: PathBuf,
}
impl Default for StorageConfig {
fn default() -> Self {
Self {
path: aranet_store::default_db_path(),
}
}
}
impl StorageConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.path.as_os_str().is_empty() {
validate!(errors, "storage.path", "database path cannot be empty");
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct SecurityConfig {
pub api_key_enabled: bool,
pub api_key: Option<String>,
pub rate_limit_enabled: bool,
#[serde(default = "default_rate_limit_requests")]
pub rate_limit_requests: u32,
#[serde(default = "default_rate_limit_window")]
pub rate_limit_window_secs: u64,
#[serde(default = "default_rate_limit_max_entries")]
pub rate_limit_max_entries: usize,
#[serde(default = "default_cors_origins")]
pub cors_origins: Vec<String>,
}
fn default_rate_limit_requests() -> u32 {
100
}
fn default_rate_limit_window() -> u64 {
60
}
fn default_rate_limit_max_entries() -> usize {
10_000
}
fn default_cors_origins() -> Vec<String> {
vec![
"http://localhost".to_string(),
"http://127.0.0.1".to_string(),
]
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
api_key_enabled: false,
api_key: None,
rate_limit_enabled: true,
rate_limit_requests: default_rate_limit_requests(),
rate_limit_window_secs: default_rate_limit_window(),
rate_limit_max_entries: default_rate_limit_max_entries(),
cors_origins: default_cors_origins(),
}
}
}
impl SecurityConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.api_key_enabled {
match &self.api_key {
None => {
validate!(
errors,
"security.api_key",
"API key must be set when authentication is enabled"
);
}
Some(key) if key.len() < 32 => {
validate!(
errors,
"security.api_key",
"API key must be at least 32 characters for security"
);
}
_ => {}
}
}
if self.rate_limit_enabled {
if self.rate_limit_requests == 0 {
validate!(
errors,
"security.rate_limit_requests",
"rate limit requests must be greater than 0"
);
}
if self.rate_limit_window_secs < 1 {
validate!(
errors,
"security.rate_limit_window_secs",
"rate limit window must be at least 1 second"
);
}
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct PrometheusConfig {
pub enabled: bool,
pub push_gateway: Option<String>,
#[serde(default = "default_push_interval")]
pub push_interval: u64,
}
fn default_push_interval() -> u64 {
60
}
impl Default for PrometheusConfig {
fn default() -> Self {
Self {
enabled: false,
push_gateway: None,
push_interval: default_push_interval(),
}
}
}
impl PrometheusConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if let Some(url) = &self.push_gateway
&& url.is_empty()
{
validate!(
errors,
"prometheus.push_gateway",
"push gateway URL cannot be empty (use null/omit instead)"
);
}
if self.push_interval < 10 {
validate!(
errors,
"prometheus.push_interval",
"push interval {} is too short (minimum 10 seconds)",
self.push_interval
);
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MqttConfig {
pub enabled: bool,
pub broker: String,
#[serde(default = "default_topic_prefix")]
pub topic_prefix: String,
#[serde(default = "default_client_id")]
pub client_id: String,
#[serde(default = "default_qos")]
pub qos: u8,
#[serde(default)]
pub retain: bool,
pub username: Option<String>,
pub password: Option<String>,
#[serde(default = "default_keep_alive")]
pub keep_alive: u64,
#[serde(default)]
pub homeassistant: bool,
#[serde(default = "default_ha_discovery_prefix")]
pub ha_discovery_prefix: String,
}
fn default_topic_prefix() -> String {
"aranet".to_string()
}
fn default_client_id() -> String {
"aranet-service".to_string()
}
fn default_qos() -> u8 {
1
}
fn default_keep_alive() -> u64 {
60
}
fn default_ha_discovery_prefix() -> String {
"homeassistant".to_string()
}
impl Default for MqttConfig {
fn default() -> Self {
Self {
enabled: false,
broker: "mqtt://localhost:1883".to_string(),
topic_prefix: default_topic_prefix(),
client_id: default_client_id(),
qos: default_qos(),
retain: false,
username: None,
password: None,
keep_alive: default_keep_alive(),
homeassistant: false,
ha_discovery_prefix: default_ha_discovery_prefix(),
}
}
}
impl MqttConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.enabled {
if self.broker.is_empty() {
validate!(
errors,
"mqtt.broker",
"broker URL cannot be empty when MQTT is enabled"
);
} else if !self.broker.starts_with("mqtt://") && !self.broker.starts_with("mqtts://") {
validate!(
errors,
"mqtt.broker",
"invalid broker URL '{}': must start with mqtt:// or mqtts://",
self.broker
);
}
if self.topic_prefix.is_empty() {
validate!(errors, "mqtt.topic_prefix", "topic prefix cannot be empty");
}
if self.client_id.is_empty() {
validate!(errors, "mqtt.client_id", "client ID cannot be empty");
}
if self.qos > 2 {
validate!(
errors,
"mqtt.qos",
"invalid QoS level {}: must be 0, 1, or 2",
self.qos
);
}
if self.keep_alive < 5 {
validate!(
errors,
"mqtt.keep_alive",
"keep-alive interval {} is too short (minimum 5 seconds)",
self.keep_alive
);
}
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeviceConfig {
pub address: String,
#[serde(default)]
pub alias: Option<String>,
#[serde(default = "default_poll_interval")]
pub poll_interval: u64,
}
pub const MIN_POLL_INTERVAL: u64 = 10;
pub const MAX_POLL_INTERVAL: u64 = 3600;
fn default_poll_interval() -> u64 {
60
}
impl DeviceConfig {
pub fn validate(&self, prefix: &str) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.address.is_empty() {
validate!(
errors,
format!("{}.address", prefix),
"device address cannot be empty"
);
} else if self.address.len() < 3 {
validate!(
errors,
format!("{}.address", prefix),
"device address '{}' is too short (minimum 3 characters)",
self.address
);
}
if let Some(alias) = &self.alias
&& alias.is_empty()
{
validate!(
errors,
format!("{}.alias", prefix),
"alias cannot be empty string (use null/omit instead)"
);
}
if self.poll_interval < MIN_POLL_INTERVAL {
validate!(
errors,
format!("{}.poll_interval", prefix),
"poll interval {} is too short (minimum {} seconds)",
self.poll_interval,
MIN_POLL_INTERVAL
);
} else if self.poll_interval > MAX_POLL_INTERVAL {
validate!(
errors,
format!("{}.poll_interval", prefix),
"poll interval {} is too long (maximum {} seconds / 1 hour)",
self.poll_interval,
MAX_POLL_INTERVAL
);
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct NotificationConfig {
pub enabled: bool,
#[serde(default = "default_co2_threshold")]
pub co2_threshold: u16,
#[serde(default = "default_radon_threshold")]
pub radon_threshold: u32,
#[serde(default = "default_notification_cooldown")]
pub cooldown_secs: u64,
}
fn default_co2_threshold() -> u16 {
1000
}
fn default_radon_threshold() -> u32 {
300
}
fn default_notification_cooldown() -> u64 {
300
}
impl Default for NotificationConfig {
fn default() -> Self {
Self {
enabled: false,
co2_threshold: default_co2_threshold(),
radon_threshold: default_radon_threshold(),
cooldown_secs: default_notification_cooldown(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct WebhookConfig {
pub enabled: bool,
#[serde(default = "default_co2_threshold")]
pub co2_threshold: u16,
#[serde(default = "default_radon_threshold")]
pub radon_threshold: u32,
#[serde(default = "default_battery_threshold")]
pub battery_threshold: u8,
#[serde(default = "default_webhook_cooldown")]
pub cooldown_secs: u64,
#[serde(default)]
pub endpoints: Vec<WebhookEndpoint>,
}
fn default_battery_threshold() -> u8 {
10
}
fn default_webhook_cooldown() -> u64 {
300
}
impl Default for WebhookConfig {
fn default() -> Self {
Self {
enabled: false,
co2_threshold: default_co2_threshold(),
radon_threshold: default_radon_threshold(),
battery_threshold: default_battery_threshold(),
cooldown_secs: default_webhook_cooldown(),
endpoints: Vec::new(),
}
}
}
impl WebhookConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.enabled && self.endpoints.is_empty() {
validate!(
errors,
"webhooks.endpoints",
"at least one endpoint must be configured when webhooks are enabled"
);
}
for (i, endpoint) in self.endpoints.iter().enumerate() {
let prefix = format!("webhooks.endpoints[{}]", i);
if endpoint.url.is_empty() {
validate!(errors, format!("{}.url", prefix), "URL cannot be empty");
} else if !endpoint.url.starts_with("http://") && !endpoint.url.starts_with("https://")
{
validate!(
errors,
format!("{}.url", prefix),
"URL must start with http:// or https://"
);
}
if endpoint.events.is_empty() {
validate!(
errors,
format!("{}.events", prefix),
"at least one event type must be specified"
);
}
for event in &endpoint.events {
if !["co2_high", "radon_high", "battery_low"].contains(&event.as_str()) {
validate!(
errors,
format!("{}.events", prefix),
"unknown event type '{}' (valid: co2_high, radon_high, battery_low)",
event
);
}
}
}
if self.cooldown_secs < 10 {
validate!(
errors,
"webhooks.cooldown_secs",
"cooldown {} is too short (minimum 10 seconds)",
self.cooldown_secs
);
}
errors
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebhookEndpoint {
pub url: String,
pub events: Vec<String>,
#[serde(default)]
pub headers: std::collections::HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct InfluxDbConfig {
pub enabled: bool,
pub url: String,
pub token: Option<String>,
pub org: String,
pub bucket: String,
#[serde(default = "default_influxdb_measurement")]
pub measurement: String,
#[serde(default = "default_influxdb_precision")]
pub precision: String,
}
fn default_influxdb_measurement() -> String {
"aranet".to_string()
}
fn default_influxdb_precision() -> String {
"s".to_string()
}
impl Default for InfluxDbConfig {
fn default() -> Self {
Self {
enabled: false,
url: "http://localhost:8086".to_string(),
token: None,
org: String::new(),
bucket: "aranet".to_string(),
measurement: default_influxdb_measurement(),
precision: default_influxdb_precision(),
}
}
}
impl InfluxDbConfig {
pub fn validate(&self) -> Vec<ValidationError> {
let mut errors = Vec::new();
if self.enabled {
if self.url.trim().is_empty() {
validate!(
errors,
"influxdb.url",
"URL cannot be empty when InfluxDB export is enabled"
);
}
if self.org.trim().is_empty() {
validate!(
errors,
"influxdb.org",
"organization cannot be empty when InfluxDB export is enabled"
);
}
if self.bucket.trim().is_empty() {
validate!(errors, "influxdb.bucket", "bucket name cannot be empty");
}
if self.measurement.trim().is_empty() {
validate!(
errors,
"influxdb.measurement",
"measurement name cannot be empty"
);
}
if !["s", "ms", "us", "ns"].contains(&self.precision.as_str()) {
validate!(
errors,
"influxdb.precision",
"invalid precision '{}' (valid: s, ms, us, ns)",
self.precision
);
}
}
errors
}
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("Failed to read config file {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("Failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
source: toml::de::Error,
},
#[error("Failed to serialize config: {0}")]
Serialize(toml::ser::Error),
#[error("Failed to write config file {path}: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("Configuration validation failed:\n{}", format_validation_errors(.0))]
Validation(Vec<ValidationError>),
}
#[derive(Debug, Clone, thiserror::Error)]
#[error("{field}: {message}")]
pub struct ValidationError {
pub field: String,
pub message: String,
}
fn format_validation_errors(errors: &[ValidationError]) -> String {
errors
.iter()
.map(|e| format!(" - {}", e))
.collect::<Vec<_>>()
.join("\n")
}
pub fn default_config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("aranet")
.join("server.toml")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_default() {
let config = Config::default();
assert_eq!(config.server.bind, "127.0.0.1:8080");
assert!(config.devices.is_empty());
}
#[test]
fn test_server_config_default() {
let config = ServerConfig::default();
assert_eq!(config.bind, "127.0.0.1:8080");
assert_eq!(config.broadcast_buffer, DEFAULT_BROADCAST_BUFFER);
}
#[test]
fn test_storage_config_default() {
let config = StorageConfig::default();
assert_eq!(config.path, aranet_store::default_db_path());
}
#[test]
fn test_device_config_serde() {
let toml = r#"
address = "AA:BB:CC:DD:EE:FF"
alias = "Living Room"
poll_interval = 120
"#;
let config: DeviceConfig = toml::from_str(toml).unwrap();
assert_eq!(config.address, "AA:BB:CC:DD:EE:FF");
assert_eq!(config.alias, Some("Living Room".to_string()));
assert_eq!(config.poll_interval, 120);
}
#[test]
fn test_device_config_default_poll_interval() {
let toml = r#"address = "AA:BB:CC:DD:EE:FF""#;
let config: DeviceConfig = toml::from_str(toml).unwrap();
assert_eq!(config.poll_interval, 60);
assert_eq!(config.alias, None);
}
#[test]
fn test_config_save_and_load() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("test_config.toml");
let config = Config {
server: ServerConfig {
bind: "0.0.0.0:9090".to_string(),
..Default::default()
},
storage: StorageConfig {
path: PathBuf::from("/tmp/test.db"),
},
devices: vec![DeviceConfig {
address: "AA:BB:CC:DD:EE:FF".to_string(),
alias: Some("Test Device".to_string()),
poll_interval: 30,
}],
..Default::default()
};
config.save(&config_path).unwrap();
let loaded = Config::load(&config_path).unwrap();
assert_eq!(loaded.server.bind, "0.0.0.0:9090");
assert_eq!(loaded.storage.path, PathBuf::from("/tmp/test.db"));
assert_eq!(loaded.devices.len(), 1);
assert_eq!(loaded.devices[0].address, "AA:BB:CC:DD:EE:FF");
assert_eq!(loaded.devices[0].alias, Some("Test Device".to_string()));
assert_eq!(loaded.devices[0].poll_interval, 30);
}
#[test]
fn test_config_load_nonexistent() {
let result = Config::load("/nonexistent/path/config.toml");
assert!(matches!(result, Err(ConfigError::Read { .. })));
}
#[test]
fn test_config_load_invalid_toml() {
let temp_dir = tempfile::tempdir().unwrap();
let config_path = temp_dir.path().join("invalid.toml");
std::fs::write(&config_path, "this is not valid { toml").unwrap();
let result = Config::load(&config_path);
assert!(matches!(result, Err(ConfigError::Parse { .. })));
}
#[test]
fn test_config_full_toml() {
let toml = r#"
[server]
bind = "192.168.1.1:8888"
[storage]
path = "/data/aranet.db"
[[devices]]
address = "AA:BB:CC:DD:EE:FF"
alias = "Living Room"
poll_interval = 60
[[devices]]
address = "11:22:33:44:55:66"
poll_interval = 120
"#;
let config: Config = toml::from_str(toml).unwrap();
assert_eq!(config.server.bind, "192.168.1.1:8888");
assert_eq!(config.storage.path, PathBuf::from("/data/aranet.db"));
assert_eq!(config.devices.len(), 2);
assert_eq!(config.devices[0].alias, Some("Living Room".to_string()));
assert_eq!(config.devices[1].alias, None);
}
#[test]
fn test_default_config_path() {
let path = default_config_path();
assert!(path.ends_with("aranet/server.toml"));
}
#[test]
fn test_config_error_display() {
let error = ConfigError::Read {
path: PathBuf::from("/test/path"),
source: std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
};
let display = format!("{}", error);
assert!(display.contains("/test/path"));
assert!(display.contains("not found"));
}
#[test]
fn test_default_config_validates() {
let config = Config::default();
assert!(config.validate().is_ok());
}
#[test]
fn test_server_bind_validation() {
let valid = ServerConfig {
bind: "127.0.0.1:8080".to_string(),
..Default::default()
};
assert!(valid.validate().is_empty());
let valid_ipv6 = ServerConfig {
bind: "[::1]:8080".to_string(),
..Default::default()
};
assert!(valid_ipv6.validate().is_empty());
let valid_hostname = ServerConfig {
bind: "localhost:8080".to_string(),
..Default::default()
};
assert!(valid_hostname.validate().is_empty());
let empty = ServerConfig {
bind: "".to_string(),
..Default::default()
};
let errors = empty.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be empty"));
let no_port = ServerConfig {
bind: "127.0.0.1".to_string(),
..Default::default()
};
let errors = no_port.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("host:port"));
let port_zero = ServerConfig {
bind: "127.0.0.1:0".to_string(),
..Default::default()
};
let errors = port_zero.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be 0"));
let bad_port = ServerConfig {
bind: "127.0.0.1:abc".to_string(),
..Default::default()
};
let errors = bad_port.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("must be a number"));
let zero_buffer = ServerConfig {
broadcast_buffer: 0,
..Default::default()
};
let errors = zero_buffer.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].field.contains("broadcast_buffer"));
}
#[test]
fn test_storage_path_validation() {
let valid = StorageConfig {
path: PathBuf::from("/data/aranet.db"),
};
assert!(valid.validate().is_empty());
let empty = StorageConfig {
path: PathBuf::new(),
};
let errors = empty.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be empty"));
}
#[test]
fn test_device_config_validation() {
let valid = DeviceConfig {
address: "AA:BB:CC:DD:EE:FF".to_string(),
alias: Some("Living Room".to_string()),
poll_interval: 60,
};
assert!(valid.validate("devices[0]").is_empty());
let empty_addr = DeviceConfig {
address: "".to_string(),
alias: None,
poll_interval: 60,
};
let errors = empty_addr.validate("devices[0]");
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be empty"));
let short_addr = DeviceConfig {
address: "AB".to_string(),
alias: None,
poll_interval: 60,
};
let errors = short_addr.validate("devices[0]");
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("too short"));
let empty_alias = DeviceConfig {
address: "Aranet4 12345".to_string(),
alias: Some("".to_string()),
poll_interval: 60,
};
let errors = empty_alias.validate("devices[0]");
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be empty string"));
let short_poll = DeviceConfig {
address: "Aranet4 12345".to_string(),
alias: None,
poll_interval: 5,
};
let errors = short_poll.validate("devices[0]");
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("too short"));
let long_poll = DeviceConfig {
address: "Aranet4 12345".to_string(),
alias: None,
poll_interval: 7200,
};
let errors = long_poll.validate("devices[0]");
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("too long"));
}
#[test]
fn test_duplicate_device_addresses() {
let config = Config {
server: ServerConfig::default(),
storage: StorageConfig::default(),
devices: vec![
DeviceConfig {
address: "Aranet4 12345".to_string(),
alias: Some("Office".to_string()),
poll_interval: 60,
},
DeviceConfig {
address: "Aranet4 12345".to_string(), alias: Some("Bedroom".to_string()),
poll_interval: 60,
},
],
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
if let Err(ConfigError::Validation(errors)) = result {
assert!(errors.iter().any(|e| e.message.contains("duplicate")));
}
}
#[test]
fn test_duplicate_addresses_case_insensitive() {
let config = Config {
server: ServerConfig::default(),
storage: StorageConfig::default(),
devices: vec![
DeviceConfig {
address: "Aranet4 12345".to_string(),
alias: None,
poll_interval: 60,
},
DeviceConfig {
address: "ARANET4 12345".to_string(), alias: None,
poll_interval: 60,
},
],
..Default::default()
};
let result = config.validate();
assert!(result.is_err());
}
#[test]
fn test_validation_error_display() {
let error = ValidationError {
field: "server.bind".to_string(),
message: "invalid port".to_string(),
};
assert_eq!(format!("{}", error), "server.bind: invalid port");
}
#[test]
fn test_config_validation_error_display() {
let errors = vec![
ValidationError {
field: "server.bind".to_string(),
message: "port cannot be 0".to_string(),
},
ValidationError {
field: "devices[0].address".to_string(),
message: "cannot be empty".to_string(),
},
];
let error = ConfigError::Validation(errors);
let display = format!("{}", error);
assert!(display.contains("server.bind"));
assert!(display.contains("devices[0].address"));
}
#[test]
fn test_prometheus_config_default() {
let config = PrometheusConfig::default();
assert!(!config.enabled);
assert!(config.push_gateway.is_none());
assert_eq!(config.push_interval, 60);
}
#[test]
fn test_prometheus_config_validates() {
let config = PrometheusConfig::default();
assert!(config.validate().is_empty());
}
#[test]
fn test_prometheus_config_empty_push_gateway() {
let config = PrometheusConfig {
enabled: true,
push_gateway: Some("".to_string()),
push_interval: 60,
};
let errors = config.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("cannot be empty"));
}
#[test]
fn test_prometheus_config_short_push_interval() {
let config = PrometheusConfig {
enabled: true,
push_gateway: None,
push_interval: 5,
};
let errors = config.validate();
assert_eq!(errors.len(), 1);
assert!(errors[0].message.contains("too short"));
}
#[test]
fn test_prometheus_config_serde() {
let toml = r#"
enabled = true
push_gateway = "http://localhost:9091"
push_interval = 30
"#;
let config: PrometheusConfig = toml::from_str(toml).unwrap();
assert!(config.enabled);
assert_eq!(
config.push_gateway,
Some("http://localhost:9091".to_string())
);
assert_eq!(config.push_interval, 30);
}
#[test]
fn test_mqtt_config_default() {
let config = MqttConfig::default();
assert!(!config.enabled);
assert_eq!(config.broker, "mqtt://localhost:1883");
assert_eq!(config.topic_prefix, "aranet");
assert_eq!(config.client_id, "aranet-service");
assert_eq!(config.qos, 1);
assert!(!config.retain);
assert!(config.username.is_none());
assert!(config.password.is_none());
assert_eq!(config.keep_alive, 60);
}
#[test]
fn test_mqtt_config_validates_when_disabled() {
let config = MqttConfig::default();
assert!(config.validate().is_empty());
}
#[test]
fn test_mqtt_config_validates_when_enabled() {
let config = MqttConfig {
enabled: true,
..Default::default()
};
assert!(config.validate().is_empty());
}
#[test]
fn test_mqtt_config_empty_broker() {
let config = MqttConfig {
enabled: true,
broker: "".to_string(),
..Default::default()
};
let errors = config.validate();
assert!(
errors
.iter()
.any(|e| e.message.contains("broker URL cannot be empty"))
);
}
#[test]
fn test_mqtt_config_invalid_broker_scheme() {
let config = MqttConfig {
enabled: true,
broker: "http://localhost:1883".to_string(),
..Default::default()
};
let errors = config.validate();
assert!(errors.iter().any(|e| e.message.contains("mqtt://")));
}
#[test]
fn test_mqtt_config_empty_topic_prefix() {
let config = MqttConfig {
enabled: true,
topic_prefix: "".to_string(),
..Default::default()
};
let errors = config.validate();
assert!(
errors
.iter()
.any(|e| e.message.contains("topic prefix cannot be empty"))
);
}
#[test]
fn test_mqtt_config_empty_client_id() {
let config = MqttConfig {
enabled: true,
client_id: "".to_string(),
..Default::default()
};
let errors = config.validate();
assert!(
errors
.iter()
.any(|e| e.message.contains("client ID cannot be empty"))
);
}
#[test]
fn test_mqtt_config_invalid_qos() {
let config = MqttConfig {
enabled: true,
qos: 5,
..Default::default()
};
let errors = config.validate();
assert!(errors.iter().any(|e| e.message.contains("invalid QoS")));
}
#[test]
fn test_mqtt_config_short_keep_alive() {
let config = MqttConfig {
enabled: true,
keep_alive: 2,
..Default::default()
};
let errors = config.validate();
assert!(
errors
.iter()
.any(|e| e.message.contains("keep-alive interval"))
);
}
#[test]
fn test_mqtt_config_serde() {
let toml = r#"
enabled = true
broker = "mqtts://broker.example.com:8883"
topic_prefix = "home/sensors"
client_id = "my-service"
qos = 2
retain = true
username = "user"
password = "secret"
keep_alive = 30
"#;
let config: MqttConfig = toml::from_str(toml).unwrap();
assert!(config.enabled);
assert_eq!(config.broker, "mqtts://broker.example.com:8883");
assert_eq!(config.topic_prefix, "home/sensors");
assert_eq!(config.client_id, "my-service");
assert_eq!(config.qos, 2);
assert!(config.retain);
assert_eq!(config.username, Some("user".to_string()));
assert_eq!(config.password, Some("secret".to_string()));
assert_eq!(config.keep_alive, 30);
}
#[test]
fn test_influxdb_config_requires_org_when_enabled() {
let config = InfluxDbConfig {
enabled: true,
org: " ".to_string(),
..Default::default()
};
let errors = config.validate();
assert!(errors.iter().any(
|e| e.field == "influxdb.org" && e.message.contains("organization cannot be empty")
));
}
#[test]
fn test_influxdb_config_validates_when_enabled_with_org() {
let config = InfluxDbConfig {
enabled: true,
org: "aranet".to_string(),
..Default::default()
};
assert!(config.validate().is_empty());
}
#[test]
fn test_config_with_prometheus_and_mqtt() {
let toml = r#"
[server]
bind = "127.0.0.1:8080"
[prometheus]
enabled = true
[mqtt]
enabled = true
broker = "mqtt://localhost:1883"
topic_prefix = "aranet"
"#;
let config: Config = toml::from_str(toml).unwrap();
assert!(config.prometheus.enabled);
assert!(config.mqtt.enabled);
assert!(config.validate().is_ok());
}
}