1use anyhow::{Context, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23#[cfg(feature = "runtime")]
24use std::collections::HashSet;
25use std::path::Path;
26#[cfg(feature = "runtime")]
27use std::path::PathBuf;
28use tracing::{debug, info, trace, warn};
29use validator::Validate;
30
31use zentinel_common::{
32 errors::{ZentinelError, ZentinelResult},
33 limits::Limits,
34 types::Priority,
35};
36
37pub mod agents;
42mod defaults;
43pub mod filters;
44pub mod flatten;
45mod kdl;
46#[cfg(feature = "runtime")]
47pub mod multi_file;
48pub mod namespace;
49pub mod observability;
50pub mod resolution;
51pub mod routes;
52pub mod server;
53pub mod upstreams;
54#[cfg(feature = "validation")]
55pub mod validate;
56pub mod validation;
57pub mod waf;
58
59pub use agents::{
65 AgentConfig, AgentEvent, AgentPoolConfig, AgentTlsConfig, AgentTransport, AgentType,
66 BodyStreamingMode, LoadBalanceStrategy,
67};
68
69pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
71
72pub use filters::*;
74pub use filters::{Filter, FilterConfig, PathModifier, RedirectFilter, UrlRewriteFilter};
76
77#[cfg(feature = "runtime")]
79pub use multi_file::{ConfigDirectory, MultiFileLoader};
80
81pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
83
84pub use flatten::FlattenedConfig;
86
87pub use resolution::ResourceResolver;
89
90pub use observability::{
92 AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig, MetricsConfig,
93 ObservabilityConfig, TracingBackend, TracingConfig,
94};
95
96pub use routes::{
98 ApiSchemaConfig, BuiltinHandler, CacheBackend, CacheStorageConfig, ErrorFormat, ErrorPage,
99 ErrorPageConfig, FailureMode, FallbackConfig, FallbackTriggers, FallbackUpstream,
100 GuardrailAction, GuardrailFailureMode, GuardrailsConfig, HeaderModifications, InferenceConfig,
101 InferenceProvider, InferenceRouting, InferenceRoutingStrategy, MatchCondition,
102 ModelRoutingConfig, ModelUpstreamMapping, PiiAction, PiiDetectionConfig, PromptInjectionConfig,
103 RateLimitPolicy, RouteCacheConfig, RouteConfig, RoutePolicies, ServiceType, StaticFileConfig,
104 TokenEstimation, TokenRateLimit,
105};
106
107pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
109
110pub use zentinel_common::TraceIdFormat;
112
113pub use zentinel_common::budget::{
115 BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
116};
117
118pub use upstreams::{
120 ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
121 UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
122};
123
124pub use validation::ValidationContext;
126
127pub use waf::{
129 BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
130};
131
132pub use zentinel_common::types::LoadBalancingAlgorithm;
134
135pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
141
142pub const MIN_SCHEMA_VERSION: &str = "1.0";
144
145#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
147#[validate(schema(function = "validation::validate_config_semantics"))]
148pub struct Config {
149 #[serde(default = "default_schema_version")]
152 pub schema_version: String,
153
154 pub server: ServerConfig,
156
157 #[validate(length(min = 1, message = "At least one listener is required"))]
159 pub listeners: Vec<ListenerConfig>,
160
161 pub routes: Vec<RouteConfig>,
163
164 #[serde(default)]
166 pub upstreams: HashMap<String, UpstreamConfig>,
167
168 #[serde(default)]
170 pub filters: HashMap<String, FilterConfig>,
171
172 #[serde(default)]
174 pub agents: Vec<AgentConfig>,
175
176 #[serde(default)]
178 pub waf: Option<WafConfig>,
179
180 #[serde(default, skip_serializing_if = "Vec::is_empty")]
187 pub namespaces: Vec<NamespaceConfig>,
188
189 #[serde(default)]
191 pub limits: Limits,
192
193 #[serde(default)]
195 pub observability: ObservabilityConfig,
196
197 #[serde(default)]
199 pub rate_limits: GlobalRateLimitConfig,
200
201 #[serde(default)]
203 pub cache: Option<CacheStorageConfig>,
204
205 #[serde(skip)]
207 pub default_upstream: Option<UpstreamPeer>,
208}
209
210fn default_schema_version() -> String {
212 CURRENT_SCHEMA_VERSION.to_string()
213}
214
215#[derive(Debug, Clone, PartialEq, Eq)]
217pub enum SchemaCompatibility {
218 Exact,
220 Compatible,
222 Newer {
224 config_version: String,
225 max_supported: String,
226 },
227 Older {
229 config_version: String,
230 min_supported: String,
231 },
232 Invalid {
234 config_version: String,
235 reason: String,
236 },
237}
238
239impl SchemaCompatibility {
240 pub fn is_loadable(&self) -> bool {
242 matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
243 }
244
245 pub fn warning(&self) -> Option<String> {
247 match self {
248 Self::Newer { config_version, max_supported } => Some(format!(
249 "Config schema version {} is newer than supported version {}. Some features may not work.",
250 config_version, max_supported
251 )),
252 _ => None,
253 }
254 }
255
256 pub fn error(&self) -> Option<String> {
258 match self {
259 Self::Older { config_version, min_supported } => Some(format!(
260 "Config schema version {} is older than minimum supported version {}. Please update your configuration.",
261 config_version, min_supported
262 )),
263 Self::Invalid { config_version, reason } => Some(format!(
264 "Invalid schema version '{}': {}",
265 config_version, reason
266 )),
267 _ => None,
268 }
269 }
270}
271
272fn parse_version(version: &str) -> Option<(u32, u32)> {
274 let parts: Vec<&str> = version.trim().split('.').collect();
275 if parts.len() != 2 {
276 return None;
277 }
278 let major = parts[0].parse().ok()?;
279 let minor = parts[1].parse().ok()?;
280 Some((major, minor))
281}
282
283pub fn check_schema_compatibility(config_version: &str) -> SchemaCompatibility {
285 let config_ver = match parse_version(config_version) {
286 Some(v) => v,
287 None => {
288 return SchemaCompatibility::Invalid {
289 config_version: config_version.to_string(),
290 reason: "Expected format: major.minor (e.g., '1.0')".to_string(),
291 }
292 }
293 };
294
295 let current_ver = parse_version(CURRENT_SCHEMA_VERSION).unwrap();
296 let min_ver = parse_version(MIN_SCHEMA_VERSION).unwrap();
297
298 if config_ver < min_ver {
300 return SchemaCompatibility::Older {
301 config_version: config_version.to_string(),
302 min_supported: MIN_SCHEMA_VERSION.to_string(),
303 };
304 }
305
306 if config_ver > current_ver {
308 return SchemaCompatibility::Newer {
309 config_version: config_version.to_string(),
310 max_supported: CURRENT_SCHEMA_VERSION.to_string(),
311 };
312 }
313
314 if config_ver == current_ver {
316 return SchemaCompatibility::Exact;
317 }
318
319 SchemaCompatibility::Compatible
321}
322
323impl Config {
328 pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
330 let path = path.as_ref();
331
332 trace!(
333 path = %path.display(),
334 "Loading configuration from file"
335 );
336
337 let content = std::fs::read_to_string(path)
338 .with_context(|| format!("Failed to read config file: {:?}", path))?;
339
340 let extension = path
341 .extension()
342 .and_then(|ext| ext.to_str())
343 .unwrap_or("kdl");
344
345 debug!(
346 path = %path.display(),
347 format = extension,
348 content_length = content.len(),
349 "Read configuration file"
350 );
351
352 let config = match extension {
353 "kdl" => {
354 let expanded = Self::expand_kdl_includes(&content, path)?;
355 Self::from_kdl(&expanded)
356 }
357 "json" => Self::from_json(&content),
358 "toml" => Self::from_toml(&content),
359 _ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
360 }?;
361
362 info!(
363 path = %path.display(),
364 routes = config.routes.len(),
365 upstreams = config.upstreams.len(),
366 agents = config.agents.len(),
367 listeners = config.listeners.len(),
368 "Configuration loaded successfully"
369 );
370
371 Ok(config)
372 }
373
374 #[cfg(feature = "runtime")]
381 fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
382 let doc: ::kdl::KdlDocument = content
384 .parse()
385 .map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
386
387 let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
388 if !has_includes {
389 return Ok(content.to_string());
390 }
391
392 let mut visited = HashSet::new();
393 Self::expand_includes_recursive(content, source_path, &mut visited)
394 }
395
396 #[cfg(not(feature = "runtime"))]
398 fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
399 let doc: ::kdl::KdlDocument = content
401 .parse()
402 .map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
403
404 let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
405 if has_includes {
406 return Err(anyhow::anyhow!(
407 "The 'include' directive in '{}' requires the 'runtime' feature.\n\
408 Build with: cargo build --features runtime",
409 source_path.display()
410 ));
411 }
412
413 Ok(content.to_string())
414 }
415
416 #[cfg(feature = "runtime")]
422 fn expand_includes_recursive(
423 content: &str,
424 source_path: &Path,
425 visited: &mut HashSet<PathBuf>,
426 ) -> Result<String> {
427 let canonical = source_path
428 .canonicalize()
429 .with_context(|| format!("Failed to resolve config path: {}", source_path.display()))?;
430
431 if !visited.insert(canonical.clone()) {
432 return Err(anyhow::anyhow!(
433 "Circular include detected: '{}' has already been included",
434 source_path.display()
435 ));
436 }
437
438 let base_dir = canonical.parent().ok_or_else(|| {
439 anyhow::anyhow!(
440 "Config file has no parent directory: {}",
441 source_path.display()
442 )
443 })?;
444
445 let doc: ::kdl::KdlDocument = content.parse().map_err(|e| {
446 anyhow::anyhow!("KDL parse error in '{}': {}", source_path.display(), e)
447 })?;
448
449 let mut output = String::new();
450
451 for node in doc.nodes() {
452 if node.name().value() == "include" {
453 let pattern = node
454 .entries()
455 .iter()
456 .find_map(|e| {
457 if e.name().is_none() {
458 e.value().as_string().map(|s| s.to_string())
459 } else {
460 None
461 }
462 })
463 .ok_or_else(|| {
464 anyhow::anyhow!(
465 "The 'include' directive requires a string argument, e.g.: include \"routes/*.kdl\"\n\
466 Found in: {}",
467 source_path.display()
468 )
469 })?;
470
471 let full_pattern = base_dir.join(&pattern);
473 let pattern_str = full_pattern.to_str().ok_or_else(|| {
474 anyhow::anyhow!("Include pattern contains invalid UTF-8: {:?}", full_pattern)
475 })?;
476
477 let mut matched_any = false;
478 let mut paths: Vec<PathBuf> = Vec::new();
479
480 for entry in glob::glob(pattern_str).with_context(|| {
481 format!(
482 "Invalid glob pattern '{}' in {}",
483 pattern,
484 source_path.display()
485 )
486 })? {
487 let path = entry.with_context(|| {
488 format!(
489 "Error reading glob match for '{}' in {}",
490 pattern,
491 source_path.display()
492 )
493 })?;
494 paths.push(path);
495 }
496
497 paths.sort();
499
500 for path in paths {
501 matched_any = true;
502 debug!(
503 include = %path.display(),
504 from = %source_path.display(),
505 "Including config file"
506 );
507
508 let included_content = std::fs::read_to_string(&path).with_context(|| {
509 format!(
510 "Failed to read included config file '{}' (included from '{}')",
511 path.display(),
512 source_path.display()
513 )
514 })?;
515
516 let expanded =
517 Self::expand_includes_recursive(&included_content, &path, visited)?;
518 output.push_str(&expanded);
519 output.push('\n');
520 }
521
522 if !matched_any {
523 warn!(
524 pattern = %pattern,
525 source = %source_path.display(),
526 "Include pattern matched no files"
527 );
528 }
529 } else {
530 output.push_str(&node.to_string());
532 output.push('\n');
533 }
534 }
535
536 Ok(output)
537 }
538
539 pub fn default_embedded() -> Result<Self> {
545 trace!("Loading embedded default configuration");
546
547 Self::from_kdl(DEFAULT_CONFIG_KDL).or_else(|e| {
548 warn!(
549 error = %e,
550 "Failed to parse embedded KDL config, using programmatic default"
551 );
552 Ok(create_default_config())
553 })
554 }
555
556 pub fn from_kdl(content: &str) -> Result<Self> {
558 trace!(content_length = content.len(), "Parsing KDL configuration");
559 let doc: ::kdl::KdlDocument = content.parse().map_err(|e: ::kdl::KdlError| {
560 use miette::Diagnostic;
561
562 let mut error_msg = String::new();
563 error_msg.push_str("KDL configuration parse error:\n\n");
564
565 let mut found_details = false;
566 if let Some(related) = e.related() {
567 for diagnostic in related {
568 let diag_str = format!("{}", diagnostic);
569 error_msg.push_str(&format!(" {}\n", diag_str));
570 found_details = true;
571
572 if let Some(labels) = diagnostic.labels() {
573 for label in labels {
574 let offset = label.offset();
575 let (line, col) = kdl::offset_to_line_col(content, offset);
576 error_msg
577 .push_str(&format!("\n --> at line {}, column {}\n", line, col));
578
579 let lines: Vec<&str> = content.lines().collect();
580
581 if line > 1 {
582 if let Some(lc) = lines.get(line.saturating_sub(2)) {
583 error_msg.push_str(&format!("{:>4} | {}\n", line - 1, lc));
584 }
585 }
586
587 if let Some(line_content) = lines.get(line.saturating_sub(1)) {
588 error_msg.push_str(&format!("{:>4} | {}\n", line, line_content));
589 error_msg.push_str(&format!(
590 " | {}^",
591 " ".repeat(col.saturating_sub(1))
592 ));
593 if let Some(label_msg) = label.label() {
594 error_msg.push_str(&format!(" {}", label_msg));
595 }
596 error_msg.push('\n');
597 }
598
599 if let Some(lc) = lines.get(line) {
600 error_msg.push_str(&format!("{:>4} | {}\n", line + 1, lc));
601 }
602 }
603 }
604
605 if let Some(help) = diagnostic.help() {
606 error_msg.push_str(&format!("\n Help: {}\n", help));
607 }
608 }
609 }
610
611 if !found_details {
612 error_msg.push_str(&format!(" {}\n", e));
613 error_msg.push_str("\n Note: Check your KDL syntax. Common issues:\n");
614 error_msg.push_str(" - Unclosed strings (missing closing quote)\n");
615 error_msg.push_str(" - Unclosed blocks (missing closing brace)\n");
616 error_msg.push_str(" - Invalid node names or values\n");
617 }
618
619 if let Some(help) = e.help() {
620 error_msg.push_str(&format!("\n Help: {}\n", help));
621 }
622
623 anyhow::anyhow!("{}", error_msg)
624 })?;
625
626 kdl::parse_kdl_document(doc)
627 }
628
629 pub fn from_json(content: &str) -> Result<Self> {
631 trace!(content_length = content.len(), "Parsing JSON configuration");
632 serde_json::from_str(content).context("Failed to parse JSON configuration")
633 }
634
635 pub fn from_toml(content: &str) -> Result<Self> {
637 trace!(content_length = content.len(), "Parsing TOML configuration");
638 toml::from_str(content).context("Failed to parse TOML configuration")
639 }
640
641 pub fn check_schema_version(&self) -> SchemaCompatibility {
643 check_schema_compatibility(&self.schema_version)
644 }
645
646 pub fn validate(&self) -> ZentinelResult<()> {
648 trace!(
649 routes = self.routes.len(),
650 upstreams = self.upstreams.len(),
651 agents = self.agents.len(),
652 schema_version = %self.schema_version,
653 "Starting configuration validation"
654 );
655
656 let compat = self.check_schema_version();
658 if let Some(warning) = compat.warning() {
659 warn!("{}", warning);
660 }
661 if !compat.is_loadable() {
662 return Err(ZentinelError::Config {
663 message: compat
664 .error()
665 .unwrap_or_else(|| "Unknown schema version error".to_string()),
666 source: None,
667 });
668 }
669 trace!(
670 schema_version = %self.schema_version,
671 compatibility = ?compat,
672 "Schema version check passed"
673 );
674
675 Validate::validate(self).map_err(|e| ZentinelError::Config {
676 message: format!("Configuration validation failed: {}", e),
677 source: None,
678 })?;
679
680 trace!("Schema validation passed");
681
682 self.validate_routes()?;
683 trace!("Route validation passed");
684
685 self.validate_upstreams()?;
686 trace!("Upstream validation passed");
687
688 self.validate_agents()?;
689 trace!("Agent validation passed");
690
691 self.limits.validate()?;
692 trace!("Limits validation passed");
693
694 debug!(
695 routes = self.routes.len(),
696 upstreams = self.upstreams.len(),
697 agents = self.agents.len(),
698 "Configuration validation successful"
699 );
700
701 Ok(())
702 }
703
704 fn validate_routes(&self) -> ZentinelResult<()> {
705 for route in &self.routes {
706 if let Some(upstream) = &route.upstream {
707 if !self.upstreams.contains_key(upstream) {
708 return Err(ZentinelError::Config {
709 message: format!(
710 "Route '{}' references non-existent upstream '{}'",
711 route.id, upstream
712 ),
713 source: None,
714 });
715 }
716 }
717
718 for filter_id in &route.filters {
719 if !self.filters.contains_key(filter_id) {
720 return Err(ZentinelError::Config {
721 message: format!(
722 "Route '{}' references non-existent filter '{}'",
723 route.id, filter_id
724 ),
725 source: None,
726 });
727 }
728 }
729 }
730
731 for (filter_id, filter_config) in &self.filters {
732 if let Filter::Agent(agent_filter) = &filter_config.filter {
733 if !self.agents.iter().any(|a| a.id == agent_filter.agent) {
734 return Err(ZentinelError::Config {
735 message: format!(
736 "Filter '{}' references non-existent agent '{}'",
737 filter_id, agent_filter.agent
738 ),
739 source: None,
740 });
741 }
742 }
743 }
744
745 Ok(())
746 }
747
748 fn validate_upstreams(&self) -> ZentinelResult<()> {
749 for (id, upstream) in &self.upstreams {
750 if upstream.targets.is_empty() {
751 return Err(ZentinelError::Config {
752 message: format!("Upstream '{}' has no targets", id),
753 source: None,
754 });
755 }
756 }
757 Ok(())
758 }
759
760 fn validate_agents(&self) -> ZentinelResult<()> {
761 for agent in &self.agents {
762 if agent.timeout_ms == 0 {
763 return Err(ZentinelError::Config {
764 message: format!("Agent '{}' has invalid timeout", agent.id),
765 source: None,
766 });
767 }
768
769 if let AgentTransport::UnixSocket { path } = &agent.transport {
770 if !path.exists() && !path.parent().is_some_and(|p| p.exists()) {
771 return Err(ZentinelError::Config {
772 message: format!(
773 "Agent '{}' unix socket path parent directory doesn't exist: {:?}",
774 agent.id, path
775 ),
776 source: None,
777 });
778 }
779 }
780 }
781 Ok(())
782 }
783
784 pub fn default_for_testing() -> Self {
786 use zentinel_common::types::LoadBalancingAlgorithm;
787
788 let mut upstreams = HashMap::new();
789 upstreams.insert(
790 "default".to_string(),
791 UpstreamConfig {
792 id: "default".to_string(),
793 targets: vec![UpstreamTarget {
794 address: "127.0.0.1:8081".to_string(),
795 weight: 1,
796 max_requests: None,
797 metadata: HashMap::new(),
798 }],
799 load_balancing: LoadBalancingAlgorithm::RoundRobin,
800 sticky_session: None,
801 health_check: None,
802 connection_pool: ConnectionPoolConfig::default(),
803 timeouts: UpstreamTimeouts::default(),
804 tls: None,
805 http_version: HttpVersionConfig::default(),
806 },
807 );
808
809 Self {
810 schema_version: CURRENT_SCHEMA_VERSION.to_string(),
811 server: ServerConfig {
812 worker_threads: 4,
813 max_connections: 1000,
814 graceful_shutdown_timeout_secs: 30,
815 daemon: false,
816 pid_file: None,
817 user: None,
818 group: None,
819 working_directory: None,
820 trace_id_format: Default::default(),
821 auto_reload: false,
822 },
823 listeners: vec![ListenerConfig {
824 id: "http".to_string(),
825 address: "0.0.0.0:8080".to_string(),
826 protocol: ListenerProtocol::Http,
827 tls: None,
828 default_route: Some("default".to_string()),
829 request_timeout_secs: 60,
830 keepalive_timeout_secs: 75,
831 max_concurrent_streams: 100,
832 keepalive_max_requests: None,
833 }],
834 routes: vec![RouteConfig {
835 id: "default".to_string(),
836 priority: Priority::NORMAL,
837 matches: vec![MatchCondition::PathPrefix("/".to_string())],
838 upstream: Some("default".to_string()),
839 service_type: ServiceType::Web,
840 policies: RoutePolicies::default(),
841 filters: vec![],
842 builtin_handler: None,
843 waf_enabled: false,
844 circuit_breaker: None,
845 retry_policy: None,
846 static_files: None,
847 api_schema: None,
848 inference: None,
849 error_pages: None,
850 websocket: false,
851 websocket_inspection: false,
852 shadow: None,
853 fallback: None,
854 }],
855 upstreams,
856 filters: HashMap::new(),
857 agents: vec![],
858 waf: None,
859 namespaces: vec![],
860 limits: Limits::for_testing(),
861 observability: ObservabilityConfig::default(),
862 rate_limits: GlobalRateLimitConfig::default(),
863 cache: None,
864 default_upstream: Some(UpstreamPeer {
865 address: "127.0.0.1:8081".to_string(),
866 tls: false,
867 host: "localhost".to_string(),
868 connect_timeout_secs: 10,
869 read_timeout_secs: 30,
870 write_timeout_secs: 30,
871 }),
872 }
873 }
874
875 pub fn reload(&mut self, path: impl AsRef<Path>) -> ZentinelResult<()> {
877 let path = path.as_ref();
878 debug!(
879 path = %path.display(),
880 "Reloading configuration"
881 );
882
883 let new_config = Self::from_file(path).map_err(|e| ZentinelError::Config {
884 message: format!("Failed to reload configuration: {}", e),
885 source: None,
886 })?;
887
888 new_config.validate()?;
889
890 info!(
891 path = %path.display(),
892 routes = new_config.routes.len(),
893 upstreams = new_config.upstreams.len(),
894 "Configuration reloaded successfully"
895 );
896
897 *self = new_config;
898 Ok(())
899 }
900
901 pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
903 self.routes.iter().find(|r| r.id == id)
904 }
905
906 pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
908 self.upstreams.get(id)
909 }
910
911 pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
913 self.agents.iter().find(|a| a.id == id)
914 }
915}
916
917#[cfg(test)]
922mod tests {
923 use super::*;
924
925 #[test]
926 fn test_parse_version() {
927 assert_eq!(parse_version("1.0"), Some((1, 0)));
928 assert_eq!(parse_version("2.5"), Some((2, 5)));
929 assert_eq!(parse_version("10.20"), Some((10, 20)));
930 assert_eq!(parse_version("1"), None);
931 assert_eq!(parse_version("1.0.0"), None);
932 assert_eq!(parse_version("abc"), None);
933 assert_eq!(parse_version(""), None);
934 }
935
936 #[test]
937 fn test_schema_compatibility_exact() {
938 let compat = check_schema_compatibility(CURRENT_SCHEMA_VERSION);
939 assert_eq!(compat, SchemaCompatibility::Exact);
940 assert!(compat.is_loadable());
941 assert!(compat.warning().is_none());
942 assert!(compat.error().is_none());
943 }
944
945 #[test]
946 fn test_schema_compatibility_newer() {
947 let compat = check_schema_compatibility("99.0");
948 assert!(matches!(compat, SchemaCompatibility::Newer { .. }));
949 assert!(compat.is_loadable()); assert!(compat.warning().is_some());
951 assert!(compat.error().is_none());
952 }
953
954 #[test]
955 fn test_schema_compatibility_older() {
956 let compat = check_schema_compatibility("0.5");
958 assert!(matches!(compat, SchemaCompatibility::Older { .. }));
959 assert!(!compat.is_loadable());
960 assert!(compat.warning().is_none());
961 assert!(compat.error().is_some());
962 }
963
964 #[test]
965 fn test_schema_compatibility_invalid() {
966 let compat = check_schema_compatibility("not-a-version");
967 assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
968 assert!(!compat.is_loadable());
969 assert!(compat.error().is_some());
970
971 let compat = check_schema_compatibility("1.0.0");
972 assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
973 }
974
975 #[test]
976 fn test_default_schema_version() {
977 let config = Config::default_for_testing();
978 assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
979 }
980
981 #[test]
982 fn test_kdl_with_schema_version() {
983 let kdl = r#"
984 schema-version "1.0"
985 server {
986 worker-threads 4
987 }
988 listeners {
989 listener "http" {
990 address "0.0.0.0:8080"
991 protocol "http"
992 }
993 }
994 "#;
995 let config = Config::from_kdl(kdl).unwrap();
996 assert_eq!(config.schema_version, "1.0");
997 }
998
999 #[test]
1000 fn test_kdl_without_schema_version_uses_default() {
1001 let kdl = r#"
1002 server {
1003 worker-threads 4
1004 }
1005 listeners {
1006 listener "http" {
1007 address "0.0.0.0:8080"
1008 protocol "http"
1009 }
1010 }
1011 "#;
1012 let config = Config::from_kdl(kdl).unwrap();
1013 assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
1014 }
1015
1016 #[test]
1017 fn test_from_kdl_rejects_include_directive() {
1018 let kdl = r#"
1019 include "routes/*.kdl"
1020 system {
1021 worker-threads 4
1022 }
1023 listeners {
1024 listener "http" {
1025 address "0.0.0.0:8080"
1026 protocol "http"
1027 }
1028 }
1029 "#;
1030 let err = Config::from_kdl(kdl).unwrap_err();
1031 assert!(
1032 err.to_string()
1033 .contains("not supported when parsing raw KDL strings"),
1034 "Expected helpful include error, got: {}",
1035 err
1036 );
1037 }
1038
1039 #[test]
1040 fn test_from_file_with_include() {
1041 let dir = tempfile::tempdir().unwrap();
1042
1043 let routes_dir = dir.path().join("routes");
1045 std::fs::create_dir(&routes_dir).unwrap();
1046 std::fs::write(
1047 routes_dir.join("api.kdl"),
1048 r#"
1049routes {
1050 route "api" {
1051 match {
1052 path-prefix "/api"
1053 }
1054 upstream "backend"
1055 }
1056}
1057"#,
1058 )
1059 .unwrap();
1060
1061 let main_config = dir.path().join("zentinel.kdl");
1063 std::fs::write(
1064 &main_config,
1065 r#"
1066schema-version "1.0"
1067system {
1068 worker-threads 4
1069}
1070listeners {
1071 listener "http" {
1072 address "0.0.0.0:8080"
1073 protocol "http"
1074 }
1075}
1076upstreams {
1077 upstream "backend" {
1078 target "127.0.0.1:9000"
1079 }
1080}
1081include "routes/*.kdl"
1082"#,
1083 )
1084 .unwrap();
1085
1086 let config = Config::from_file(&main_config).unwrap();
1087 assert_eq!(config.routes.len(), 1);
1088 assert_eq!(config.routes[0].id, "api");
1089 }
1090
1091 #[test]
1092 fn test_from_file_with_no_includes_still_works() {
1093 let dir = tempfile::tempdir().unwrap();
1094 let config_path = dir.path().join("zentinel.kdl");
1095 std::fs::write(
1096 &config_path,
1097 r#"
1098schema-version "1.0"
1099system {
1100 worker-threads 4
1101}
1102listeners {
1103 listener "http" {
1104 address "0.0.0.0:8080"
1105 protocol "http"
1106 }
1107}
1108"#,
1109 )
1110 .unwrap();
1111
1112 let config = Config::from_file(&config_path).unwrap();
1113 assert_eq!(config.listeners.len(), 1);
1114 }
1115
1116 #[test]
1117 fn test_include_circular_detection() {
1118 let dir = tempfile::tempdir().unwrap();
1119
1120 let a_path = dir.path().join("a.kdl");
1121 let b_path = dir.path().join("b.kdl");
1122
1123 std::fs::write(&a_path, format!("include \"{}\"", b_path.display())).unwrap();
1124 std::fs::write(&b_path, format!("include \"{}\"", a_path.display())).unwrap();
1125
1126 let err = Config::from_file(&a_path).unwrap_err();
1127 assert!(
1128 err.to_string().contains("Circular include detected"),
1129 "Expected circular include error, got: {}",
1130 err
1131 );
1132 }
1133
1134 #[test]
1135 fn test_include_no_match_warns_but_succeeds() {
1136 let dir = tempfile::tempdir().unwrap();
1137 let config_path = dir.path().join("zentinel.kdl");
1138 std::fs::write(
1139 &config_path,
1140 r#"
1141system {
1142 worker-threads 4
1143}
1144listeners {
1145 listener "http" {
1146 address "0.0.0.0:8080"
1147 protocol "http"
1148 }
1149}
1150include "nonexistent/*.kdl"
1151"#,
1152 )
1153 .unwrap();
1154
1155 let config = Config::from_file(&config_path).unwrap();
1157 assert_eq!(config.listeners.len(), 1);
1158 }
1159}