sentinel_config/
lib.rs

1//! Configuration module for Sentinel proxy
2//!
3//! This module provides configuration parsing, validation, and hot-reload support
4//! with a focus on safety, security-first defaults, and operational clarity.
5//!
6//! # Module Organization
7//!
8//! - [`server`]: Server and listener configuration
9//! - [`routes`]: Route configuration and match conditions
10//! - [`upstreams`]: Upstream backend configuration
11//! - [`agents`]: External processing agent configuration
12//! - [`waf`]: WAF (Web Application Firewall) configuration
13//! - [`observability`]: Metrics, logging, and tracing configuration
14//! - [`filters`]: Filter types for request/response processing
15//! - [`validation`]: Configuration validation functions
16//! - [`kdl`]: KDL format parsing
17//! - [`defaults`]: Default embedded configuration
18//! - [`multi_file`]: Multi-file configuration loading
19
20use anyhow::{Context, Result};
21use serde::{Deserialize, Serialize};
22use std::collections::HashMap;
23use std::path::Path;
24use tracing::{debug, info, trace, warn};
25use validator::Validate;
26
27use sentinel_common::{
28    errors::{SentinelError, SentinelResult},
29    limits::Limits,
30    types::Priority,
31};
32
33// ============================================================================
34// Module Declarations
35// ============================================================================
36
37pub mod agents;
38mod defaults;
39pub mod filters;
40pub mod flatten;
41mod kdl;
42pub mod multi_file;
43pub mod namespace;
44pub mod observability;
45pub mod resolution;
46pub mod routes;
47pub mod server;
48pub mod upstreams;
49pub mod validate;
50pub mod validation;
51pub mod waf;
52
53// ============================================================================
54// Re-exports
55// ============================================================================
56
57// Agents
58pub use agents::{
59    AgentConfig, AgentEvent, AgentTlsConfig, AgentTransport, AgentType, BodyStreamingMode,
60};
61
62// Defaults
63pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
64
65// Filters
66pub use filters::*;
67
68// Multi-file
69pub use multi_file::{ConfigDirectory, MultiFileLoader};
70
71// Namespace
72pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
73
74// Flatten
75pub use flatten::FlattenedConfig;
76
77// Resolution
78pub use resolution::ResourceResolver;
79
80// Observability
81pub use observability::{
82    AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig,
83    MetricsConfig, ObservabilityConfig, TracingBackend, TracingConfig,
84};
85
86// Routes
87pub use routes::{
88    ApiSchemaConfig, BuiltinHandler, CacheBackend, CacheStorageConfig, ErrorFormat, ErrorPage,
89    ErrorPageConfig, FailureMode, HeaderModifications, MatchCondition, RateLimitPolicy,
90    RouteCacheConfig, RouteConfig, RoutePolicies, ServiceType, StaticFileConfig,
91};
92
93// Server
94pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
95
96// Re-export TraceIdFormat from common for convenience
97pub use sentinel_common::TraceIdFormat;
98
99// Upstreams
100pub use upstreams::{
101    ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
102    UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
103};
104
105// Validation
106pub use validation::ValidationContext;
107
108// WAF
109pub use waf::{
110    BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
111};
112
113// Common types re-exported for convenience
114pub use sentinel_common::types::LoadBalancingAlgorithm;
115
116// ============================================================================
117// Main Configuration Structure
118// ============================================================================
119
120/// Current schema version supported by this build
121pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
122
123/// Minimum schema version supported by this build
124pub const MIN_SCHEMA_VERSION: &str = "1.0";
125
126/// Main configuration structure for Sentinel proxy
127#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
128#[validate(schema(function = "validation::validate_config_semantics"))]
129pub struct Config {
130    /// Configuration schema version for compatibility checking
131    /// If not specified, defaults to current version
132    #[serde(default = "default_schema_version")]
133    pub schema_version: String,
134
135    /// Global server configuration
136    pub server: ServerConfig,
137
138    /// Listener configurations
139    #[validate(length(min = 1, message = "At least one listener is required"))]
140    pub listeners: Vec<ListenerConfig>,
141
142    /// Route configurations
143    pub routes: Vec<RouteConfig>,
144
145    /// Upstream pool configurations (can be empty if all routes are static)
146    #[serde(default)]
147    pub upstreams: HashMap<String, UpstreamConfig>,
148
149    /// Named filter configurations (referenced by routes)
150    #[serde(default)]
151    pub filters: HashMap<String, FilterConfig>,
152
153    /// Agent configurations
154    #[serde(default)]
155    pub agents: Vec<AgentConfig>,
156
157    /// WAF configuration
158    #[serde(default)]
159    pub waf: Option<WafConfig>,
160
161    /// Namespace configurations for hierarchical organization.
162    ///
163    /// Namespaces provide domain-driven boundaries within the configuration,
164    /// grouping related resources (routes, upstreams, agents, etc.) together.
165    /// Resources within namespaces can reference each other without qualification,
166    /// and can optionally be exported for global visibility.
167    #[serde(default, skip_serializing_if = "Vec::is_empty")]
168    pub namespaces: Vec<NamespaceConfig>,
169
170    /// Global limits configuration
171    #[serde(default)]
172    pub limits: Limits,
173
174    /// Observability configuration
175    #[serde(default)]
176    pub observability: ObservabilityConfig,
177
178    /// Global rate limit configuration
179    #[serde(default)]
180    pub rate_limits: GlobalRateLimitConfig,
181
182    /// Global cache storage configuration
183    #[serde(default)]
184    pub cache: Option<CacheStorageConfig>,
185
186    /// Default upstream for Phase 0 testing
187    #[serde(skip)]
188    pub default_upstream: Option<UpstreamPeer>,
189}
190
191/// Default schema version (current version)
192fn default_schema_version() -> String {
193    CURRENT_SCHEMA_VERSION.to_string()
194}
195
196/// Schema version compatibility result
197#[derive(Debug, Clone, PartialEq, Eq)]
198pub enum SchemaCompatibility {
199    /// Version matches exactly
200    Exact,
201    /// Version is compatible (within supported range)
202    Compatible,
203    /// Version is newer than supported - may have unsupported features
204    Newer { config_version: String, max_supported: String },
205    /// Version is older than minimum supported
206    Older { config_version: String, min_supported: String },
207    /// Version format is invalid
208    Invalid { config_version: String, reason: String },
209}
210
211impl SchemaCompatibility {
212    /// Returns true if the config can be loaded (Exact, Compatible, or Newer with warning)
213    pub fn is_loadable(&self) -> bool {
214        matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
215    }
216
217    /// Returns a warning message if applicable
218    pub fn warning(&self) -> Option<String> {
219        match self {
220            Self::Newer { config_version, max_supported } => Some(format!(
221                "Config schema version {} is newer than supported version {}. Some features may not work.",
222                config_version, max_supported
223            )),
224            _ => None,
225        }
226    }
227
228    /// Returns an error message if not loadable
229    pub fn error(&self) -> Option<String> {
230        match self {
231            Self::Older { config_version, min_supported } => Some(format!(
232                "Config schema version {} is older than minimum supported version {}. Please update your configuration.",
233                config_version, min_supported
234            )),
235            Self::Invalid { config_version, reason } => Some(format!(
236                "Invalid schema version '{}': {}",
237                config_version, reason
238            )),
239            _ => None,
240        }
241    }
242}
243
244/// Parse a version string into (major, minor) tuple
245fn parse_version(version: &str) -> Option<(u32, u32)> {
246    let parts: Vec<&str> = version.trim().split('.').collect();
247    if parts.len() != 2 {
248        return None;
249    }
250    let major = parts[0].parse().ok()?;
251    let minor = parts[1].parse().ok()?;
252    Some((major, minor))
253}
254
255/// Check schema version compatibility
256pub fn check_schema_compatibility(config_version: &str) -> SchemaCompatibility {
257    let config_ver = match parse_version(config_version) {
258        Some(v) => v,
259        None => return SchemaCompatibility::Invalid {
260            config_version: config_version.to_string(),
261            reason: "Expected format: major.minor (e.g., '1.0')".to_string(),
262        },
263    };
264
265    let current_ver = parse_version(CURRENT_SCHEMA_VERSION).unwrap();
266    let min_ver = parse_version(MIN_SCHEMA_VERSION).unwrap();
267
268    // Check if older than minimum
269    if config_ver < min_ver {
270        return SchemaCompatibility::Older {
271            config_version: config_version.to_string(),
272            min_supported: MIN_SCHEMA_VERSION.to_string(),
273        };
274    }
275
276    // Check if newer than current
277    if config_ver > current_ver {
278        return SchemaCompatibility::Newer {
279            config_version: config_version.to_string(),
280            max_supported: CURRENT_SCHEMA_VERSION.to_string(),
281        };
282    }
283
284    // Check if exact match
285    if config_ver == current_ver {
286        return SchemaCompatibility::Exact;
287    }
288
289    // Within range
290    SchemaCompatibility::Compatible
291}
292
293// ============================================================================
294// Config Implementation
295// ============================================================================
296
297impl Config {
298    /// Load configuration from a file
299    pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
300        let path = path.as_ref();
301
302        trace!(
303            path = %path.display(),
304            "Loading configuration from file"
305        );
306
307        let content = std::fs::read_to_string(path)
308            .with_context(|| format!("Failed to read config file: {:?}", path))?;
309
310        let extension = path
311            .extension()
312            .and_then(|ext| ext.to_str())
313            .unwrap_or("kdl");
314
315        debug!(
316            path = %path.display(),
317            format = extension,
318            content_length = content.len(),
319            "Read configuration file"
320        );
321
322        let config = match extension {
323            "kdl" => Self::from_kdl(&content),
324            "json" => Self::from_json(&content),
325            "toml" => Self::from_toml(&content),
326            _ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
327        }?;
328
329        info!(
330            path = %path.display(),
331            routes = config.routes.len(),
332            upstreams = config.upstreams.len(),
333            agents = config.agents.len(),
334            listeners = config.listeners.len(),
335            "Configuration loaded successfully"
336        );
337
338        Ok(config)
339    }
340
341    /// Load the default embedded configuration.
342    ///
343    /// This is used when no configuration file is provided. It parses the
344    /// embedded KDL configuration, falling back to the programmatic default
345    /// if KDL parsing fails for any reason.
346    pub fn default_embedded() -> Result<Self> {
347        trace!("Loading embedded default configuration");
348
349        Self::from_kdl(DEFAULT_CONFIG_KDL).or_else(|e| {
350            warn!(
351                error = %e,
352                "Failed to parse embedded KDL config, using programmatic default"
353            );
354            Ok(create_default_config())
355        })
356    }
357
358    /// Parse configuration from KDL format
359    pub fn from_kdl(content: &str) -> Result<Self> {
360        trace!(content_length = content.len(), "Parsing KDL configuration");
361        let doc: ::kdl::KdlDocument = content.parse().map_err(|e: ::kdl::KdlError| {
362            use miette::Diagnostic;
363
364            let mut error_msg = String::new();
365            error_msg.push_str("KDL configuration parse error:\n\n");
366
367            let mut found_details = false;
368            if let Some(related) = e.related() {
369                for diagnostic in related {
370                    let diag_str = format!("{}", diagnostic);
371                    error_msg.push_str(&format!("  {}\n", diag_str));
372                    found_details = true;
373
374                    if let Some(labels) = diagnostic.labels() {
375                        for label in labels {
376                            let offset = label.offset();
377                            let (line, col) = kdl::offset_to_line_col(content, offset);
378                            error_msg
379                                .push_str(&format!("\n  --> at line {}, column {}\n", line, col));
380
381                            let lines: Vec<&str> = content.lines().collect();
382
383                            if line > 1 {
384                                if let Some(lc) = lines.get(line.saturating_sub(2)) {
385                                    error_msg.push_str(&format!("{:>4} | {}\n", line - 1, lc));
386                                }
387                            }
388
389                            if let Some(line_content) = lines.get(line.saturating_sub(1)) {
390                                error_msg.push_str(&format!("{:>4} | {}\n", line, line_content));
391                                error_msg.push_str(&format!(
392                                    "     | {}^",
393                                    " ".repeat(col.saturating_sub(1))
394                                ));
395                                if let Some(label_msg) = label.label() {
396                                    error_msg.push_str(&format!(" {}", label_msg));
397                                }
398                                error_msg.push('\n');
399                            }
400
401                            if let Some(lc) = lines.get(line) {
402                                error_msg.push_str(&format!("{:>4} | {}\n", line + 1, lc));
403                            }
404                        }
405                    }
406
407                    if let Some(help) = diagnostic.help() {
408                        error_msg.push_str(&format!("\n  Help: {}\n", help));
409                    }
410                }
411            }
412
413            if !found_details {
414                error_msg.push_str(&format!("  {}\n", e));
415                error_msg.push_str("\n  Note: Check your KDL syntax. Common issues:\n");
416                error_msg.push_str("    - Unclosed strings (missing closing quote)\n");
417                error_msg.push_str("    - Unclosed blocks (missing closing brace)\n");
418                error_msg.push_str("    - Invalid node names or values\n");
419            }
420
421            if let Some(help) = e.help() {
422                error_msg.push_str(&format!("\n  Help: {}\n", help));
423            }
424
425            anyhow::anyhow!("{}", error_msg)
426        })?;
427
428        kdl::parse_kdl_document(doc)
429    }
430
431    /// Parse configuration from JSON format
432    pub fn from_json(content: &str) -> Result<Self> {
433        trace!(content_length = content.len(), "Parsing JSON configuration");
434        serde_json::from_str(content).context("Failed to parse JSON configuration")
435    }
436
437    /// Parse configuration from TOML format
438    pub fn from_toml(content: &str) -> Result<Self> {
439        trace!(content_length = content.len(), "Parsing TOML configuration");
440        toml::from_str(content).context("Failed to parse TOML configuration")
441    }
442
443    /// Check schema version compatibility
444    pub fn check_schema_version(&self) -> SchemaCompatibility {
445        check_schema_compatibility(&self.schema_version)
446    }
447
448    /// Validate the configuration
449    pub fn validate(&self) -> SentinelResult<()> {
450        trace!(
451            routes = self.routes.len(),
452            upstreams = self.upstreams.len(),
453            agents = self.agents.len(),
454            schema_version = %self.schema_version,
455            "Starting configuration validation"
456        );
457
458        // Check schema version compatibility
459        let compat = self.check_schema_version();
460        if let Some(warning) = compat.warning() {
461            warn!("{}", warning);
462        }
463        if !compat.is_loadable() {
464            return Err(SentinelError::Config {
465                message: compat.error().unwrap_or_else(|| "Unknown schema version error".to_string()),
466                source: None,
467            });
468        }
469        trace!(
470            schema_version = %self.schema_version,
471            compatibility = ?compat,
472            "Schema version check passed"
473        );
474
475        Validate::validate(self).map_err(|e| SentinelError::Config {
476            message: format!("Configuration validation failed: {}", e),
477            source: None,
478        })?;
479
480        trace!("Schema validation passed");
481
482        self.validate_routes()?;
483        trace!("Route validation passed");
484
485        self.validate_upstreams()?;
486        trace!("Upstream validation passed");
487
488        self.validate_agents()?;
489        trace!("Agent validation passed");
490
491        self.limits.validate()?;
492        trace!("Limits validation passed");
493
494        debug!(
495            routes = self.routes.len(),
496            upstreams = self.upstreams.len(),
497            agents = self.agents.len(),
498            "Configuration validation successful"
499        );
500
501        Ok(())
502    }
503
504    fn validate_routes(&self) -> SentinelResult<()> {
505        for route in &self.routes {
506            if let Some(upstream) = &route.upstream {
507                if !self.upstreams.contains_key(upstream) {
508                    return Err(SentinelError::Config {
509                        message: format!(
510                            "Route '{}' references non-existent upstream '{}'",
511                            route.id, upstream
512                        ),
513                        source: None,
514                    });
515                }
516            }
517
518            for filter_id in &route.filters {
519                if !self.filters.contains_key(filter_id) {
520                    return Err(SentinelError::Config {
521                        message: format!(
522                            "Route '{}' references non-existent filter '{}'",
523                            route.id, filter_id
524                        ),
525                        source: None,
526                    });
527                }
528            }
529        }
530
531        for (filter_id, filter_config) in &self.filters {
532            if let Filter::Agent(agent_filter) = &filter_config.filter {
533                if !self.agents.iter().any(|a| a.id == agent_filter.agent) {
534                    return Err(SentinelError::Config {
535                        message: format!(
536                            "Filter '{}' references non-existent agent '{}'",
537                            filter_id, agent_filter.agent
538                        ),
539                        source: None,
540                    });
541                }
542            }
543        }
544
545        Ok(())
546    }
547
548    fn validate_upstreams(&self) -> SentinelResult<()> {
549        for (id, upstream) in &self.upstreams {
550            if upstream.targets.is_empty() {
551                return Err(SentinelError::Config {
552                    message: format!("Upstream '{}' has no targets", id),
553                    source: None,
554                });
555            }
556        }
557        Ok(())
558    }
559
560    fn validate_agents(&self) -> SentinelResult<()> {
561        for agent in &self.agents {
562            if agent.timeout_ms == 0 {
563                return Err(SentinelError::Config {
564                    message: format!("Agent '{}' has invalid timeout", agent.id),
565                    source: None,
566                });
567            }
568
569            if let AgentTransport::UnixSocket { path } = &agent.transport {
570                if !path.exists() && !path.parent().is_some_and(|p| p.exists()) {
571                    return Err(SentinelError::Config {
572                        message: format!(
573                            "Agent '{}' unix socket path parent directory doesn't exist: {:?}",
574                            agent.id, path
575                        ),
576                        source: None,
577                    });
578                }
579            }
580        }
581        Ok(())
582    }
583
584    /// Create a default configuration for testing
585    pub fn default_for_testing() -> Self {
586        use sentinel_common::types::LoadBalancingAlgorithm;
587
588        let mut upstreams = HashMap::new();
589        upstreams.insert(
590            "default".to_string(),
591            UpstreamConfig {
592                id: "default".to_string(),
593                targets: vec![UpstreamTarget {
594                    address: "127.0.0.1:8081".to_string(),
595                    weight: 1,
596                    max_requests: None,
597                    metadata: HashMap::new(),
598                }],
599                load_balancing: LoadBalancingAlgorithm::RoundRobin,
600                health_check: None,
601                connection_pool: ConnectionPoolConfig::default(),
602                timeouts: UpstreamTimeouts::default(),
603                tls: None,
604                http_version: HttpVersionConfig::default(),
605            },
606        );
607
608        Self {
609            schema_version: CURRENT_SCHEMA_VERSION.to_string(),
610            server: ServerConfig {
611                worker_threads: 4,
612                max_connections: 1000,
613                graceful_shutdown_timeout_secs: 30,
614                daemon: false,
615                pid_file: None,
616                user: None,
617                group: None,
618                working_directory: None,
619                trace_id_format: Default::default(),
620                auto_reload: false,
621            },
622            listeners: vec![ListenerConfig {
623                id: "http".to_string(),
624                address: "0.0.0.0:8080".to_string(),
625                protocol: ListenerProtocol::Http,
626                tls: None,
627                default_route: Some("default".to_string()),
628                request_timeout_secs: 60,
629                keepalive_timeout_secs: 75,
630                max_concurrent_streams: 100,
631            }],
632            routes: vec![RouteConfig {
633                id: "default".to_string(),
634                priority: Priority::Normal,
635                matches: vec![MatchCondition::PathPrefix("/".to_string())],
636                upstream: Some("default".to_string()),
637                service_type: ServiceType::Web,
638                policies: RoutePolicies::default(),
639                filters: vec![],
640                builtin_handler: None,
641                waf_enabled: false,
642                circuit_breaker: None,
643                retry_policy: None,
644                static_files: None,
645                api_schema: None,
646                error_pages: None,
647                websocket: false,
648                websocket_inspection: false,
649                shadow: None,
650            }],
651            upstreams,
652            filters: HashMap::new(),
653            agents: vec![],
654            waf: None,
655            namespaces: vec![],
656            limits: Limits::for_testing(),
657            observability: ObservabilityConfig::default(),
658            rate_limits: GlobalRateLimitConfig::default(),
659            cache: None,
660            default_upstream: Some(UpstreamPeer {
661                address: "127.0.0.1:8081".to_string(),
662                tls: false,
663                host: "localhost".to_string(),
664                connect_timeout_secs: 10,
665                read_timeout_secs: 30,
666                write_timeout_secs: 30,
667            }),
668        }
669    }
670
671    /// Reload configuration from the same file path
672    pub fn reload(&mut self, path: impl AsRef<Path>) -> SentinelResult<()> {
673        let path = path.as_ref();
674        debug!(
675            path = %path.display(),
676            "Reloading configuration"
677        );
678
679        let new_config = Self::from_file(path).map_err(|e| SentinelError::Config {
680            message: format!("Failed to reload configuration: {}", e),
681            source: None,
682        })?;
683
684        new_config.validate()?;
685
686        info!(
687            path = %path.display(),
688            routes = new_config.routes.len(),
689            upstreams = new_config.upstreams.len(),
690            "Configuration reloaded successfully"
691        );
692
693        *self = new_config;
694        Ok(())
695    }
696
697    /// Get a route by ID
698    pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
699        self.routes.iter().find(|r| r.id == id)
700    }
701
702    /// Get an upstream by ID
703    pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
704        self.upstreams.get(id)
705    }
706
707    /// Get an agent by ID
708    pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
709        self.agents.iter().find(|a| a.id == id)
710    }
711}
712
713// ============================================================================
714// Tests
715// ============================================================================
716
717#[cfg(test)]
718mod tests {
719    use super::*;
720
721    #[test]
722    fn test_parse_version() {
723        assert_eq!(parse_version("1.0"), Some((1, 0)));
724        assert_eq!(parse_version("2.5"), Some((2, 5)));
725        assert_eq!(parse_version("10.20"), Some((10, 20)));
726        assert_eq!(parse_version("1"), None);
727        assert_eq!(parse_version("1.0.0"), None);
728        assert_eq!(parse_version("abc"), None);
729        assert_eq!(parse_version(""), None);
730    }
731
732    #[test]
733    fn test_schema_compatibility_exact() {
734        let compat = check_schema_compatibility(CURRENT_SCHEMA_VERSION);
735        assert_eq!(compat, SchemaCompatibility::Exact);
736        assert!(compat.is_loadable());
737        assert!(compat.warning().is_none());
738        assert!(compat.error().is_none());
739    }
740
741    #[test]
742    fn test_schema_compatibility_newer() {
743        let compat = check_schema_compatibility("99.0");
744        assert!(matches!(compat, SchemaCompatibility::Newer { .. }));
745        assert!(compat.is_loadable()); // Newer versions are loadable with warning
746        assert!(compat.warning().is_some());
747        assert!(compat.error().is_none());
748    }
749
750    #[test]
751    fn test_schema_compatibility_older() {
752        // This test assumes MIN_SCHEMA_VERSION is "1.0"
753        let compat = check_schema_compatibility("0.5");
754        assert!(matches!(compat, SchemaCompatibility::Older { .. }));
755        assert!(!compat.is_loadable());
756        assert!(compat.warning().is_none());
757        assert!(compat.error().is_some());
758    }
759
760    #[test]
761    fn test_schema_compatibility_invalid() {
762        let compat = check_schema_compatibility("not-a-version");
763        assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
764        assert!(!compat.is_loadable());
765        assert!(compat.error().is_some());
766
767        let compat = check_schema_compatibility("1.0.0");
768        assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
769    }
770
771    #[test]
772    fn test_default_schema_version() {
773        let config = Config::default_for_testing();
774        assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
775    }
776
777    #[test]
778    fn test_kdl_with_schema_version() {
779        let kdl = r#"
780            schema-version "1.0"
781            server {
782                worker-threads 4
783            }
784            listeners {
785                listener "http" {
786                    address "0.0.0.0:8080"
787                    protocol "http"
788                }
789            }
790        "#;
791        let config = Config::from_kdl(kdl).unwrap();
792        assert_eq!(config.schema_version, "1.0");
793    }
794
795    #[test]
796    fn test_kdl_without_schema_version_uses_default() {
797        let kdl = r#"
798            server {
799                worker-threads 4
800            }
801            listeners {
802                listener "http" {
803                    address "0.0.0.0:8080"
804                    protocol "http"
805                }
806            }
807        "#;
808        let config = Config::from_kdl(kdl).unwrap();
809        assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
810    }
811}