Skip to main content

grapsus_config/
lib.rs

1//! Configuration module for Grapsus 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;
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
37// ============================================================================
38// Module Declarations
39// ============================================================================
40
41pub 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
59// ============================================================================
60// Re-exports
61// ============================================================================
62
63// Agents
64pub use agents::{
65    AgentConfig, AgentEvent, AgentPoolConfig, AgentTlsConfig, AgentTransport, AgentType,
66    BodyStreamingMode, LoadBalanceStrategy,
67};
68
69// Defaults
70pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
71
72// Filters
73pub use filters::*;
74
75// Multi-file (runtime only - uses glob which requires std::fs)
76#[cfg(feature = "runtime")]
77pub use multi_file::{ConfigDirectory, MultiFileLoader};
78
79// Namespace
80pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
81
82// Flatten
83pub use flatten::FlattenedConfig;
84
85// Resolution
86pub use resolution::ResourceResolver;
87
88// Observability
89pub use observability::{
90    AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig, MetricsConfig,
91    ObservabilityConfig, TracingBackend, TracingConfig,
92};
93
94// Routes
95pub 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
105// Server
106pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
107
108// Re-export TraceIdFormat from common for convenience
109pub use grapsus_common::TraceIdFormat;
110
111// Re-export budget types from common for convenience
112pub use grapsus_common::budget::{
113    BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
114};
115
116// Upstreams
117pub use upstreams::{
118    ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
119    UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
120};
121
122// Validation
123pub use validation::ValidationContext;
124
125// WAF
126pub use waf::{
127    BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
128};
129
130// Common types re-exported for convenience
131pub use grapsus_common::types::LoadBalancingAlgorithm;
132
133// ============================================================================
134// Main Configuration Structure
135// ============================================================================
136
137/// Current schema version supported by this build
138pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
139
140/// Minimum schema version supported by this build
141pub const MIN_SCHEMA_VERSION: &str = "1.0";
142
143/// Main configuration structure for Grapsus proxy
144#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
145#[validate(schema(function = "validation::validate_config_semantics"))]
146pub struct Config {
147    /// Configuration schema version for compatibility checking
148    /// If not specified, defaults to current version
149    #[serde(default = "default_schema_version")]
150    pub schema_version: String,
151
152    /// Global server configuration
153    pub server: ServerConfig,
154
155    /// Listener configurations
156    #[validate(length(min = 1, message = "At least one listener is required"))]
157    pub listeners: Vec<ListenerConfig>,
158
159    /// Route configurations
160    pub routes: Vec<RouteConfig>,
161
162    /// Upstream pool configurations (can be empty if all routes are static)
163    #[serde(default)]
164    pub upstreams: HashMap<String, UpstreamConfig>,
165
166    /// Named filter configurations (referenced by routes)
167    #[serde(default)]
168    pub filters: HashMap<String, FilterConfig>,
169
170    /// Agent configurations
171    #[serde(default)]
172    pub agents: Vec<AgentConfig>,
173
174    /// WAF configuration
175    #[serde(default)]
176    pub waf: Option<WafConfig>,
177
178    /// Namespace configurations for hierarchical organization.
179    ///
180    /// Namespaces provide domain-driven boundaries within the configuration,
181    /// grouping related resources (routes, upstreams, agents, etc.) together.
182    /// Resources within namespaces can reference each other without qualification,
183    /// and can optionally be exported for global visibility.
184    #[serde(default, skip_serializing_if = "Vec::is_empty")]
185    pub namespaces: Vec<NamespaceConfig>,
186
187    /// Global limits configuration
188    #[serde(default)]
189    pub limits: Limits,
190
191    /// Observability configuration
192    #[serde(default)]
193    pub observability: ObservabilityConfig,
194
195    /// Global rate limit configuration
196    #[serde(default)]
197    pub rate_limits: GlobalRateLimitConfig,
198
199    /// Global cache storage configuration
200    #[serde(default)]
201    pub cache: Option<CacheStorageConfig>,
202
203    /// Default upstream for Phase 0 testing
204    #[serde(skip)]
205    pub default_upstream: Option<UpstreamPeer>,
206}
207
208/// Default schema version (current version)
209fn default_schema_version() -> String {
210    CURRENT_SCHEMA_VERSION.to_string()
211}
212
213/// Schema version compatibility result
214#[derive(Debug, Clone, PartialEq, Eq)]
215pub enum SchemaCompatibility {
216    /// Version matches exactly
217    Exact,
218    /// Version is compatible (within supported range)
219    Compatible,
220    /// Version is newer than supported - may have unsupported features
221    Newer {
222        config_version: String,
223        max_supported: String,
224    },
225    /// Version is older than minimum supported
226    Older {
227        config_version: String,
228        min_supported: String,
229    },
230    /// Version format is invalid
231    Invalid {
232        config_version: String,
233        reason: String,
234    },
235}
236
237impl SchemaCompatibility {
238    /// Returns true if the config can be loaded (Exact, Compatible, or Newer with warning)
239    pub fn is_loadable(&self) -> bool {
240        matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
241    }
242
243    /// Returns a warning message if applicable
244    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    /// Returns an error message if not loadable
255    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
270/// Parse a version string into (major, minor) tuple
271fn 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
281/// Check schema version compatibility
282pub 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    // Check if older than minimum
297    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    // Check if newer than current
305    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    // Check if exact match
313    if config_ver == current_ver {
314        return SchemaCompatibility::Exact;
315    }
316
317    // Within range
318    SchemaCompatibility::Compatible
319}
320
321// ============================================================================
322// Config Implementation
323// ============================================================================
324
325impl Config {
326    /// Load configuration from a file
327    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    /// Expand `include` directives in KDL content.
373    ///
374    /// This pre-processes the KDL string, resolving any `include` directives
375    /// by reading the referenced files (supporting glob patterns) and
376    /// recursively expanding their includes. Returns a single KDL string
377    /// with all includes inlined.
378    #[cfg(feature = "runtime")]
379    fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
380        // Quick check: if there are no include directives, return as-is
381        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    /// Fallback when the `runtime` feature is not enabled.
395    #[cfg(not(feature = "runtime"))]
396    fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
397        // Check if the content has include directives
398        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    /// Recursively expand include directives in a KDL file.
415    ///
416    /// Parses the KDL content, replaces `include` nodes with the contents of
417    /// the referenced files, and returns the merged KDL string. Uses a visited
418    /// set of canonical paths to detect and prevent circular includes.
419    #[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                // Resolve pattern relative to source file's directory
470                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                // Sort for deterministic include order
496                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                // Serialize the node back to KDL
529                output.push_str(&node.to_string());
530                output.push('\n');
531            }
532        }
533
534        Ok(output)
535    }
536
537    /// Load the default embedded configuration.
538    ///
539    /// This is used when no configuration file is provided. It parses the
540    /// embedded KDL configuration, falling back to the programmatic default
541    /// if KDL parsing fails for any reason.
542    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    /// Parse configuration from KDL format
555    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    /// Parse configuration from JSON format
628    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    /// Parse configuration from TOML format
634    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    /// Check schema version compatibility
640    pub fn check_schema_version(&self) -> SchemaCompatibility {
641        check_schema_compatibility(&self.schema_version)
642    }
643
644    /// Validate the configuration
645    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        // Check schema version compatibility
655        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    /// Create a default configuration for testing
783    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    /// Reload configuration from the same file path
873    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    /// Get a route by ID
899    pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
900        self.routes.iter().find(|r| r.id == id)
901    }
902
903    /// Get an upstream by ID
904    pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
905        self.upstreams.get(id)
906    }
907
908    /// Get an agent by ID
909    pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
910        self.agents.iter().find(|a| a.id == id)
911    }
912}
913
914// ============================================================================
915// Tests
916// ============================================================================
917
918#[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()); // Newer versions are loadable with warning
947        assert!(compat.warning().is_some());
948        assert!(compat.error().is_none());
949    }
950
951    #[test]
952    fn test_schema_compatibility_older() {
953        // This test assumes MIN_SCHEMA_VERSION is "1.0"
954        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        // Write an included file with routes
1041        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        // Write the main config that includes the routes file
1059        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        // Should succeed even with no matching files
1153        let config = Config::from_file(&config_path).unwrap();
1154        assert_eq!(config.listeners.len(), 1);
1155    }
1156}