1use serde::{Deserialize, Serialize};
10use std::fs;
11use std::path::Path;
12use std::str::FromStr;
13use thiserror::Error;
14
15#[derive(Debug, Error)]
17pub enum ConfigError {
18 #[error("IO error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("TOML parse error: {0}")]
24 Toml(#[from] toml::de::Error),
25
26 #[error("TOML serialize error: {0}")]
28 Serialize(#[from] toml::ser::Error),
29
30 #[error("Validation error: {0}")]
32 Validation(String),
33
34 #[error("Config not found: {0}")]
36 NotFound(String),
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
41#[derive(Default)]
42pub struct ZenithConfig {
43 pub server: ServerConfig,
45 pub runtime: RuntimeConfig,
47 pub cache: CacheConfig,
49 pub security: SecurityConfig,
51 pub observability: ObservabilityConfig,
53}
54
55
56impl ZenithConfig {
57 pub fn from_file(path: &Path) -> Result<Self, ConfigError> {
65 let content = fs::read_to_string(path).map_err(|e| {
67 if e.kind() == std::io::ErrorKind::NotFound {
68 ConfigError::NotFound(path.display().to_string())
69 } else {
70 ConfigError::Io(e)
71 }
72 })?;
73 let config: ZenithConfig = toml::from_str(&content)?;
74 config.validate()?;
75 Ok(config)
76 }
77
78 pub fn to_toml(&self) -> Result<String, ConfigError> {
83 toml::to_string_pretty(self).map_err(ConfigError::from)
84 }
85
86 pub fn save_to_file(&self, path: &Path) -> Result<(), ConfigError> {
91 let content = self.to_toml()?;
92 fs::write(path, content)?;
93 Ok(())
94 }
95
96 pub fn validate(&self) -> Result<(), ConfigError> {
101 self.server.validate()?;
102 self.runtime.validate()?;
103 self.cache.validate()?;
104 self.security.validate()?;
105 self.observability.validate()?;
106 Ok(())
107 }
108}
109
110impl FromStr for ZenithConfig {
112 type Err = ConfigError;
113
114 fn from_str(s: &str) -> Result<Self, ConfigError> {
115 let config: ZenithConfig = toml::from_str(s)?;
116 config.validate()?;
117 Ok(config)
118 }
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct ServerConfig {
124 pub listen_addr: String,
126 pub max_connections: u32,
128 pub connection_timeout_ms: u64,
130 pub keepalive_interval_ms: u64,
132 pub metrics_auth_token: Option<String>,
138}
139
140impl Default for ServerConfig {
141 fn default() -> Self {
142 Self {
143 listen_addr: "0.0.0.0:8080".to_string(),
144 max_connections: 65536,
145 connection_timeout_ms: 5000,
146 keepalive_interval_ms: 30000,
147 metrics_auth_token: None,
148 }
149 }
150}
151
152impl ServerConfig {
153 pub fn validate(&self) -> Result<(), ConfigError> {
158 if self.max_connections == 0 {
159 return Err(ConfigError::Validation("max_connections must be > 0".into()));
160 }
161 if self.connection_timeout_ms == 0 {
162 return Err(ConfigError::Validation("connection_timeout_ms must be > 0".into()));
163 }
164 if self.keepalive_interval_ms == 0 {
165 return Err(ConfigError::Validation("keepalive_interval_ms must be > 0".into()));
166 }
167 Ok(())
168 }
169}
170
171#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
176pub struct RuntimeConfig {
177 pub worker_threads: usize,
179 pub io_threads: usize,
181 pub time_threads: usize,
183 pub blocking_threads: usize,
185 pub enable_ebpf: bool,
187 pub enable_xsk: bool,
189 pub numa_aware: bool,
191}
192
193impl Default for RuntimeConfig {
194 fn default() -> Self {
195 Self {
196 worker_threads: 4,
197 io_threads: 2,
198 time_threads: 1,
199 blocking_threads: 4,
200 enable_ebpf: true,
201 enable_xsk: true,
202 numa_aware: true,
203 }
204 }
205}
206
207impl RuntimeConfig {
208 pub fn validate(&self) -> Result<(), ConfigError> {
213 if self.worker_threads == 0 {
214 return Err(ConfigError::Validation("worker_threads must be > 0".into()));
215 }
216 if self.io_threads == 0 {
217 return Err(ConfigError::Validation("io_threads must be > 0".into()));
218 }
219 if self.time_threads == 0 {
220 return Err(ConfigError::Validation("time_threads must be > 0".into()));
221 }
222 if self.blocking_threads == 0 {
223 return Err(ConfigError::Validation("blocking_threads must be > 0".into()));
224 }
225 Ok(())
226 }
227}
228
229#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct CacheConfig {
232 pub shard_count: usize,
234 pub per_shard_capacity: usize,
236 pub max_entry_size: usize,
238 pub eviction_policy: String,
240 pub ttl_seconds: u64,
242}
243
244impl Default for CacheConfig {
245 fn default() -> Self {
246 Self {
247 shard_count: 16,
248 per_shard_capacity: 65536,
249 max_entry_size: 1048576,
250 eviction_policy: "lru".to_string(),
251 ttl_seconds: 3600,
252 }
253 }
254}
255
256impl CacheConfig {
257 pub fn validate(&self) -> Result<(), ConfigError> {
267 if self.shard_count == 0 || !self.shard_count.is_power_of_two() {
268 return Err(ConfigError::Validation("shard_count must be a power of 2".into()));
269 }
270 if self.per_shard_capacity == 0 {
271 return Err(ConfigError::Validation("per_shard_capacity must be > 0".into()));
272 }
273 if self.max_entry_size == 0 {
274 return Err(ConfigError::Validation("max_entry_size must be > 0".into()));
275 }
276 if !matches!(self.eviction_policy.as_str(), "lru" | "fifo" | "random") {
278 return Err(ConfigError::Validation(
279 "eviction_policy must be one of \"lru\" | \"fifo\" | \"random\"".into(),
280 ));
281 }
282 if self.ttl_seconds == 0 {
283 return Err(ConfigError::Validation("ttl_seconds must be > 0".into()));
284 }
285 Ok(())
286 }
287}
288
289#[derive(Debug, Clone, Serialize, Deserialize)]
291pub struct SecurityConfig {
292 pub enable_tls: bool,
294 pub cert_path: Option<String>,
296 pub key_path: Option<String>,
298 pub enable_constant_time: bool,
300 pub enable_ebpf_integrity: bool,
302 pub max_token_lifetime_ms: u64,
304}
305
306impl Default for SecurityConfig {
307 fn default() -> Self {
308 Self {
309 enable_tls: false,
310 cert_path: None,
311 key_path: None,
312 enable_constant_time: true,
313 enable_ebpf_integrity: true,
314 max_token_lifetime_ms: 3600000,
315 }
316 }
317}
318
319impl SecurityConfig {
320 pub fn validate(&self) -> Result<(), ConfigError> {
326 if self.enable_tls
327 && (self.cert_path.is_none() || self.key_path.is_none()) {
328 return Err(ConfigError::Validation("TLS requires cert_path and key_path".into()));
329 }
330 if self.max_token_lifetime_ms == 0 {
331 return Err(ConfigError::Validation("max_token_lifetime_ms must be > 0".into()));
332 }
333 Ok(())
334 }
335}
336
337#[derive(Debug, Clone, Serialize, Deserialize)]
339pub struct ObservabilityConfig {
340 pub enable_metrics: bool,
342 pub metrics_addr: String,
344 pub enable_tracing: bool,
346 pub tracing_level: String,
348 pub enable_logging: bool,
350 pub log_level: String,
352}
353
354impl Default for ObservabilityConfig {
355 fn default() -> Self {
356 Self {
357 enable_metrics: true,
358 metrics_addr: "0.0.0.0:9090".to_string(),
359 enable_tracing: false,
360 tracing_level: "info".to_string(),
361 enable_logging: true,
362 log_level: "info".to_string(),
363 }
364 }
365}
366
367impl ObservabilityConfig {
368 pub fn validate(&self) -> Result<(), ConfigError> {
375 const LEVELS: [&str; 5] = ["trace", "debug", "info", "warn", "error"];
377 if !LEVELS.contains(&self.log_level.as_str()) {
378 return Err(ConfigError::Validation(
379 "log_level must be one of \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\""
380 .into(),
381 ));
382 }
383 if !LEVELS.contains(&self.tracing_level.as_str()) {
384 return Err(ConfigError::Validation(
385 "tracing_level must be one of \"trace\" | \"debug\" | \"info\" | \"warn\" | \"error\""
386 .into(),
387 ));
388 }
389 Ok(())
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_default_config() {
399 let config = ZenithConfig::default();
400 assert!(config.validate().is_ok());
401 assert_eq!(config.server.listen_addr, "0.0.0.0:8080");
402 assert!(config.runtime.enable_ebpf);
403 }
404
405 #[test]
406 fn test_metrics_auth_token_default_none() {
407 let config = ZenithConfig::default();
408 assert!(config.server.metrics_auth_token.is_none());
409 }
410
411 #[test]
412 fn test_metrics_auth_token_from_toml() {
413 let toml_str = r#"
414[server]
415listen_addr = "0.0.0.0:8080"
416max_connections = 100
417connection_timeout_ms = 5000
418keepalive_interval_ms = 30000
419metrics_auth_token = "s3cr3t-t0ken"
420
421[runtime]
422worker_threads = 4
423io_threads = 2
424time_threads = 1
425blocking_threads = 4
426enable_ebpf = false
427enable_xsk = false
428numa_aware = false
429
430[cache]
431shard_count = 16
432per_shard_capacity = 4096
433max_entry_size = 1048576
434eviction_policy = "lru"
435ttl_seconds = 300
436
437[security]
438enable_tls = false
439enable_constant_time = true
440enable_ebpf_integrity = true
441max_token_lifetime_ms = 3600000
442
443[observability]
444enable_metrics = true
445metrics_addr = "0.0.0.0:9091"
446enable_tracing = false
447tracing_level = "info"
448enable_logging = true
449log_level = "info"
450"#;
451 let config = ZenithConfig::from_str(toml_str).unwrap();
452 assert_eq!(config.server.metrics_auth_token.as_deref(), Some("s3cr3t-t0ken"));
453 }
454
455 #[test]
456 fn test_config_roundtrip() {
457 let config = ZenithConfig::default();
458 let toml_str = config.to_toml().unwrap();
459 let parsed = ZenithConfig::from_str(&toml_str).unwrap();
460 assert_eq!(parsed.server.listen_addr, config.server.listen_addr);
461 assert_eq!(parsed.runtime.worker_threads, config.runtime.worker_threads);
462 }
463
464 #[test]
465 fn test_config_validation() {
466 let mut config = ZenithConfig::default();
467 config.server.max_connections = 0;
468 assert!(config.validate().is_err());
469 }
470
471 #[test]
472 fn test_config_tls_validation() {
473 let mut config = ZenithConfig::default();
474 config.security.enable_tls = true;
475 assert!(config.validate().is_err());
476
477 config.security.cert_path = Some("/path/to/cert".to_string());
478 config.security.key_path = Some("/path/to/key".to_string());
479 assert!(config.validate().is_ok());
480 }
481
482 #[test]
483 fn test_config_cache_validation() {
484 let mut config = ZenithConfig::default();
485 config.cache.shard_count = 3;
486 assert!(config.validate().is_err());
487
488 config.cache.shard_count = 16;
489 assert!(config.validate().is_ok());
490 }
491
492 #[test]
493 fn test_config_custom_values() {
494 let toml_str = r#"
495[server]
496listen_addr = "127.0.0.1:9090"
497max_connections = 10000
498connection_timeout_ms = 3000
499keepalive_interval_ms = 15000
500
501[runtime]
502worker_threads = 8
503io_threads = 4
504time_threads = 2
505blocking_threads = 8
506enable_ebpf = true
507enable_xsk = true
508numa_aware = true
509
510[cache]
511shard_count = 32
512per_shard_capacity = 131072
513max_entry_size = 2097152
514eviction_policy = "fifo"
515ttl_seconds = 7200
516
517[security]
518enable_tls = false
519enable_constant_time = true
520enable_ebpf_integrity = true
521max_token_lifetime_ms = 7200000
522
523[observability]
524enable_metrics = true
525metrics_addr = "0.0.0.0:8081"
526enable_tracing = true
527tracing_level = "debug"
528enable_logging = true
529log_level = "debug"
530"#;
531 let config = ZenithConfig::from_str(toml_str).unwrap();
532 assert_eq!(config.server.listen_addr, "127.0.0.1:9090");
533 assert_eq!(config.runtime.worker_threads, 8);
534 assert_eq!(config.cache.shard_count, 32);
535 assert_eq!(config.observability.metrics_addr, "0.0.0.0:8081");
536 }
537
538 #[test]
539 fn test_from_file_not_found_maps_to_not_found() {
540 let path = Path::new("zzz_zenith_definitely_missing_config_x7q9.toml");
542 let result = ZenithConfig::from_file(path);
543 match result {
544 Err(ConfigError::NotFound(msg)) => {
545 assert!(msg.contains("zzz_zenith_definitely_missing_config_x7q9.toml"));
546 }
547 other => panic!("expected ConfigError::NotFound, got {other:?}"),
548 }
549 let config = ZenithConfig::default();
551 assert!(config.validate().is_ok());
552 }
553
554 fn default_cache() -> CacheConfig {
555 CacheConfig::default()
556 }
557
558 #[test]
559 fn test_cache_validate_eviction_policy_whitelist() {
560 for policy in ["lru", "fifo", "random"] {
562 let mut cache = default_cache();
563 cache.eviction_policy = policy.to_string();
564 assert!(cache.validate().is_ok(), "policy {policy} should pass");
565 }
566 for policy in ["lfu", "LRU", "", "clock"] {
568 let mut cache = default_cache();
569 cache.eviction_policy = policy.to_string();
570 assert!(
571 matches!(cache.validate(), Err(ConfigError::Validation(_))),
572 "policy {policy} should be rejected"
573 );
574 }
575 }
576
577 #[test]
578 fn test_cache_validate_max_entry_size_positive() {
579 let mut cache = default_cache();
580 cache.max_entry_size = 0;
581 assert!(matches!(cache.validate(), Err(ConfigError::Validation(_))));
582
583 let mut cache = default_cache();
584 cache.max_entry_size = 1;
585 assert!(cache.validate().is_ok());
586 }
587
588 #[test]
589 fn test_observability_validate_level_whitelist() {
590 for level in ["trace", "debug", "info", "warn", "error"] {
592 let obs = ObservabilityConfig {
593 log_level: level.to_string(),
594 tracing_level: level.to_string(),
595 ..Default::default()
596 };
597 assert!(obs.validate().is_ok(), "level {level} should pass");
598 }
599 let obs = ObservabilityConfig {
601 log_level: "verbose".to_string(),
602 ..Default::default()
603 };
604 assert!(matches!(obs.validate(), Err(ConfigError::Validation(_))));
605 let obs = ObservabilityConfig {
607 tracing_level: "TRACE".to_string(),
608 ..Default::default()
609 };
610 assert!(matches!(obs.validate(), Err(ConfigError::Validation(_))));
611 let mut config = ZenithConfig::default();
613 config.observability.log_level = "nope".to_string();
614 assert!(matches!(config.validate(), Err(ConfigError::Validation(_))));
615 }
616}