use std::collections::HashMap; use std::path::PathBuf; use miette::{IntoDiagnostic as _, Result}; use kdl::{KdlDocument, KdlValue}; /// Internal application configuration aggregated from env and KDL. #[derive(Clone, Debug)] pub struct AppConfig { pub grpc_addr: Option, pub http_addr: Option, pub database_url: Option, pub otlp_endpoint: Option, pub mq: crate::mq::MqConfig, } impl AppConfig { /// Load config by reading env vars and KDL files without mutating the environment. /// Precedence: KDL (lowest) < Environment < CLI (applied by callers). pub fn load(service: &str) -> Result { let kdl_map = load_kdl_kv(service)?; let grpc_addr = std::env::var("GRPC_ADDR").ok().or_else(|| kdl_map.get("GRPC_ADDR").cloned()); let http_addr = std::env::var("HTTP_ADDR").ok().or_else(|| kdl_map.get("HTTP_ADDR").cloned()); let database_url = std::env::var("DATABASE_URL").ok().or_else(|| kdl_map.get("DATABASE_URL").cloned()); let otlp_endpoint = std::env::var("OTEL_EXPORTER_OTLP_ENDPOINT").ok().or_else(|| kdl_map.get("OTEL_EXPORTER_OTLP_ENDPOINT").cloned()); // Build MQ config from env with KDL fallbacks, then defaults let url = std::env::var("AMQP_URL") .ok() .or_else(|| kdl_map.get("AMQP_URL").cloned()) .unwrap_or_else(|| "amqp://127.0.0.1:5672/%2f".into()); let exchange = std::env::var("AMQP_EXCHANGE") .ok() .or_else(|| kdl_map.get("AMQP_EXCHANGE").cloned()) .unwrap_or_else(|| "solstice.jobs".into()); let routing_key = std::env::var("AMQP_ROUTING_KEY") .ok() .or_else(|| kdl_map.get("AMQP_ROUTING_KEY").cloned()) .unwrap_or_else(|| "jobrequest.v1".into()); let queue = std::env::var("AMQP_QUEUE") .ok() .or_else(|| kdl_map.get("AMQP_QUEUE").cloned()) .unwrap_or_else(|| "solstice.jobs.v1".into()); let dlx = std::env::var("AMQP_DLX") .ok() .or_else(|| kdl_map.get("AMQP_DLX").cloned()) .unwrap_or_else(|| "solstice.dlx".into()); let dlq = std::env::var("AMQP_DLQ") .ok() .or_else(|| kdl_map.get("AMQP_DLQ").cloned()) .unwrap_or_else(|| "solstice.jobs.v1.dlq".into()); let prefetch = std::env::var("AMQP_PREFETCH") .ok() .and_then(|s| s.parse().ok()) .or_else(|| kdl_map.get("AMQP_PREFETCH").and_then(|s| s.parse().ok())) .unwrap_or(64u16); let mq = crate::mq::MqConfig { url, exchange, routing_key, queue, dlx, dlq, prefetch }; Ok(Self { grpc_addr, http_addr, database_url, otlp_endpoint, mq }) } } /// Load KDL files into a simple key/value map of strings. fn load_kdl_kv(service: &str) -> Result> { let global = PathBuf::from("/etc/solstice/solstice.kdl"); let svc = PathBuf::from(format!("/etc/solstice/{}.kdl", service)); let mut map = HashMap::new(); for path in [global, svc] { if !path.exists() { continue; } let s = std::fs::read_to_string(&path).into_diagnostic()?; let doc: KdlDocument = s.parse().into_diagnostic()?; for node in doc.nodes() { let key = node.name().value().to_string(); // Prefer first argument, otherwise `value` property; skip nulls let value_str = if let Some(entry) = node.entries().first() { kdl_value_to_string(entry.value()) } else if let Some(v) = node.get("value") { kdl_value_to_string(v) } else { None }; if let Some(v) = value_str { // Only insert if not already set by a previous file (global lowest precedence) map.entry(key).or_insert(v); } } } Ok(map) } fn kdl_value_to_string(v: &KdlValue) -> Option { match v { KdlValue::Null => None, _ => Some(v.to_string()), } }