use crate::config::types::Config;
use anyhow::Context;
use serde_yaml;
use std::fs;
use std::path::Path;
pub struct ConfigLoader;
impl ConfigLoader {
pub fn from_file<P: AsRef<Path>>(path: P) -> anyhow::Result<Config> {
let content = fs::read_to_string(&path)
.with_context(|| format!("Failed to read config file: {}", path.as_ref().display()))?;
Self::parse_str(&content)
}
pub fn parse_str(content: &str) -> anyhow::Result<Config> {
let config: Config =
serde_yaml::from_str(content).with_context(|| "Failed to parse YAML configuration")?;
Self::validate(&config)?;
Ok(config)
}
fn validate(config: &Config) -> anyhow::Result<()> {
if config.server.port == 0 {
anyhow::bail!("Server port cannot be 0");
}
if config.server.workers == 0 {
anyhow::bail!("Number of workers cannot be 0");
}
if config.telemetry.sampling_rate < 0.0 || config.telemetry.sampling_rate > 1.0 {
anyhow::bail!("Sampling rate must be between 0.0 and 1.0");
}
if config.telemetry.enabled {
Self::validate_telemetry_config(&config.telemetry)?;
}
for endpoint in &config.endpoints {
Self::validate_endpoint(endpoint)?;
}
Ok(())
}
fn validate_telemetry_config(
config: &crate::config::types::TelemetryConfig,
) -> anyhow::Result<()> {
if config.endpoint.is_empty() {
anyhow::bail!("Telemetry endpoint cannot be empty");
}
if let Ok(url) = reqwest::Url::parse(&config.endpoint) {
if url.scheme().is_empty() {
anyhow::bail!("Telemetry endpoint must have a scheme (http:// or https://)");
}
let scheme = url.scheme();
if scheme != "http" && scheme != "https" {
anyhow::bail!("Telemetry endpoint must use http:// or https:// scheme");
}
if url.host().is_none() {
anyhow::bail!("Telemetry endpoint must have a host");
}
} else {
anyhow::bail!(
"Invalid telemetry endpoint URL format: {endpoint}",
endpoint = config.endpoint
);
}
let protocol = config.protocol.to_lowercase();
if protocol != "http" && protocol != "grpc" {
anyhow::bail!(
"Telemetry protocol must be 'http' or 'grpc', got '{protocol}'",
protocol = config.protocol
);
}
if config.timeout_seconds == 0 {
anyhow::bail!("Telemetry timeout must be greater than 0");
}
if config.export_batch_size == 0 {
anyhow::bail!("Telemetry export batch size must be greater than 0");
}
if config.export_timeout_millis == 0 {
anyhow::bail!("Telemetry export timeout must be greater than 0");
}
Ok(())
}
fn validate_endpoint(endpoint: &crate::config::types::Endpoint) -> anyhow::Result<()> {
if endpoint.name.is_empty() {
anyhow::bail!("Endpoint name cannot be empty");
}
if endpoint.method.is_empty() {
anyhow::bail!("Endpoint method cannot be empty");
}
if endpoint.path.is_empty() {
anyhow::bail!("Endpoint path cannot be empty");
}
if endpoint.responses.is_empty() {
anyhow::bail!("Endpoint must have at least one response");
}
if endpoint.responses.iter().filter(|r| r.default).count() > 1 {
anyhow::bail!("Endpoint can have at most one default response");
}
for response in &endpoint.responses {
Self::validate_response(response)?;
}
Ok(())
}
fn validate_response(response: &crate::config::types::Response) -> anyhow::Result<()> {
if response.status < 100 || response.status >= 600 {
anyhow::bail!(
"Invalid HTTP status code: {status}",
status = response.status
);
}
if let Some(probability) = response.probability {
if !(0.0..=1.0).contains(&probability) {
anyhow::bail!("Probability must be between 0.0 and 1.0");
}
}
if let Some(delay) = &response.delay {
if let Err(e) = delay.parse_duration() {
anyhow::bail!("Invalid delay format: {e}");
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_load_valid_config() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
service_name: "test"
logging:
level: "info"
endpoints:
- name: "Test"
method: GET
path: "/test"
responses:
- status: 200
body: "OK"
"#;
let config = ConfigLoader::parse_str(config_str).unwrap();
assert_eq!(config.server.port, 8080);
assert_eq!(config.endpoints.len(), 1);
assert_eq!(config.endpoints[0].name, "Test");
}
#[test]
fn test_invalid_port() {
let config_str = r#"
server:
port: 0
workers: 4
telemetry:
enabled: true
logging:
level: "info"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result.unwrap_err().to_string().contains("port cannot be 0"));
}
#[test]
fn test_invalid_sampling_rate() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
sampling_rate: 1.5
logging:
level: "info"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Sampling rate must be between"));
}
#[test]
fn test_empty_endpoint_name() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
logging:
level: "info"
endpoints:
- name: ""
method: GET
path: "/test"
responses:
- status: 200
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Endpoint name cannot be empty"));
}
#[test]
fn test_multiple_default_responses() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
logging:
level: "info"
endpoints:
- name: "Test"
method: GET
path: "/test"
responses:
- status: 200
default: true
- status: 404
default: true
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("at most one default response"));
}
#[test]
fn test_invalid_delay_format() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
logging:
level: "info"
endpoints:
- name: "Test"
method: GET
path: "/test"
responses:
- status: 200
delay: "invalid"
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid delay format"));
}
#[test]
fn test_invalid_telemetry_endpoint() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
endpoint: "not-a-valid-url"
protocol: "http"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Invalid telemetry endpoint URL format"));
}
#[test]
fn test_invalid_telemetry_protocol() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
endpoint: "http://localhost:4318"
protocol: "invalid-protocol"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("Telemetry protocol must be 'http' or 'grpc'"));
}
#[test]
fn test_valid_telemetry_config() {
let config_str = r#"
server:
port: 8080
workers: 4
telemetry:
enabled: true
endpoint: "http://localhost:4318"
protocol: "http"
sampling_rate: 0.5
timeout_seconds: 30
export_batch_size: 512
export_timeout_millis: 30000
endpoints: []
"#;
let config = ConfigLoader::parse_str(config_str).unwrap();
assert!(config.telemetry.enabled);
assert_eq!(config.telemetry.endpoint, "http://localhost:4318");
assert_eq!(config.telemetry.protocol, "http");
assert_eq!(config.telemetry.sampling_rate, 0.5);
}
#[test]
fn test_from_file_not_found() {
let result = ConfigLoader::from_file("non-existent-file.yaml");
assert!(result.is_err());
}
#[test]
fn test_invalid_workers() {
let config_str = r#"
server:
port: 8080
workers: 0
telemetry:
enabled: false
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("workers cannot be 0"));
}
#[test]
fn test_empty_telemetry_endpoint() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
endpoint: ""
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("endpoint cannot be empty"));
}
#[test]
fn test_invalid_telemetry_scheme() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
endpoint: "ftp://localhost:4318"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("must use http:// or https:// scheme"));
}
#[test]
fn test_telemetry_invalid_host() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
endpoint: "http://"
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
}
#[test]
fn test_invalid_telemetry_timeout() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
timeout_seconds: 0
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("timeout must be greater than 0"));
}
#[test]
fn test_invalid_telemetry_batch_size() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
export_batch_size: 0
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("export batch size must be greater than 0"));
}
#[test]
fn test_invalid_telemetry_export_timeout() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: true
export_timeout_millis: 0
endpoints: []
"#;
let result = ConfigLoader::parse_str(config_str);
assert!(result.is_err());
assert!(result
.unwrap_err()
.to_string()
.contains("export timeout must be greater than 0"));
}
#[test]
fn test_direct_telemetry_validation() {
let config_base = crate::config::types::TelemetryConfig {
enabled: true,
..Default::default()
};
let mut config = config_base.clone();
config.timeout_seconds = 0;
assert!(ConfigLoader::validate_telemetry_config(&config).is_err());
config = config_base.clone();
config.export_batch_size = 0;
assert!(ConfigLoader::validate_telemetry_config(&config).is_err());
config = config_base.clone();
config.export_timeout_millis = 0;
assert!(ConfigLoader::validate_telemetry_config(&config).is_err());
}
#[test]
fn test_invalid_endpoint_fields() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: false
endpoints:
- name: "test"
method: ""
path: "/test"
responses: [{status: 200}]
"#;
assert!(ConfigLoader::parse_str(config_str).is_err());
let config_str = r#"
server:
port: 8080
telemetry:
enabled: false
endpoints:
- name: "test"
method: "GET"
path: ""
responses: [{status: 200}]
"#;
assert!(ConfigLoader::parse_str(config_str).is_err());
let config_str = r#"
server:
port: 8080
telemetry:
enabled: false
endpoints:
- name: "test"
method: "GET"
path: "/test"
responses: []
"#;
assert!(ConfigLoader::parse_str(config_str).is_err());
}
#[test]
fn test_invalid_response_fields() {
let config_str = r#"
server:
port: 8080
telemetry:
enabled: false
endpoints:
- name: "test"
method: "GET"
path: "/test"
responses: [{status: 99}]
"#;
assert!(ConfigLoader::parse_str(config_str).is_err());
let config_str = r#"
server:
port: 8080
telemetry:
enabled: false
endpoints:
- name: "test"
method: "GET"
path: "/test"
responses: [{status: 200, probability: 1.1}]
"#;
assert!(ConfigLoader::parse_str(config_str).is_err());
}
}