Skip to main content

nova_boot/
config.rs

1use serde::{Deserialize, Serialize, de::DeserializeOwned};
2use serde_json::{Map, Value};
3use std::env;
4use std::fs;
5use std::path::{Path, PathBuf};
6use std::sync::Arc;
7use std::time::{Duration, SystemTime};
8use tokio::sync::RwLock;
9use tracing::error;
10
11use crate::NovaError;
12use crate::NovaResult;
13
14pub trait NovaConfigSource {
15    fn load(&self) -> NovaResult<Value>;
16}
17
18pub trait NovaSecretSource {
19    fn resolve(&self, key: &str) -> NovaResult<Option<String>>;
20}
21
22#[derive(Debug, Clone)]
23pub struct NovaConfig<T> {
24    pub inner: T,
25}
26
27impl<T> NovaConfig<T> {
28    pub fn into_inner(self) -> T {
29        self.inner
30    }
31}
32
33#[derive(Clone)]
34pub struct ReloadableConfig<T> {
35    inner: Arc<RwLock<NovaConfig<T>>>,
36}
37
38impl<T> ReloadableConfig<T> {
39    pub fn new(config: NovaConfig<T>) -> Self {
40        Self {
41            inner: Arc::new(RwLock::new(config)),
42        }
43    }
44}
45
46impl<T> ReloadableConfig<T>
47where
48    T: Clone,
49{
50    pub async fn snapshot(&self) -> NovaConfig<T> {
51        self.inner.read().await.clone()
52    }
53
54    pub async fn get(&self) -> T {
55        self.inner.read().await.inner.clone()
56    }
57}
58
59fn build_json_file_config<T>(
60    path: &Path,
61    defaults: &Option<T>,
62    env_prefix: &Option<String>,
63) -> NovaResult<NovaConfig<T>>
64where
65    T: Serialize + DeserializeOwned + Clone,
66{
67    let mut builder = NovaConfigBuilder::new().with_json_file(path.to_path_buf());
68
69    if let Some(defaults) = defaults.clone() {
70        builder = builder.defaults(defaults);
71    }
72
73    if let Some(prefix) = env_prefix.as_deref() {
74        builder = builder.with_env_prefix(prefix);
75    }
76
77    builder.build()
78}
79
80/// Start a background poller that reloads JSON config when file mtime changes.
81///
82/// This function must be called from within an active Tokio runtime.
83pub fn spawn_json_file_hot_reloader<T>(
84    path: impl Into<PathBuf>,
85    defaults: Option<T>,
86    env_prefix: Option<String>,
87    poll_interval: Duration,
88) -> NovaResult<ReloadableConfig<T>>
89where
90    T: Serialize + DeserializeOwned + Clone + Send + Sync + 'static,
91{
92    let path = path.into();
93    let initial = build_json_file_config(&path, &defaults, &env_prefix)?;
94    let holder = ReloadableConfig::new(initial);
95    let holder_task = holder.clone();
96    let watched_path = path.clone();
97
98    tokio::spawn(async move {
99        let mut last_modified: Option<SystemTime> = fs::metadata(&watched_path)
100            .ok()
101            .and_then(|meta| meta.modified().ok());
102
103        loop {
104            tokio::time::sleep(poll_interval).await;
105
106            let current_modified = fs::metadata(&watched_path)
107                .ok()
108                .and_then(|meta| meta.modified().ok());
109
110            if current_modified.is_none() || current_modified == last_modified {
111                continue;
112            }
113
114            match build_json_file_config(&watched_path, &defaults, &env_prefix) {
115                Ok(new_config) => {
116                    let mut lock = holder_task.inner.write().await;
117                    *lock = new_config;
118                    last_modified = current_modified;
119                    println!("hot-reloaded config from {}", watched_path.display());
120                }
121                Err(err) => {
122                    error!(
123                        "failed to hot-reload config from {}: {}",
124                        watched_path.display(),
125                        err
126                    );
127                }
128            }
129        }
130    });
131
132    Ok(holder)
133}
134
135pub struct NovaConfigBuilder<T> {
136    defaults: Option<T>,
137    sources: Vec<Box<dyn NovaConfigSource>>,
138    secret_sources: Vec<Box<dyn NovaSecretSource>>,
139}
140
141impl<T> Default for NovaConfigBuilder<T> {
142    fn default() -> Self {
143        Self {
144            defaults: None,
145            sources: Vec::new(),
146            secret_sources: Vec::new(),
147        }
148    }
149}
150
151impl<T> NovaConfigBuilder<T>
152where
153    T: Serialize + DeserializeOwned,
154{
155    pub fn new() -> Self {
156        Self::default()
157    }
158
159    pub fn defaults(mut self, defaults: T) -> Self {
160        self.defaults = Some(defaults);
161        self
162    }
163
164    pub fn with_source<S>(mut self, source: S) -> Self
165    where
166        S: NovaConfigSource + 'static,
167    {
168        self.sources.push(Box::new(source));
169        self
170    }
171
172    pub fn with_secret_source<S>(mut self, source: S) -> Self
173    where
174        S: NovaSecretSource + 'static,
175    {
176        self.secret_sources.push(Box::new(source));
177        self
178    }
179
180    pub fn with_env_prefix(self, prefix: impl Into<String>) -> Self {
181        self.with_source(EnvConfigSource::new(prefix))
182    }
183
184    pub fn with_json_file(self, path: impl Into<PathBuf>) -> Self {
185        self.with_source(JsonFileConfigSource::new(path))
186    }
187
188    pub fn build(self) -> NovaResult<NovaConfig<T>> {
189        let mut merged = if let Some(defaults) = self.defaults {
190            serde_json::to_value(defaults)?
191        } else {
192            Value::Object(Map::new())
193        };
194
195        for source in self.sources {
196            let value = source.load()?;
197            merge_value(&mut merged, value);
198        }
199
200        let resolved = resolve_secrets(merged, &self.secret_sources)?;
201        let inner = serde_json::from_value(resolved)?;
202
203        Ok(NovaConfig { inner })
204    }
205}
206
207pub struct EnvConfigSource {
208    prefix: String,
209}
210
211impl EnvConfigSource {
212    pub fn new(prefix: impl Into<String>) -> Self {
213        Self {
214            prefix: prefix.into(),
215        }
216    }
217}
218
219impl NovaConfigSource for EnvConfigSource {
220    fn load(&self) -> NovaResult<Value> {
221        let mut root = Value::Object(Map::new());
222        let prefix = self.prefix.to_uppercase();
223
224        for (key, raw_value) in env::vars() {
225            let normalized_key = key.to_uppercase();
226            if !normalized_key.starts_with(&prefix) {
227                continue;
228            }
229
230            let trimmed = normalized_key
231                .trim_start_matches(&prefix)
232                .trim_start_matches('_');
233
234            if trimmed.is_empty() {
235                continue;
236            }
237
238            let path: Vec<String> = trimmed
239                .split("__")
240                .filter(|segment| !segment.is_empty())
241                .map(|segment| segment.to_lowercase())
242                .collect();
243
244            if path.is_empty() {
245                continue;
246            }
247
248            set_value_at_path(&mut root, &path, parse_env_value(&raw_value));
249        }
250
251        Ok(root)
252    }
253}
254
255pub struct JsonFileConfigSource {
256    path: PathBuf,
257}
258
259impl JsonFileConfigSource {
260    pub fn new(path: impl Into<PathBuf>) -> Self {
261        Self { path: path.into() }
262    }
263}
264
265impl NovaConfigSource for JsonFileConfigSource {
266    fn load(&self) -> NovaResult<Value> {
267        let contents = fs::read_to_string(&self.path)?;
268        Ok(serde_json::from_str(&contents)?)
269    }
270}
271
272fn merge_value(base: &mut Value, overlay: Value) {
273    match (base, overlay) {
274        (Value::Object(base_map), Value::Object(overlay_map)) => {
275            for (key, value) in overlay_map {
276                match base_map.get_mut(&key) {
277                    Some(existing) => merge_value(existing, value),
278                    None => {
279                        base_map.insert(key, value);
280                    }
281                }
282            }
283        }
284        (base_slot, overlay_value) => {
285            *base_slot = overlay_value;
286        }
287    }
288}
289
290fn set_value_at_path(root: &mut Value, path: &[String], value: Value) {
291    if path.is_empty() {
292        *root = value;
293        return;
294    }
295
296    let Some((head, tail)) = path.split_first() else {
297        return;
298    };
299
300    if !root.is_object() {
301        *root = Value::Object(Map::new());
302    }
303
304    let object = root.as_object_mut().expect("root must be object");
305
306    if tail.is_empty() {
307        object.insert(head.clone(), value);
308        return;
309    }
310
311    let next = object
312        .entry(head.clone())
313        .or_insert_with(|| Value::Object(Map::new()));
314
315    set_value_at_path(next, tail, value);
316}
317
318fn parse_env_value(raw_value: &str) -> Value {
319    serde_json::from_str(raw_value).unwrap_or_else(|_| Value::String(raw_value.to_string()))
320}
321
322fn resolve_secrets(
323    value: Value,
324    secret_sources: &[Box<dyn NovaSecretSource>],
325) -> NovaResult<Value> {
326    match value {
327        Value::String(text) => {
328            if let Some(secret_key) = text.strip_prefix("secret://") {
329                for source in secret_sources {
330                    if let Some(secret_value) = source.resolve(secret_key)? {
331                        return Ok(Value::String(secret_value));
332                    }
333                }
334
335                Err(NovaError::NotFound(format!(
336                    "secret '{secret_key}' was not resolved"
337                )))
338            } else {
339                Ok(Value::String(text))
340            }
341        }
342        Value::Array(items) => {
343            let mut resolved = Vec::with_capacity(items.len());
344            for item in items {
345                resolved.push(resolve_secrets(item, secret_sources)?);
346            }
347            Ok(Value::Array(resolved))
348        }
349        Value::Object(map) => {
350            let mut resolved = Map::new();
351            for (key, item) in map {
352                resolved.insert(key, resolve_secrets(item, secret_sources)?);
353            }
354            Ok(Value::Object(resolved))
355        }
356        other => Ok(other),
357    }
358}
359
360#[derive(Debug, Clone)]
361pub struct MapSecretSource {
362    entries: std::collections::HashMap<String, String>,
363}
364
365impl MapSecretSource {
366    pub fn new(entries: std::collections::HashMap<String, String>) -> Self {
367        Self { entries }
368    }
369}
370
371impl NovaSecretSource for MapSecretSource {
372    fn resolve(&self, key: &str) -> NovaResult<Option<String>> {
373        Ok(self.entries.get(key).cloned())
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use serde::{Deserialize, Serialize};
381    use std::collections::HashMap;
382    use std::fs;
383
384    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
385    struct TestConfig {
386        host: String,
387        port: u16,
388        nested: NestedConfig,
389        secret: String,
390    }
391
392    #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
393    struct NestedConfig {
394        enabled: bool,
395    }
396
397    #[test]
398    fn layers_env_over_file_over_defaults() {
399        let mut path = std::env::temp_dir();
400        path.push(format!("nova-config-{}.json", std::process::id()));
401        fs::write(
402            &path,
403            r#"{
404                "host": "file-host",
405                "nested": {"enabled": false},
406                "secret": "secret://api_key"
407            }"#,
408        )
409        .expect("write temp config");
410
411        let mut secrets = HashMap::new();
412        secrets.insert("api_key".to_string(), "resolved-secret".to_string());
413
414        let config = NovaConfigBuilder::new()
415            .defaults(TestConfig {
416                host: "default-host".into(),
417                port: 8080,
418                nested: NestedConfig { enabled: true },
419                secret: "default-secret".into(),
420            })
421            .with_json_file(&path)
422            .with_secret_source(MapSecretSource::new(secrets))
423            .build()
424            .expect("build config");
425
426        assert_eq!(config.inner.host, "file-host");
427        assert_eq!(config.inner.port, 8080);
428        assert!(!config.inner.nested.enabled);
429        assert_eq!(config.inner.secret, "resolved-secret");
430
431        let _ = fs::remove_file(path);
432    }
433
434    #[test]
435    fn env_source_supports_nested_paths() {
436        let source = EnvConfigSource::new("NOVA_TEST");
437
438        let mut root = source.load().expect("load env source");
439        set_value_at_path(
440            &mut root,
441            &["nested".into(), "enabled".into()],
442            Value::Bool(true),
443        );
444
445        assert_eq!(root["nested"]["enabled"], Value::Bool(true));
446    }
447}
448
449// Resilience configuration types
450#[derive(Debug, Clone, Serialize, Deserialize)]
451#[serde(tag = "type", rename_all = "lowercase")]
452pub enum ResilienceBackend {
453    Local,
454    Redis { url: String, prefix: Option<String> },
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
458pub struct CircuitBreakerConfig {
459    pub backend: ResilienceBackend,
460    pub threshold: u32,
461    /// TTL in seconds for open state when using distributed backend
462    pub open_ttl_seconds: usize,
463}
464
465impl Default for CircuitBreakerConfig {
466    fn default() -> Self {
467        Self {
468            backend: ResilienceBackend::Local,
469            threshold: 5,
470            open_ttl_seconds: 60,
471        }
472    }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct RateLimiterConfig {
477    pub backend: ResilienceBackend,
478    pub capacity: i64,
479    pub window_seconds: usize,
480    pub prefix: Option<String>,
481}
482
483impl Default for RateLimiterConfig {
484    fn default() -> Self {
485        Self {
486            backend: ResilienceBackend::Local,
487            capacity: 100,
488            window_seconds: 60,
489            prefix: None,
490        }
491    }
492}
493
494#[derive(Debug, Clone, Serialize, Deserialize, Default)]
495pub struct ResilienceConfig {
496    pub circuit_breaker: Option<CircuitBreakerConfig>,
497    pub rate_limiter: Option<RateLimiterConfig>,
498}