use std::collections::HashSet;
use std::path::Path;
use serde::{Deserialize, Serialize};
use crate::collector::http::HttpConfig;
use crate::collector::ping::PingConfig;
use crate::collector::tcp::TcpConfig;
use crate::storage::{CollectorRecord, CollectorType};
use super::validation::ConfigError;
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct CollectorsConfig {
#[serde(default)]
pub tcp: Vec<TcpConfig>,
#[serde(default)]
pub ping: Vec<PingConfig>,
#[serde(default)]
pub http: Vec<HttpConfig>,
}
impl CollectorsConfig {
#[must_use]
pub fn merge(mut self, other: CollectorsConfig) -> Self {
self.tcp.extend(other.tcp);
self.ping.extend(other.ping);
self.http.extend(other.http);
self
}
pub fn validate(&self) -> Result<(), ConfigError> {
let mut seen_names = HashSet::new();
for tcp in &self.tcp {
if tcp.name.is_empty() {
return Err(ConfigError::ValidationError(
"tcp collector name cannot be empty".to_string(),
));
}
if !seen_names.insert(&tcp.name) {
return Err(ConfigError::ValidationError(format!(
"duplicate collector name: '{}'",
tcp.name
)));
}
tcp.validate().map_err(|e| {
ConfigError::ValidationError(format!("tcp collector '{}': {}", tcp.name, e))
})?;
}
for ping in &self.ping {
if ping.name.is_empty() {
return Err(ConfigError::ValidationError(
"ping collector name cannot be empty".to_string(),
));
}
if !seen_names.insert(&ping.name) {
return Err(ConfigError::ValidationError(format!(
"duplicate collector name: '{}'",
ping.name
)));
}
}
for http in &self.http {
if http.name.is_empty() {
return Err(ConfigError::ValidationError(
"http collector name cannot be empty".to_string(),
));
}
if !seen_names.insert(&http.name) {
return Err(ConfigError::ValidationError(format!(
"duplicate collector name: '{}'",
http.name
)));
}
url::Url::parse(&http.url).map_err(|e| {
ConfigError::ValidationError(format!(
"http collector '{}': invalid URL '{}': {}",
http.name, http.url, e
))
})?;
if http.interval.is_some() && http.cron.is_some() {
return Err(ConfigError::ValidationError(format!(
"http collector '{}': cannot specify both interval and cron",
http.name
)));
}
}
Ok(())
}
pub fn load_from_dir(dir_path: &str) -> Result<Self, ConfigError> {
let dir = Path::new(dir_path);
if !dir.exists() {
return Err(ConfigError::ValidationError(format!(
"collector include path '{}' does not exist",
dir_path
)));
}
if !dir.is_dir() {
return Err(ConfigError::ValidationError(format!(
"collector include path '{}' is not a directory",
dir_path
)));
}
let mut merged = Self::default();
let entries = std::fs::read_dir(dir)?;
for entry in entries {
let entry = entry?;
let path = entry.path();
if !path.is_file() {
continue;
}
let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
if ext != "yaml" && ext != "yml" {
continue;
}
tracing::debug!("Loading collector config from: {}", path.display());
let content = std::fs::read_to_string(&path)?;
let file_config: Self = serde_yaml::from_str(&content).map_err(|e| {
ConfigError::ValidationError(format!("failed to parse '{}': {}", path.display(), e))
})?;
merged = merged.merge(file_config);
}
Ok(merged)
}
pub fn to_collector_records(&self) -> Vec<CollectorRecord> {
let mut records = Vec::new();
for tcp in &self.tcp {
let config_json = serde_json::to_value(tcp).unwrap_or_default();
records.push(CollectorRecord::from_config(
CollectorType::Tcp,
&tcp.name,
tcp.enabled,
&tcp.group,
config_json,
));
}
for ping in &self.ping {
let config_json = serde_json::to_value(ping).unwrap_or_default();
records.push(CollectorRecord::from_config(
CollectorType::Ping,
&ping.name,
ping.enabled,
&ping.group,
config_json,
));
}
for http in &self.http {
let config_json = serde_json::to_value(http).unwrap_or_default();
records.push(CollectorRecord::from_config(
CollectorType::Http,
&http.name,
http.enabled,
&http.group,
config_json,
));
}
records
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_collectors_config_merge() {
let config1 = CollectorsConfig {
tcp: vec![TcpConfig::new("tcp-1", "127.0.0.1", 6379)],
ping: vec![],
http: vec![],
};
let config2 = CollectorsConfig {
tcp: vec![TcpConfig::new("tcp-2", "127.0.0.1", 6380)],
ping: vec![PingConfig::new("ping-1", "8.8.8.8")],
http: vec![],
};
let merged = config1.merge(config2);
assert_eq!(merged.tcp.len(), 2);
assert_eq!(merged.ping.len(), 1);
}
#[test]
fn test_collectors_config_validate_duplicate_names() {
let config = CollectorsConfig {
tcp: vec![
TcpConfig::new("duplicate", "127.0.0.1", 6379),
TcpConfig::new("duplicate", "127.0.0.1", 6380),
],
ping: vec![],
http: vec![],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("duplicate"));
}
#[test]
fn test_collectors_config_validate_cross_type_duplicate() {
let config = CollectorsConfig {
tcp: vec![TcpConfig::new("same-name", "127.0.0.1", 6379)],
ping: vec![PingConfig::new("same-name", "8.8.8.8")],
http: vec![],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("duplicate"));
}
#[test]
fn test_collectors_config_validate_empty_name() {
let mut tcp = TcpConfig::new("", "127.0.0.1", 6379);
tcp.name = "".to_string();
let config = CollectorsConfig {
tcp: vec![tcp],
ping: vec![],
http: vec![],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("cannot be empty"));
}
#[test]
fn test_collectors_config_validate_invalid_http_url() {
use crate::collector::http::HttpConfig;
let mut http = HttpConfig::new("test", "https://valid.url");
http.url = "not-a-valid-url".to_string();
let config = CollectorsConfig {
tcp: vec![],
ping: vec![],
http: vec![http],
};
let result = config.validate();
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("invalid URL"));
}
#[test]
fn test_collectors_config_validate_http_interval_cron_conflict() {
use crate::collector::http::HttpConfig;
use std::time::Duration;
let mut http = HttpConfig::new("test", "https://valid.url");
http.interval = Some(Duration::from_secs(30));
http.cron = Some("0 * * * * *".to_string());
let config = CollectorsConfig {
tcp: vec![],
ping: vec![],
http: vec![http],
};
let result = config.validate();
assert!(result.is_err());
assert!(
result
.unwrap_err()
.to_string()
.contains("cannot specify both interval and cron")
);
}
#[test]
fn test_tcp_config_serde_roundtrip() {
let yaml = r#"
name: test-tcp
host: 127.0.0.1
port: 6379
enabled: false
group: production
interval: 10s
timeout: 2s
tags:
env: test
description: Test TCP probe
"#;
let config: TcpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "test-tcp");
assert_eq!(config.host, "127.0.0.1");
assert_eq!(config.port, 6379);
assert!(!config.enabled);
assert_eq!(config.group, "production");
assert_eq!(config.interval.as_secs(), 10);
assert_eq!(config.timeout.as_secs(), 2);
assert_eq!(config.tags.get("env"), Some(&"test".to_string()));
assert_eq!(config.description, Some("Test TCP probe".to_string()));
}
#[test]
fn test_tcp_config_serde_defaults() {
let yaml = r#"
name: minimal
host: 127.0.0.1
port: 80
"#;
let config: TcpConfig = serde_yaml::from_str(yaml).unwrap();
assert!(config.enabled); assert_eq!(config.group, "default"); assert_eq!(config.interval.as_secs(), 30); assert_eq!(config.timeout.as_secs(), 3); }
#[test]
fn test_http_config_serde_roundtrip() {
use crate::collector::http::HttpConfig;
let yaml = r#"
name: test-http
url: https://api.example.com/health
enabled: true
group: production
method: POST
expected_status: 201
interval: 60s
timeout: 15s
headers:
Authorization: Bearer token
"#;
let config: HttpConfig = serde_yaml::from_str(yaml).unwrap();
assert_eq!(config.name, "test-http");
assert_eq!(config.url, "https://api.example.com/health");
assert!(config.enabled);
assert_eq!(config.group, "production");
assert_eq!(config.expected_status, 201);
assert_eq!(config.interval, Some(std::time::Duration::from_secs(60)));
assert_eq!(config.timeout.as_secs(), 15);
assert_eq!(
config.headers.get("Authorization"),
Some(&"Bearer token".to_string())
);
}
#[test]
fn test_to_collector_records() {
let config = CollectorsConfig {
tcp: vec![TcpConfig::new("tcp-1", "127.0.0.1", 6379)],
ping: vec![PingConfig::new("ping-1", "8.8.8.8")],
http: vec![],
};
let records = config.to_collector_records();
assert_eq!(records.len(), 2);
assert_eq!(records[0].name, "tcp-1");
assert_eq!(records[0].collector_type, CollectorType::Tcp);
assert_eq!(records[1].name, "ping-1");
assert_eq!(records[1].collector_type, CollectorType::Ping);
}
}