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