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