use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[cfg(feature = "runtime")]
use std::collections::HashSet;
use std::path::Path;
#[cfg(feature = "runtime")]
use std::path::PathBuf;
use tracing::{debug, info, trace, warn};
use validator::Validate;
use zentinel_common::{
errors::{ZentinelError, ZentinelResult},
limits::Limits,
types::Priority,
};
pub mod agents;
mod defaults;
pub mod filters;
pub mod flatten;
mod kdl;
#[cfg(feature = "runtime")]
pub mod multi_file;
pub mod namespace;
pub mod observability;
pub mod resolution;
pub mod routes;
pub mod server;
pub mod upstreams;
#[cfg(feature = "validation")]
pub mod validate;
pub mod validation;
pub mod waf;
pub use agents::{
AgentConfig, AgentEvent, AgentPoolConfig, AgentTlsConfig, AgentTransport, AgentType,
BodyStreamingMode, LoadBalanceStrategy,
};
pub use defaults::{create_default_config, DEFAULT_CONFIG_KDL};
pub use filters::*;
pub use filters::{Filter, FilterConfig, PathModifier, RedirectFilter, UrlRewriteFilter};
#[cfg(feature = "runtime")]
pub use multi_file::{ConfigDirectory, MultiFileLoader};
pub use namespace::{ExportConfig, NamespaceConfig, ServiceConfig};
pub use flatten::FlattenedConfig;
pub use resolution::ResourceResolver;
pub use observability::{
AccessLogConfig, AccessLogFields, AuditLogConfig, ErrorLogConfig, LoggingConfig, MetricsConfig,
ObservabilityConfig, TracingBackend, TracingConfig,
};
pub use routes::{
ApiSchemaConfig, BuiltinHandler, CacheBackend, CacheStorageConfig, ErrorFormat, ErrorPage,
ErrorPageConfig, FailureMode, FallbackConfig, FallbackTriggers, FallbackUpstream,
GuardrailAction, GuardrailFailureMode, GuardrailsConfig, HeaderModifications, InferenceConfig,
InferenceProvider, InferenceRouting, InferenceRoutingStrategy, MatchCondition,
ModelRoutingConfig, ModelUpstreamMapping, PiiAction, PiiDetectionConfig, PromptInjectionConfig,
RateLimitPolicy, RouteCacheConfig, RouteConfig, RoutePolicies, ServiceType, StaticFileConfig,
TokenEstimation, TokenRateLimit,
};
pub use server::{ListenerConfig, ListenerProtocol, ServerConfig, SniCertificate, TlsConfig};
pub use zentinel_common::TraceIdFormat;
pub use zentinel_common::budget::{
BudgetPeriod, CostAttributionConfig, ModelPricing, TokenBudgetConfig,
};
pub use upstreams::{
ConnectionPoolConfig, HealthCheck, HttpVersionConfig, UpstreamConfig, UpstreamPeer,
UpstreamTarget, UpstreamTimeouts, UpstreamTlsConfig,
};
pub use validation::ValidationContext;
pub use waf::{
BodyInspectionPolicy, ExclusionScope, RuleExclusion, WafConfig, WafEngine, WafMode, WafRuleset,
};
pub use zentinel_common::types::LoadBalancingAlgorithm;
pub const CURRENT_SCHEMA_VERSION: &str = "1.0";
pub const MIN_SCHEMA_VERSION: &str = "1.0";
#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
#[validate(schema(function = "validation::validate_config_semantics"))]
pub struct Config {
#[serde(default = "default_schema_version")]
pub schema_version: String,
pub server: ServerConfig,
#[validate(length(min = 1, message = "At least one listener is required"))]
pub listeners: Vec<ListenerConfig>,
pub routes: Vec<RouteConfig>,
#[serde(default)]
pub upstreams: HashMap<String, UpstreamConfig>,
#[serde(default)]
pub filters: HashMap<String, FilterConfig>,
#[serde(default)]
pub agents: Vec<AgentConfig>,
#[serde(default)]
pub waf: Option<WafConfig>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub namespaces: Vec<NamespaceConfig>,
#[serde(default)]
pub limits: Limits,
#[serde(default)]
pub observability: ObservabilityConfig,
#[serde(default)]
pub rate_limits: GlobalRateLimitConfig,
#[serde(default)]
pub cache: Option<CacheStorageConfig>,
#[serde(skip)]
pub default_upstream: Option<UpstreamPeer>,
}
fn default_schema_version() -> String {
CURRENT_SCHEMA_VERSION.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum SchemaCompatibility {
Exact,
Compatible,
Newer {
config_version: String,
max_supported: String,
},
Older {
config_version: String,
min_supported: String,
},
Invalid {
config_version: String,
reason: String,
},
}
impl SchemaCompatibility {
pub fn is_loadable(&self) -> bool {
matches!(self, Self::Exact | Self::Compatible | Self::Newer { .. })
}
pub fn warning(&self) -> Option<String> {
match self {
Self::Newer { config_version, max_supported } => Some(format!(
"Config schema version {} is newer than supported version {}. Some features may not work.",
config_version, max_supported
)),
_ => None,
}
}
pub fn error(&self) -> Option<String> {
match self {
Self::Older { config_version, min_supported } => Some(format!(
"Config schema version {} is older than minimum supported version {}. Please update your configuration.",
config_version, min_supported
)),
Self::Invalid { config_version, reason } => Some(format!(
"Invalid schema version '{}': {}",
config_version, reason
)),
_ => None,
}
}
}
fn parse_version(version: &str) -> Option<(u32, u32)> {
let parts: Vec<&str> = version.trim().split('.').collect();
if parts.len() != 2 {
return None;
}
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
Some((major, minor))
}
pub fn check_schema_compatibility(config_version: &str) -> SchemaCompatibility {
let config_ver = match parse_version(config_version) {
Some(v) => v,
None => {
return SchemaCompatibility::Invalid {
config_version: config_version.to_string(),
reason: "Expected format: major.minor (e.g., '1.0')".to_string(),
}
}
};
let current_ver = parse_version(CURRENT_SCHEMA_VERSION).unwrap();
let min_ver = parse_version(MIN_SCHEMA_VERSION).unwrap();
if config_ver < min_ver {
return SchemaCompatibility::Older {
config_version: config_version.to_string(),
min_supported: MIN_SCHEMA_VERSION.to_string(),
};
}
if config_ver > current_ver {
return SchemaCompatibility::Newer {
config_version: config_version.to_string(),
max_supported: CURRENT_SCHEMA_VERSION.to_string(),
};
}
if config_ver == current_ver {
return SchemaCompatibility::Exact;
}
SchemaCompatibility::Compatible
}
impl Config {
pub fn from_file(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
trace!(
path = %path.display(),
"Loading configuration from file"
);
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read config file: {:?}", path))?;
let extension = path
.extension()
.and_then(|ext| ext.to_str())
.unwrap_or("kdl");
debug!(
path = %path.display(),
format = extension,
content_length = content.len(),
"Read configuration file"
);
let config = match extension {
"kdl" => {
let expanded = Self::expand_kdl_includes(&content, path)?;
Self::from_kdl(&expanded)
}
"json" => Self::from_json(&content),
"toml" => Self::from_toml(&content),
_ => Err(anyhow::anyhow!("Unsupported config format: {}", extension)),
}?;
info!(
path = %path.display(),
routes = config.routes.len(),
upstreams = config.upstreams.len(),
agents = config.agents.len(),
listeners = config.listeners.len(),
"Configuration loaded successfully"
);
Ok(config)
}
#[cfg(feature = "runtime")]
fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
let doc: ::kdl::KdlDocument = content
.parse()
.map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
if !has_includes {
return Ok(content.to_string());
}
let mut visited = HashSet::new();
Self::expand_includes_recursive(content, source_path, &mut visited)
}
#[cfg(not(feature = "runtime"))]
fn expand_kdl_includes(content: &str, source_path: &Path) -> Result<String> {
let doc: ::kdl::KdlDocument = content
.parse()
.map_err(|e| anyhow::anyhow!("KDL parse error during include expansion: {}", e))?;
let has_includes = doc.nodes().iter().any(|n| n.name().value() == "include");
if has_includes {
return Err(anyhow::anyhow!(
"The 'include' directive in '{}' requires the 'runtime' feature.\n\
Build with: cargo build --features runtime",
source_path.display()
));
}
Ok(content.to_string())
}
#[cfg(feature = "runtime")]
fn expand_includes_recursive(
content: &str,
source_path: &Path,
visited: &mut HashSet<PathBuf>,
) -> Result<String> {
let canonical = source_path
.canonicalize()
.with_context(|| format!("Failed to resolve config path: {}", source_path.display()))?;
if !visited.insert(canonical.clone()) {
return Err(anyhow::anyhow!(
"Circular include detected: '{}' has already been included",
source_path.display()
));
}
let base_dir = canonical.parent().ok_or_else(|| {
anyhow::anyhow!(
"Config file has no parent directory: {}",
source_path.display()
)
})?;
let doc: ::kdl::KdlDocument = content.parse().map_err(|e| {
anyhow::anyhow!("KDL parse error in '{}': {}", source_path.display(), e)
})?;
let mut output = String::new();
for node in doc.nodes() {
if node.name().value() == "include" {
let pattern = node
.entries()
.iter()
.find_map(|e| {
if e.name().is_none() {
e.value().as_string().map(|s| s.to_string())
} else {
None
}
})
.ok_or_else(|| {
anyhow::anyhow!(
"The 'include' directive requires a string argument, e.g.: include \"routes/*.kdl\"\n\
Found in: {}",
source_path.display()
)
})?;
let full_pattern = base_dir.join(&pattern);
let pattern_str = full_pattern.to_str().ok_or_else(|| {
anyhow::anyhow!("Include pattern contains invalid UTF-8: {:?}", full_pattern)
})?;
let mut matched_any = false;
let mut paths: Vec<PathBuf> = Vec::new();
for entry in glob::glob(pattern_str).with_context(|| {
format!(
"Invalid glob pattern '{}' in {}",
pattern,
source_path.display()
)
})? {
let path = entry.with_context(|| {
format!(
"Error reading glob match for '{}' in {}",
pattern,
source_path.display()
)
})?;
paths.push(path);
}
paths.sort();
for path in paths {
matched_any = true;
debug!(
include = %path.display(),
from = %source_path.display(),
"Including config file"
);
let included_content = std::fs::read_to_string(&path).with_context(|| {
format!(
"Failed to read included config file '{}' (included from '{}')",
path.display(),
source_path.display()
)
})?;
let expanded =
Self::expand_includes_recursive(&included_content, &path, visited)?;
output.push_str(&expanded);
output.push('\n');
}
if !matched_any {
warn!(
pattern = %pattern,
source = %source_path.display(),
"Include pattern matched no files"
);
}
} else {
output.push_str(&node.to_string());
output.push('\n');
}
}
Ok(output)
}
pub fn default_embedded() -> Result<Self> {
trace!("Loading embedded default configuration");
Self::from_kdl(DEFAULT_CONFIG_KDL).or_else(|e| {
warn!(
error = %e,
"Failed to parse embedded KDL config, using programmatic default"
);
Ok(create_default_config())
})
}
pub fn from_kdl(content: &str) -> Result<Self> {
trace!(content_length = content.len(), "Parsing KDL configuration");
let doc: ::kdl::KdlDocument = content.parse().map_err(|e: ::kdl::KdlError| {
use miette::Diagnostic;
let mut error_msg = String::new();
error_msg.push_str("KDL configuration parse error:\n\n");
let mut found_details = false;
if let Some(related) = e.related() {
for diagnostic in related {
let diag_str = format!("{}", diagnostic);
error_msg.push_str(&format!(" {}\n", diag_str));
found_details = true;
if let Some(labels) = diagnostic.labels() {
for label in labels {
let offset = label.offset();
let (line, col) = kdl::offset_to_line_col(content, offset);
error_msg
.push_str(&format!("\n --> at line {}, column {}\n", line, col));
let lines: Vec<&str> = content.lines().collect();
if line > 1 {
if let Some(lc) = lines.get(line.saturating_sub(2)) {
error_msg.push_str(&format!("{:>4} | {}\n", line - 1, lc));
}
}
if let Some(line_content) = lines.get(line.saturating_sub(1)) {
error_msg.push_str(&format!("{:>4} | {}\n", line, line_content));
error_msg.push_str(&format!(
" | {}^",
" ".repeat(col.saturating_sub(1))
));
if let Some(label_msg) = label.label() {
error_msg.push_str(&format!(" {}", label_msg));
}
error_msg.push('\n');
}
if let Some(lc) = lines.get(line) {
error_msg.push_str(&format!("{:>4} | {}\n", line + 1, lc));
}
}
}
if let Some(help) = diagnostic.help() {
error_msg.push_str(&format!("\n Help: {}\n", help));
}
}
}
if !found_details {
error_msg.push_str(&format!(" {}\n", e));
error_msg.push_str("\n Note: Check your KDL syntax. Common issues:\n");
error_msg.push_str(" - Unclosed strings (missing closing quote)\n");
error_msg.push_str(" - Unclosed blocks (missing closing brace)\n");
error_msg.push_str(" - Invalid node names or values\n");
}
if let Some(help) = e.help() {
error_msg.push_str(&format!("\n Help: {}\n", help));
}
anyhow::anyhow!("{}", error_msg)
})?;
kdl::parse_kdl_document(doc)
}
pub fn from_json(content: &str) -> Result<Self> {
trace!(content_length = content.len(), "Parsing JSON configuration");
serde_json::from_str(content).context("Failed to parse JSON configuration")
}
pub fn from_toml(content: &str) -> Result<Self> {
trace!(content_length = content.len(), "Parsing TOML configuration");
toml::from_str(content).context("Failed to parse TOML configuration")
}
pub fn check_schema_version(&self) -> SchemaCompatibility {
check_schema_compatibility(&self.schema_version)
}
pub fn validate(&self) -> ZentinelResult<()> {
trace!(
routes = self.routes.len(),
upstreams = self.upstreams.len(),
agents = self.agents.len(),
schema_version = %self.schema_version,
"Starting configuration validation"
);
let compat = self.check_schema_version();
if let Some(warning) = compat.warning() {
warn!("{}", warning);
}
if !compat.is_loadable() {
return Err(ZentinelError::Config {
message: compat
.error()
.unwrap_or_else(|| "Unknown schema version error".to_string()),
source: None,
});
}
trace!(
schema_version = %self.schema_version,
compatibility = ?compat,
"Schema version check passed"
);
Validate::validate(self).map_err(|e| ZentinelError::Config {
message: format!("Configuration validation failed: {}", e),
source: None,
})?;
trace!("Schema validation passed");
self.validate_routes()?;
trace!("Route validation passed");
self.validate_upstreams()?;
trace!("Upstream validation passed");
self.validate_agents()?;
trace!("Agent validation passed");
self.limits.validate()?;
trace!("Limits validation passed");
debug!(
routes = self.routes.len(),
upstreams = self.upstreams.len(),
agents = self.agents.len(),
"Configuration validation successful"
);
Ok(())
}
fn validate_routes(&self) -> ZentinelResult<()> {
for route in &self.routes {
if let Some(upstream) = &route.upstream {
if !self.upstreams.contains_key(upstream) {
return Err(ZentinelError::Config {
message: format!(
"Route '{}' references non-existent upstream '{}'",
route.id, upstream
),
source: None,
});
}
}
for filter_id in &route.filters {
if !self.filters.contains_key(filter_id) {
return Err(ZentinelError::Config {
message: format!(
"Route '{}' references non-existent filter '{}'",
route.id, filter_id
),
source: None,
});
}
}
}
for (filter_id, filter_config) in &self.filters {
if let Filter::Agent(agent_filter) = &filter_config.filter {
if !self.agents.iter().any(|a| a.id == agent_filter.agent) {
return Err(ZentinelError::Config {
message: format!(
"Filter '{}' references non-existent agent '{}'",
filter_id, agent_filter.agent
),
source: None,
});
}
}
}
Ok(())
}
fn validate_upstreams(&self) -> ZentinelResult<()> {
for (id, upstream) in &self.upstreams {
if upstream.targets.is_empty() {
return Err(ZentinelError::Config {
message: format!("Upstream '{}' has no targets", id),
source: None,
});
}
}
Ok(())
}
fn validate_agents(&self) -> ZentinelResult<()> {
for agent in &self.agents {
if agent.timeout_ms == 0 {
return Err(ZentinelError::Config {
message: format!("Agent '{}' has invalid timeout", agent.id),
source: None,
});
}
if let AgentTransport::UnixSocket { path } = &agent.transport {
if !path.exists() && !path.parent().is_some_and(|p| p.exists()) {
return Err(ZentinelError::Config {
message: format!(
"Agent '{}' unix socket path parent directory doesn't exist: {:?}",
agent.id, path
),
source: None,
});
}
}
}
Ok(())
}
pub fn default_for_testing() -> Self {
use zentinel_common::types::LoadBalancingAlgorithm;
let mut upstreams = HashMap::new();
upstreams.insert(
"default".to_string(),
UpstreamConfig {
id: "default".to_string(),
targets: vec![UpstreamTarget {
address: "127.0.0.1:8081".to_string(),
weight: 1,
max_requests: None,
metadata: HashMap::new(),
}],
load_balancing: LoadBalancingAlgorithm::RoundRobin,
sticky_session: None,
health_check: None,
connection_pool: ConnectionPoolConfig::default(),
timeouts: UpstreamTimeouts::default(),
tls: None,
http_version: HttpVersionConfig::default(),
},
);
Self {
schema_version: CURRENT_SCHEMA_VERSION.to_string(),
server: ServerConfig {
worker_threads: 4,
max_connections: 1000,
graceful_shutdown_timeout_secs: 30,
daemon: false,
pid_file: None,
user: None,
group: None,
working_directory: None,
trace_id_format: Default::default(),
auto_reload: false,
},
listeners: vec![ListenerConfig {
id: "http".to_string(),
address: "0.0.0.0:8080".to_string(),
protocol: ListenerProtocol::Http,
tls: None,
default_route: Some("default".to_string()),
request_timeout_secs: 60,
keepalive_timeout_secs: 75,
max_concurrent_streams: 100,
keepalive_max_requests: None,
}],
routes: vec![RouteConfig {
id: "default".to_string(),
priority: Priority::NORMAL,
matches: vec![MatchCondition::PathPrefix("/".to_string())],
upstream: Some("default".to_string()),
service_type: ServiceType::Web,
policies: RoutePolicies::default(),
filters: vec![],
builtin_handler: None,
waf_enabled: false,
circuit_breaker: None,
retry_policy: None,
static_files: None,
api_schema: None,
inference: None,
error_pages: None,
websocket: false,
websocket_inspection: false,
shadow: None,
fallback: None,
}],
upstreams,
filters: HashMap::new(),
agents: vec![],
waf: None,
namespaces: vec![],
limits: Limits::for_testing(),
observability: ObservabilityConfig::default(),
rate_limits: GlobalRateLimitConfig::default(),
cache: None,
default_upstream: Some(UpstreamPeer {
address: "127.0.0.1:8081".to_string(),
tls: false,
host: "localhost".to_string(),
connect_timeout_secs: 10,
read_timeout_secs: 30,
write_timeout_secs: 30,
}),
}
}
pub fn reload(&mut self, path: impl AsRef<Path>) -> ZentinelResult<()> {
let path = path.as_ref();
debug!(
path = %path.display(),
"Reloading configuration"
);
let new_config = Self::from_file(path).map_err(|e| ZentinelError::Config {
message: format!("Failed to reload configuration: {}", e),
source: None,
})?;
new_config.validate()?;
info!(
path = %path.display(),
routes = new_config.routes.len(),
upstreams = new_config.upstreams.len(),
"Configuration reloaded successfully"
);
*self = new_config;
Ok(())
}
pub fn get_route(&self, id: &str) -> Option<&RouteConfig> {
self.routes.iter().find(|r| r.id == id)
}
pub fn get_upstream(&self, id: &str) -> Option<&UpstreamConfig> {
self.upstreams.get(id)
}
pub fn get_agent(&self, id: &str) -> Option<&AgentConfig> {
self.agents.iter().find(|a| a.id == id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_version() {
assert_eq!(parse_version("1.0"), Some((1, 0)));
assert_eq!(parse_version("2.5"), Some((2, 5)));
assert_eq!(parse_version("10.20"), Some((10, 20)));
assert_eq!(parse_version("1"), None);
assert_eq!(parse_version("1.0.0"), None);
assert_eq!(parse_version("abc"), None);
assert_eq!(parse_version(""), None);
}
#[test]
fn test_schema_compatibility_exact() {
let compat = check_schema_compatibility(CURRENT_SCHEMA_VERSION);
assert_eq!(compat, SchemaCompatibility::Exact);
assert!(compat.is_loadable());
assert!(compat.warning().is_none());
assert!(compat.error().is_none());
}
#[test]
fn test_schema_compatibility_newer() {
let compat = check_schema_compatibility("99.0");
assert!(matches!(compat, SchemaCompatibility::Newer { .. }));
assert!(compat.is_loadable()); assert!(compat.warning().is_some());
assert!(compat.error().is_none());
}
#[test]
fn test_schema_compatibility_older() {
let compat = check_schema_compatibility("0.5");
assert!(matches!(compat, SchemaCompatibility::Older { .. }));
assert!(!compat.is_loadable());
assert!(compat.warning().is_none());
assert!(compat.error().is_some());
}
#[test]
fn test_schema_compatibility_invalid() {
let compat = check_schema_compatibility("not-a-version");
assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
assert!(!compat.is_loadable());
assert!(compat.error().is_some());
let compat = check_schema_compatibility("1.0.0");
assert!(matches!(compat, SchemaCompatibility::Invalid { .. }));
}
#[test]
fn test_default_schema_version() {
let config = Config::default_for_testing();
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
}
#[test]
fn test_kdl_with_schema_version() {
let kdl = r#"
schema-version "1.0"
server {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
"#;
let config = Config::from_kdl(kdl).unwrap();
assert_eq!(config.schema_version, "1.0");
}
#[test]
fn test_kdl_without_schema_version_uses_default() {
let kdl = r#"
server {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
"#;
let config = Config::from_kdl(kdl).unwrap();
assert_eq!(config.schema_version, CURRENT_SCHEMA_VERSION);
}
#[test]
fn test_from_kdl_rejects_include_directive() {
let kdl = r#"
include "routes/*.kdl"
system {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
"#;
let err = Config::from_kdl(kdl).unwrap_err();
assert!(
err.to_string()
.contains("not supported when parsing raw KDL strings"),
"Expected helpful include error, got: {}",
err
);
}
#[test]
fn test_from_file_with_include() {
let dir = tempfile::tempdir().unwrap();
let routes_dir = dir.path().join("routes");
std::fs::create_dir(&routes_dir).unwrap();
std::fs::write(
routes_dir.join("api.kdl"),
r#"
routes {
route "api" {
match {
path-prefix "/api"
}
upstream "backend"
}
}
"#,
)
.unwrap();
let main_config = dir.path().join("zentinel.kdl");
std::fs::write(
&main_config,
r#"
schema-version "1.0"
system {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
upstreams {
upstream "backend" {
target "127.0.0.1:9000"
}
}
include "routes/*.kdl"
"#,
)
.unwrap();
let config = Config::from_file(&main_config).unwrap();
assert_eq!(config.routes.len(), 1);
assert_eq!(config.routes[0].id, "api");
}
#[test]
fn test_from_file_with_no_includes_still_works() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("zentinel.kdl");
std::fs::write(
&config_path,
r#"
schema-version "1.0"
system {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
"#,
)
.unwrap();
let config = Config::from_file(&config_path).unwrap();
assert_eq!(config.listeners.len(), 1);
}
#[test]
fn test_include_circular_detection() {
let dir = tempfile::tempdir().unwrap();
let a_path = dir.path().join("a.kdl");
let b_path = dir.path().join("b.kdl");
std::fs::write(&a_path, format!("include \"{}\"", b_path.display())).unwrap();
std::fs::write(&b_path, format!("include \"{}\"", a_path.display())).unwrap();
let err = Config::from_file(&a_path).unwrap_err();
assert!(
err.to_string().contains("Circular include detected"),
"Expected circular include error, got: {}",
err
);
}
#[test]
fn test_include_no_match_warns_but_succeeds() {
let dir = tempfile::tempdir().unwrap();
let config_path = dir.path().join("zentinel.kdl");
std::fs::write(
&config_path,
r#"
system {
worker-threads 4
}
listeners {
listener "http" {
address "0.0.0.0:8080"
protocol "http"
}
}
include "nonexistent/*.kdl"
"#,
)
.unwrap();
let config = Config::from_file(&config_path).unwrap();
assert_eq!(config.listeners.len(), 1);
}
}