Skip to main content

gearbox_rs_core/
config.rs

1use config::{Config as ConfigLoader, Environment, File};
2use dashmap::DashMap;
3use serde::Deserialize;
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use std::any::{Any, TypeId, type_name};
7use std::sync::Arc;
8
9use crate::error::Error;
10
11/// Trait for configuration structs.
12pub trait CogConfig: DeserializeOwned + Default + Send + Sync + 'static {
13    const CONFIG_KEY: &'static str;
14
15    /// Optional validation hook called after deserialization during startup.
16    ///
17    /// Return `Err(message)` to abort startup with a clear diagnostic.
18    fn validate(&self) -> Result<(), String> {
19        Ok(())
20    }
21}
22
23/// Metadata registered via inventory for each config type.
24pub struct ConfigMeta {
25    pub key: &'static str,
26    pub type_id_fn: fn() -> TypeId,
27    pub type_name: &'static str,
28    pub deserialize_fn: fn(&Value) -> Result<Box<dyn Any + Send + Sync>, serde_json::Error>,
29    pub validate_fn: fn(&dyn Any) -> Result<(), String>,
30}
31
32inventory::collect!(ConfigMeta);
33
34#[derive(Debug, Clone, Deserialize)]
35#[serde(default)]
36pub struct GearboxAppConfig {
37    pub http_port: u16,
38    pub log_level: String,
39    pub app_name: String,
40}
41
42impl Default for GearboxAppConfig {
43    fn default() -> Self {
44        Self {
45            http_port: 8080,
46            log_level: "info".to_string(),
47            app_name: "gearbox-app".to_string(),
48        }
49    }
50}
51
52pub struct Config {
53    raw: Value,
54    configs: DashMap<TypeId, Arc<dyn Any + Send + Sync>>,
55    app: GearboxAppConfig,
56    defaulted_keys: Vec<(&'static str, &'static str)>,
57}
58
59impl Config {
60    pub fn load() -> Result<Self, Error> {
61        let config_path =
62            std::env::var("CONFIG_LOCATION").unwrap_or_else(|_| "./config.toml".to_string());
63
64        let mut builder = ConfigLoader::builder();
65
66        if std::path::Path::new(&config_path).exists() {
67            builder = builder.add_source(File::with_name(&config_path));
68        }
69
70        builder = builder.add_source(
71            Environment::with_prefix("GEARBOX")
72                .separator("__")
73                .try_parsing(true),
74        );
75
76        let raw: Value = builder.build()?.try_deserialize()?;
77
78        // Debug: print all GEARBOX env vars
79        for (key, value) in std::env::vars() {
80            if key.starts_with("GEARBOX") {
81                tracing::debug!("env override: {}={}", key, value);
82            }
83        }
84        tracing::debug!("raw config: {:?}", raw);
85
86        Self::from_value(raw)
87    }
88
89    /// Build a Config from a pre-built `serde_json::Value`, running inventory
90    /// deserialization and validation for all registered `ConfigMeta` types.
91    pub fn from_value(raw: Value) -> Result<Self, Error> {
92        let app: GearboxAppConfig = raw
93            .get("gearbox_app")
94            .map(|v| serde_json::from_value(v.clone()))
95            .transpose()
96            .map_err(|e| Error::ConfigDeserialize("GearboxAppConfig".into(), Box::new(e)))?
97            .unwrap_or_else(|| {
98                tracing::debug!("config section 'gearbox_app' not found, using defaults");
99                GearboxAppConfig::default()
100            });
101
102        let configs = DashMap::new();
103        let mut defaulted_keys = Vec::new();
104
105        for meta in inventory::iter::<ConfigMeta> {
106            let section = match raw.get(meta.key).cloned() {
107                Some(v) => v,
108                None => {
109                    defaulted_keys.push((meta.key, meta.type_name));
110                    tracing::debug!(
111                        "config section '{}' ({}) not found, using defaults",
112                        meta.key,
113                        meta.type_name,
114                    );
115                    Value::Object(Default::default())
116                }
117            };
118            tracing::debug!("loading config section {:?}", section);
119            let config = (meta.deserialize_fn)(&section)
120                .map_err(|e| Error::ConfigDeserialize(meta.type_name.to_string(), Box::new(e)))?;
121            (meta.validate_fn)(config.as_ref())
122                .map_err(|msg| Error::ConfigValidation(meta.type_name.to_string(), msg))?;
123            configs.insert((meta.type_id_fn)(), Arc::from(config));
124        }
125
126        // Warn about TOML sections that no registered config claims (possible typos)
127        if let Some(obj) = raw.as_object() {
128            let registered_keys: std::collections::HashSet<&str> =
129                inventory::iter::<ConfigMeta>.into_iter().map(|m| m.key).collect();
130            for key in obj.keys() {
131                if key != "gearbox_app" && !registered_keys.contains(key.as_str()) {
132                    tracing::warn!(
133                        "config section '{}' found in file but no #[cog_config(\"{}\")] is registered — possible typo?",
134                        key, key,
135                    );
136                }
137            }
138        }
139
140        Ok(Self {
141            raw,
142            configs,
143            app,
144            defaulted_keys,
145        })
146    }
147
148    /// Insert a typed config directly, bypassing TOML/env loading.
149    pub fn insert<C: CogConfig + Clone + 'static>(&self, config: C) {
150        self.configs.insert(TypeId::of::<C>(), Arc::new(config));
151    }
152
153    /// Override the app config.
154    pub(crate) fn set_app(&mut self, app: GearboxAppConfig) {
155        self.app = app;
156    }
157
158    pub fn get<C: CogConfig + Clone>(&self) -> Result<C, Error> {
159        self.configs
160            .get(&TypeId::of::<C>())
161            .and_then(|v| v.value().downcast_ref::<C>().cloned())
162            .ok_or_else(|| Error::ConfigNotFound(type_name::<C>().to_string()))
163    }
164
165    pub fn app(&self) -> &GearboxAppConfig {
166        &self.app
167    }
168
169    pub fn raw(&self) -> &Value {
170        &self.raw
171    }
172
173    /// Returns config sections that fell back to defaults during loading.
174    ///
175    /// Each entry is `(config_key, type_name)`.
176    pub fn defaulted_configs(&self) -> &[(&str, &str)] {
177        &self.defaulted_keys
178    }
179}
180
181impl Default for Config {
182    fn default() -> Self {
183        Self {
184            raw: Value::Object(Default::default()),
185            configs: DashMap::new(),
186            app: GearboxAppConfig::default(),
187            defaulted_keys: Vec::new(),
188        }
189    }
190}
191
192#[doc(hidden)]
193pub fn deserialize_config<T: DeserializeOwned + Send + Sync + 'static>(
194    value: &Value,
195) -> Result<Box<dyn Any + Send + Sync>, serde_json::Error> {
196    Ok(Box::new(serde_json::from_value::<T>(value.clone())?))
197}
198
199#[doc(hidden)]
200pub fn validate_config<T: CogConfig + 'static>(value: &dyn Any) -> Result<(), String> {
201    value
202        .downcast_ref::<T>()
203        .ok_or_else(|| {
204            format!(
205                "internal error: config type mismatch for {}",
206                type_name::<T>()
207            )
208        })
209        .and_then(|c| c.validate())
210}