use serde::{Deserialize, Serialize};
use std::fs;
use std::path::Path;
use std::str::FromStr;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("TOML parse error: {0}")]
Toml(#[from] toml::de::Error),
#[error("TOML serialize error: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("Validation error: {0}")]
Validation(String),
#[error("Config not found: {0}")]
NotFound(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[derive(Default)]
pub struct ZenithConfig {
pub server: ServerConfig,
pub runtime: RuntimeConfig,
pub cache: CacheConfig,
pub security: SecurityConfig,
pub observability: ObservabilityConfig,
}
impl ZenithConfig {
pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
let content = fs::read_to_string(path).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
ConfigError::NotFound(path.display().to_string())
} else {
ConfigError::Io(e)
}
})?;
let config: ZenithConfig = toml::from_str(&content)?;
config.validate()?;
Ok(config)
}
pub fn to_toml(&self) -> Result<String, ConfigError> {
toml::to_string_pretty(self).map_err(ConfigError::from)
}
pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
let content = self.to_toml()?;
fs::write(path, content)?;
Ok(())
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.server.validate()?;
self.runtime.validate()?;
self.cache.validate()?;
self.security.validate()?;
self.observability.validate()?;
Ok(())
}
}
impl FromStr for ZenithConfig {
type Err = ConfigError;
fn from_str(s: &str) -> Result<Self, ConfigError> {
let config: ZenithConfig = toml::from_str(s)?;
config.validate()?;
Ok(config)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServerConfig {
pub listen_addr: String,
pub max_connections: u32,
pub connection_timeout_ms: u64,
pub keepalive_interval_ms: u64,
pub metrics_auth_token: Option<String>,
}
impl Default for ServerConfig {
fn default() -> Self {
Self {
listen_addr: "0.0.0.0:8080".to_string(),
max_connections: 65536,
connection_timeout_ms: 5000,
keepalive_interval_ms: 30000,
metrics_auth_token: None,
}
}
}
impl ServerConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.max_connections == 0 {
return Err(ConfigError::Validation("max_connections must be > 0".into()));
}
if self.connection_timeout_ms == 0 {
return Err(ConfigError::Validation("connection_timeout_ms must be > 0".into()));
}
if self.keepalive_interval_ms == 0 {
return Err(ConfigError::Validation("keepalive_interval_ms must be > 0".into()));
}
Ok(())
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub struct RuntimeConfig {
pub worker_threads: usize,
pub io_threads: usize,
pub time_threads: usize,
pub blocking_threads: usize,
pub enable_ebpf: bool,
pub enable_xsk: bool,
pub numa_aware: bool,
}
impl Default for RuntimeConfig {
fn default() -> Self {
Self {
worker_threads: 4,
io_threads: 2,
time_threads: 1,
blocking_threads: 4,
enable_ebpf: true,
enable_xsk: true,
numa_aware: true,
}
}
}
impl RuntimeConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.worker_threads == 0 {
return Err(ConfigError::Validation("worker_threads must be > 0".into()));
}
if self.io_threads == 0 {
return Err(ConfigError::Validation("io_threads must be > 0".into()));
}
if self.time_threads == 0 {
return Err(ConfigError::Validation("time_threads must be > 0".into()));
}
if self.blocking_threads == 0 {
return Err(ConfigError::Validation("blocking_threads must be > 0".into()));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CacheConfig {
pub shard_count: usize,
pub per_shard_capacity: usize,
pub max_entry_size: usize,
pub eviction_policy: String,
pub ttl_seconds: u64,
}
impl Default for CacheConfig {
fn default() -> Self {
Self {
shard_count: 16,
per_shard_capacity: 65536,
max_entry_size: 1048576,
eviction_policy: "lru".to_string(),
ttl_seconds: 3600,
}
}
}
impl CacheConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.shard_count == 0 || !self.shard_count.is_power_of_two() {
return Err(ConfigError::Validation("shard_count must be a power of 2".into()));
}
if self.per_shard_capacity == 0 {
return Err(ConfigError::Validation("per_shard_capacity must be > 0".into()));
}
if self.max_entry_size == 0 {
return Err(ConfigError::Validation("max_entry_size must be > 0".into()));
}
if !matches!(self.eviction_policy.as_str(), "lru" | "fifo" | "random") {
return Err(ConfigError::Validation(
"eviction_policy must be one of \"lru\" | \"fifo\" | \"random\"".into(),
));
}
if self.ttl_seconds == 0 {
return Err(ConfigError::Validation("ttl_seconds must be > 0".into()));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SecurityConfig {
pub enable_tls: bool,
pub cert_path: Option<String>,
pub key_path: Option<String>,
pub enable_constant_time: bool,
pub enable_ebpf_integrity: bool,
pub max_token_lifetime_ms: u64,
}
impl Default for SecurityConfig {
fn default() -> Self {
Self {
enable_tls: false,
cert_path: None,
key_path: None,
enable_constant_time: true,
enable_ebpf_integrity: true,
max_token_lifetime_ms: 3600000,
}
}
}
impl SecurityConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
if self.enable_tls
&& (self.cert_path.is_none() || self.key_path.is_none()) {
return Err(ConfigError::Validation("TLS requires cert_path and key_path".into()));
}
if self.max_token_lifetime_ms == 0 {
return Err(ConfigError::Validation("max_token_lifetime_ms must be > 0".into()));
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ObservabilityConfig {
pub enable_metrics: bool,
pub metrics_addr: String,
pub enable_tracing: bool,
pub tracing_level: String,
pub enable_logging: bool,
pub log_level: String,
}
impl Default for ObservabilityConfig {
fn default() -> Self {
Self {
enable_metrics: true,
metrics_addr: "0.0.0.0:9090".to_string(),
enable_tracing: false,
tracing_level: "info".to_string(),
enable_logging: true,
log_level: "info".to_string(),
}
}
}
impl ObservabilityConfig {
pub fn validate(&self) -> Result<(), ConfigError> {
const LEVELS: [&str; 5] = ["trace", "debug", "info", "warn", "error"];
if !LEVELS.contains(&self.log_level.as_str()) {
return Err(ConfigError::Validation(
"log_level must be one of \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\""
.into(),
));
}
if !LEVELS.contains(&self.tracing_level.as_str()) {
return Err(ConfigError::Validation(
"tracing_level must be one of \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\""
.into(),
));
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_default_config() {
let config = ZenithConfig::default();
assert!(config.validate().is_ok());
assert_eq!(config.server.listen_addr, "0.0.0.0:8080");
assert!(config.runtime.enable_ebpf);
}
#[test]
fn test_metrics_auth_token_default_none() {
let config = ZenithConfig::default();
assert!(config.server.metrics_auth_token.is_none());
}
#[test]
fn test_metrics_auth_token_from_toml() {
let toml_str = r#"
[server]
listen_addr = "0.0.0.0:8080"
max_connections = 100
connection_timeout_ms = 5000
keepalive_interval_ms = 30000
metrics_auth_token = "s3cr3t-t0ken"
[runtime]
worker_threads = 4
io_threads = 2
time_threads = 1
blocking_threads = 4
enable_ebpf = false
enable_xsk = false
numa_aware = false
[cache]
shard_count = 16
per_shard_capacity = 4096
max_entry_size = 1048576
eviction_policy = "lru"
ttl_seconds = 300
[security]
enable_tls = false
enable_constant_time = true
enable_ebpf_integrity = true
max_token_lifetime_ms = 3600000
[observability]
enable_metrics = true
metrics_addr = "0.0.0.0:9091"
enable_tracing = false
tracing_level = "info"
enable_logging = true
log_level = "info"
"#;
let config = ZenithConfig::from_str(toml_str).unwrap();
assert_eq!(config.server.metrics_auth_token.as_deref(), Some("s3cr3t-t0ken"));
}
#[test]
fn test_config_roundtrip() {
let config = ZenithConfig::default();
let toml_str = config.to_toml().unwrap();
let parsed = ZenithConfig::from_str(&toml_str).unwrap();
assert_eq!(parsed.server.listen_addr, config.server.listen_addr);
assert_eq!(parsed.runtime.worker_threads, config.runtime.worker_threads);
}
#[test]
fn test_config_validation() {
let mut config = ZenithConfig::default();
config.server.max_connections = 0;
assert!(config.validate().is_err());
}
#[test]
fn test_config_tls_validation() {
let mut config = ZenithConfig::default();
config.security.enable_tls = true;
assert!(config.validate().is_err());
config.security.cert_path = Some("/path/to/cert".to_string());
config.security.key_path = Some("/path/to/key".to_string());
assert!(config.validate().is_ok());
}
#[test]
fn test_config_cache_validation() {
let mut config = ZenithConfig::default();
config.cache.shard_count = 3;
assert!(config.validate().is_err());
config.cache.shard_count = 16;
assert!(config.validate().is_ok());
}
#[test]
fn test_config_custom_values() {
let toml_str = r#"
[server]
listen_addr = "127.0.0.1:9090"
max_connections = 10000
connection_timeout_ms = 3000
keepalive_interval_ms = 15000
[runtime]
worker_threads = 8
io_threads = 4
time_threads = 2
blocking_threads = 8
enable_ebpf = true
enable_xsk = true
numa_aware = true
[cache]
shard_count = 32
per_shard_capacity = 131072
max_entry_size = 2097152
eviction_policy = "fifo"
ttl_seconds = 7200
[security]
enable_tls = false
enable_constant_time = true
enable_ebpf_integrity = true
max_token_lifetime_ms = 7200000
[observability]
enable_metrics = true
metrics_addr = "0.0.0.0:8081"
enable_tracing = true
tracing_level = "debug"
enable_logging = true
log_level = "debug"
"#;
let config = ZenithConfig::from_str(toml_str).unwrap();
assert_eq!(config.server.listen_addr, "127.0.0.1:9090");
assert_eq!(config.runtime.worker_threads, 8);
assert_eq!(config.cache.shard_count, 32);
assert_eq!(config.observability.metrics_addr, "0.0.0.0:8081");
}
#[test]
fn test_from_file_not_found_maps_to_not_found() {
let path = Path::new("zzz_zenith_definitely_missing_config_x7q9.toml");
let result = ZenithConfig::from_file(path);
match result {
Err(ConfigError::NotFound(msg)) => {
assert!(msg.contains("zzz_zenith_definitely_missing_config_x7q9.toml"));
}
other => panic!("expected ConfigError::NotFound, got {other:?}"),
}
let config = ZenithConfig::default();
assert!(config.validate().is_ok());
}
fn default_cache() -> CacheConfig {
CacheConfig::default()
}
#[test]
fn test_cache_validate_eviction_policy_whitelist() {
for policy in ["lru", "fifo", "random"] {
let mut cache = default_cache();
cache.eviction_policy = policy.to_string();
assert!(cache.validate().is_ok(), "policy {policy} should pass");
}
for policy in ["lfu", "LRU", "", "clock"] {
let mut cache = default_cache();
cache.eviction_policy = policy.to_string();
assert!(
matches!(cache.validate(), Err(ConfigError::Validation(_))),
"policy {policy} should be rejected"
);
}
}
#[test]
fn test_cache_validate_max_entry_size_positive() {
let mut cache = default_cache();
cache.max_entry_size = 0;
assert!(matches!(cache.validate(), Err(ConfigError::Validation(_))));
let mut cache = default_cache();
cache.max_entry_size = 1;
assert!(cache.validate().is_ok());
}
#[test]
fn test_observability_validate_level_whitelist() {
for level in ["trace", "debug", "info", "warn", "error"] {
let obs = ObservabilityConfig {
log_level: level.to_string(),
tracing_level: level.to_string(),
..Default::default()
};
assert!(obs.validate().is_ok(), "level {level} should pass");
}
let obs = ObservabilityConfig {
log_level: "verbose".to_string(),
..Default::default()
};
assert!(matches!(obs.validate(), Err(ConfigError::Validation(_))));
let obs = ObservabilityConfig {
tracing_level: "TRACE".to_string(),
..Default::default()
};
assert!(matches!(obs.validate(), Err(ConfigError::Validation(_))));
let mut config = ZenithConfig::default();
config.observability.log_level = "nope".to_string();
assert!(matches!(config.validate(), Err(ConfigError::Validation(_))));
}
}