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