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
80pub 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#[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 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}