Skip to main content

sova_core/
config.rs

1//! Load app-level settings from `sova.toml` (no per-route addressing).
2//!
3//! # Document shape
4//!
5//! ```toml
6//! [server]
7//! max_body = "2mb"
8//! trust_proxy = false
9//!
10//! [mail]
11//! from = "App <noreply@example.com>"
12//!
13//! [development.server]
14//! # profile overlay
15//!
16//! [production.server]
17//! trust_proxy = true
18//! ```
19//!
20//! Active profile: `SOVA_PROFILE` → else `SOVA_ENV` → else
21//! `development` (debug builds) / `production` (release). Aliases:
22//! `debug`→`development`, `release`→`production`.
23//!
24//! Legacy: server keys under `[default]` / `[debug]` / `[release]`, and
25//! `[default.mail]` instead of top-level `[mail]`, still work.
26
27use crate::app::App;
28use crate::error::{Error, Result};
29use crate::human::{parse_bytes, parse_duration};
30use serde::Deserialize;
31use std::collections::HashMap;
32use std::path::Path;
33use std::sync::Arc;
34use std::time::Duration;
35
36/// Full parsed `sova.toml` (raw root for plugins; profile name for overlays).
37#[derive(Debug, Clone)]
38pub struct ConfigDoc {
39    /// Original root table (`server`, `mail`, profile names, …).
40    pub root: toml::Value,
41    /// Active profile (`development` / `production` / `test` / custom).
42    pub profile: String,
43}
44
45impl ConfigDoc {
46    /// Merge `[section]` + `[default.section]` + `[<profile>.section]` (shallow).
47    pub fn section(&self, section: &str) -> Option<toml::map::Map<String, toml::Value>> {
48        let mut out = toml::map::Map::new();
49        if let Some(toml::Value::Table(t)) = self.root.get(section) {
50            // Top-level `[mail]` etc. — skip if this table looks like a profile
51            // container with only nested section tables and no scalar keys for
52            // leaf sections. For leaf sections (mail, storage, …) take as-is.
53            // `[server]` is a leaf; `[http]` is nested clients (handled separately).
54            if section != "http" {
55                out.extend(t.clone());
56            }
57        }
58        if let Some(toml::Value::Table(t)) = self
59            .root
60            .get("default")
61            .and_then(|d| d.get(section))
62        {
63            out.extend(t.clone());
64        }
65        if let Some(toml::Value::Table(t)) = self
66            .root
67            .get(&self.profile)
68            .and_then(|d| d.get(section))
69        {
70            out.extend(t.clone());
71        }
72        // Legacy profile aliases: also merge debug/release overlays when active
73        // profile is development/production (in case file still uses old names).
74        for alias in profile_aliases_for_merge(&self.profile) {
75            if let Some(toml::Value::Table(t)) =
76                self.root.get(alias).and_then(|d| d.get(section))
77            {
78                out.extend(t.clone());
79            }
80        }
81        if out.is_empty() {
82            None
83        } else {
84            Some(out)
85        }
86    }
87
88    /// Named client tables under `http` (`[http.payments]` / `[default.http.payments]`).
89    pub fn http_clients(&self) -> HashMap<String, toml::map::Map<String, toml::Value>> {
90        let mut out = HashMap::new();
91        let merge_into = |dest: &mut HashMap<String, toml::map::Map<String, toml::Value>>,
92                          table: &toml::map::Map<String, toml::Value>| {
93            for (name, val) in table {
94                if let toml::Value::Table(t) = val {
95                    dest.entry(name.clone())
96                        .and_modify(|e| e.extend(t.clone()))
97                        .or_insert_with(|| t.clone());
98                }
99            }
100        };
101        if let Some(toml::Value::Table(http)) = self.root.get("http") {
102            merge_into(&mut out, http);
103        }
104        if let Some(toml::Value::Table(http)) = self.root.get("default").and_then(|d| d.get("http"))
105        {
106            merge_into(&mut out, http);
107        }
108        if let Some(toml::Value::Table(http)) =
109            self.root.get(&self.profile).and_then(|d| d.get("http"))
110        {
111            merge_into(&mut out, http);
112        }
113        for alias in profile_aliases_for_merge(&self.profile) {
114            if let Some(toml::Value::Table(http)) =
115                self.root.get(alias).and_then(|d| d.get("http"))
116            {
117                merge_into(&mut out, http);
118            }
119        }
120        out
121    }
122}
123
124/// Extra profile table names to merge when the active profile is the modern name.
125fn profile_aliases_for_merge(profile: &str) -> &'static [&'static str] {
126    match profile {
127        "development" => &["debug"],
128        "production" => &["release"],
129        _ => &[],
130    }
131}
132
133#[derive(Debug, Default, Deserialize, Clone)]
134#[serde(default)]
135struct ServerProfile {
136    max_body: Option<String>,
137    max_connections: Option<usize>,
138    max_upgraded_connections: Option<usize>,
139    max_concurrent_streams: Option<usize>,
140    max_headers: Option<usize>,
141    max_buf_size: Option<String>,
142    request_timeout: Option<String>,
143    header_read_timeout: Option<String>,
144    idle_timeout: Option<String>,
145    drain_timeout: Option<String>,
146    keep_alive: Option<bool>,
147    trust_proxy: Option<bool>,
148}
149
150fn normalize_profile_name(raw: &str) -> String {
151    match raw.trim() {
152        "debug" => "development".into(),
153        "release" => "production".into(),
154        other => other.to_string(),
155    }
156}
157
158fn active_profile() -> String {
159    if let Ok(p) = std::env::var("SOVA_PROFILE") {
160        return normalize_profile_name(&p);
161    }
162    if let Ok(p) = std::env::var("SOVA_ENV") {
163        return normalize_profile_name(&p);
164    }
165    if cfg!(debug_assertions) {
166        "development".into()
167    } else {
168        "production".into()
169    }
170}
171
172fn merge_server(base: ServerProfile, over: ServerProfile) -> ServerProfile {
173    ServerProfile {
174        max_body: over.max_body.or(base.max_body),
175        max_connections: over.max_connections.or(base.max_connections),
176        max_upgraded_connections: over
177            .max_upgraded_connections
178            .or(base.max_upgraded_connections),
179        max_concurrent_streams: over
180            .max_concurrent_streams
181            .or(base.max_concurrent_streams),
182        max_headers: over.max_headers.or(base.max_headers),
183        max_buf_size: over.max_buf_size.or(base.max_buf_size),
184        request_timeout: over.request_timeout.or(base.request_timeout),
185        header_read_timeout: over.header_read_timeout.or(base.header_read_timeout),
186        idle_timeout: over.idle_timeout.or(base.idle_timeout),
187        drain_timeout: over.drain_timeout.or(base.drain_timeout),
188        keep_alive: over.keep_alive.or(base.keep_alive),
189        trust_proxy: over.trust_proxy.or(base.trust_proxy),
190    }
191}
192
193fn table_to_server(table: &toml::map::Map<String, toml::Value>) -> ServerProfile {
194    // Re-serialize subset so we can reuse Deserialize (ignore unknown keys).
195    let mut filtered = toml::map::Map::new();
196    for key in [
197        "max_body",
198        "max_connections",
199        "max_upgraded_connections",
200        "max_concurrent_streams",
201        "max_headers",
202        "max_buf_size",
203        "request_timeout",
204        "header_read_timeout",
205        "idle_timeout",
206        "drain_timeout",
207        "keep_alive",
208        "trust_proxy",
209    ] {
210        if let Some(v) = table.get(key) {
211            filtered.insert(key.to_string(), v.clone());
212        }
213    }
214    toml::Value::Table(filtered)
215        .try_into()
216        .unwrap_or_default()
217}
218
219/// Resolve merged `[server]` from canon + legacy layouts.
220fn resolve_server(root: &toml::Value, profile: &str) -> ServerProfile {
221    let mut merged = ServerProfile::default();
222
223    // Canon: [server]
224    if let Some(toml::Value::Table(t)) = root.get("server") {
225        merged = merge_server(merged, table_to_server(t));
226    }
227
228    // Legacy: flat server keys under [default]
229    if let Some(toml::Value::Table(t)) = root.get("default") {
230        // Prefer nested [default.server] if present; else treat flat keys as server.
231        if let Some(toml::Value::Table(server)) = t.get("server") {
232            merged = merge_server(merged, table_to_server(server));
233        } else {
234            merged = merge_server(merged, table_to_server(t));
235        }
236    }
237
238    // Profile overlays: [development.server], [production.server], …
239    let mut names = vec![profile];
240    names.extend(profile_aliases_for_merge(profile).iter().copied());
241    for name in names {
242        if let Some(toml::Value::Table(prof)) = root.get(name) {
243            if let Some(toml::Value::Table(server)) = prof.get("server") {
244                merged = merge_server(merged, table_to_server(server));
245            } else {
246                // Legacy: flat keys under [debug] / [release] / [development]
247                merged = merge_server(merged, table_to_server(prof));
248            }
249        }
250    }
251
252    merged
253}
254
255fn apply_server(app: &mut App, p: &ServerProfile) -> Result<()> {
256    if let Some(ref s) = p.max_body {
257        app.max_body_size(parse_bytes(s).map_err(Error::Internal)?);
258    }
259    if let Some(n) = p.max_connections {
260        app.max_connections(n);
261    }
262    if let Some(n) = p.max_upgraded_connections {
263        app.max_upgraded_connections(n);
264    }
265    if let Some(n) = p.max_concurrent_streams {
266        app.max_concurrent_streams(n);
267    }
268    if let Some(n) = p.max_headers {
269        app.max_headers(n);
270    }
271    if let Some(ref s) = p.max_buf_size {
272        app.max_buf_size(parse_bytes(s).map_err(Error::Internal)?);
273    }
274    if let Some(ref s) = p.request_timeout {
275        if s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("none") {
276            app.request_timeout(None);
277        } else {
278            app.request_timeout(Some(parse_duration(s).map_err(Error::Internal)?));
279        }
280    }
281    if let Some(ref s) = p.header_read_timeout {
282        app.header_read_timeout(parse_duration(s).map_err(Error::Internal)?);
283    }
284    if let Some(ref s) = p.idle_timeout {
285        app.idle_timeout(parse_duration(s).map_err(Error::Internal)?);
286    }
287    if let Some(ref s) = p.drain_timeout {
288        app.drain_timeout(parse_duration(s).map_err(Error::Internal)?);
289    }
290    if let Some(v) = p.keep_alive {
291        app.keep_alive(v);
292    }
293    if let Some(v) = p.trust_proxy {
294        app.trust_proxy(v);
295    }
296    Ok(())
297}
298
299fn parse_timeout_env(s: &str) -> Result<Option<Duration>> {
300    if s.eq_ignore_ascii_case("off") || s.eq_ignore_ascii_case("none") {
301        Ok(None)
302    } else {
303        Ok(Some(parse_duration(s).map_err(Error::Internal)?))
304    }
305}
306
307fn env_override(app: &mut App) -> Result<()> {
308    if let Ok(s) = std::env::var("SOVA_MAX_BODY") {
309        app.max_body_size(parse_bytes(&s).map_err(Error::Internal)?);
310    }
311    if let Ok(s) = std::env::var("SOVA_MAX_CONNECTIONS") {
312        let n: usize = s
313            .parse()
314            .map_err(|_| Error::Internal(format!("SOVA_MAX_CONNECTIONS: {s}")))?;
315        app.max_connections(n);
316    }
317    if let Ok(s) = std::env::var("SOVA_MAX_UPGRADED_CONNECTIONS") {
318        let n: usize = s.parse().map_err(|_| {
319            Error::Internal(format!("SOVA_MAX_UPGRADED_CONNECTIONS: {s}"))
320        })?;
321        app.max_upgraded_connections(n);
322    }
323    if let Ok(s) = std::env::var("SOVA_MAX_CONCURRENT_STREAMS") {
324        let n: usize = s.parse().map_err(|_| {
325            Error::Internal(format!("SOVA_MAX_CONCURRENT_STREAMS: {s}"))
326        })?;
327        app.max_concurrent_streams(n);
328    }
329    if let Ok(s) = std::env::var("SOVA_MAX_HEADERS") {
330        let n: usize = s
331            .parse()
332            .map_err(|_| Error::Internal(format!("SOVA_MAX_HEADERS: {s}")))?;
333        app.max_headers(n);
334    }
335    if let Ok(s) = std::env::var("SOVA_MAX_BUF_SIZE") {
336        app.max_buf_size(parse_bytes(&s).map_err(Error::Internal)?);
337    }
338    if let Ok(s) = std::env::var("SOVA_REQUEST_TIMEOUT") {
339        app.request_timeout(parse_timeout_env(&s)?);
340    }
341    if let Ok(s) = std::env::var("SOVA_HEADER_READ_TIMEOUT") {
342        app.header_read_timeout(parse_duration(&s).map_err(Error::Internal)?);
343    }
344    if let Ok(s) = std::env::var("SOVA_IDLE_TIMEOUT") {
345        app.idle_timeout(parse_duration(&s).map_err(Error::Internal)?);
346    }
347    if let Ok(s) = std::env::var("SOVA_DRAIN_TIMEOUT") {
348        app.drain_timeout(parse_duration(&s).map_err(Error::Internal)?);
349    }
350    if let Ok(s) = std::env::var("SOVA_KEEP_ALIVE") {
351        let v = matches!(s.as_str(), "1" | "true" | "TRUE" | "yes");
352        app.keep_alive(v);
353    }
354    if let Ok(s) = std::env::var("SOVA_TRUST_PROXY") {
355        let v = matches!(s.as_str(), "1" | "true" | "TRUE" | "yes");
356        app.trust_proxy(v);
357    }
358    Ok(())
359}
360
361impl App {
362    /// Load `sova.toml` or `Sova.toml` from the current directory, then env overrides.
363    ///
364    /// Missing file is not an error — only `SOVA_*` env overrides apply.
365    pub fn configure(&mut self) -> Result<&mut Self> {
366        for name in ["sova.toml", "Sova.toml"] {
367            let path = Path::new(name);
368            if path.is_file() {
369                return self.configure_from_path(path);
370            }
371        }
372        env_override(self)?;
373        Ok(self)
374    }
375
376    /// Load settings from a toml file (app-level only), then `SOVA_*` env overrides.
377    pub fn configure_from_path(&mut self, path: impl AsRef<Path>) -> Result<&mut Self> {
378        let text = std::fs::read_to_string(path.as_ref()).map_err(Error::Io)?;
379        self.configure_from_str(&text)?;
380        Ok(self)
381    }
382
383    /// Parse toml and apply `[server]` (+ legacy) for the active profile, then env overrides.
384    pub fn configure_from_str(&mut self, text: &str) -> Result<&mut Self> {
385        let root: toml::Value =
386            toml::from_str(text).map_err(|e| Error::Internal(format!("sova.toml: {e}")))?;
387        let profile_name = active_profile();
388        self.state(ConfigDoc {
389            root: root.clone(),
390            profile: profile_name.clone(),
391        });
392
393        let server = resolve_server(&root, &profile_name);
394        apply_server(self, &server)?;
395        env_override(self)?;
396        Ok(self)
397    }
398
399    /// `App::new()` + [`Self::configure_from_path`].
400    pub fn from_toml(path: impl AsRef<Path>) -> Result<Self> {
401        let mut app = App::new();
402        app.configure_from_path(path)?;
403        Ok(app)
404    }
405
406    /// Shared [`ConfigDoc`] from the last successful [`Self::configure_from_str`], if any.
407    pub fn config_doc(&self) -> Option<Arc<ConfigDoc>> {
408        self.state.get::<ConfigDoc>()
409    }
410}
411
412#[cfg(test)]
413mod tests {
414    use super::*;
415    use std::sync::Mutex;
416    use std::time::Duration;
417
418    static ENV_LOCK: Mutex<()> = Mutex::new(());
419
420    fn with_profile(profile: &str, f: impl FnOnce()) {
421        let _guard = ENV_LOCK.lock().unwrap();
422        let prev_profile = std::env::var("SOVA_PROFILE").ok();
423        let prev_env = std::env::var("SOVA_ENV").ok();
424        std::env::set_var("SOVA_PROFILE", profile);
425        std::env::remove_var("SOVA_ENV");
426        f();
427        match prev_profile {
428            Some(v) => std::env::set_var("SOVA_PROFILE", v),
429            None => std::env::remove_var("SOVA_PROFILE"),
430        }
431        match prev_env {
432            Some(v) => std::env::set_var("SOVA_ENV", v),
433            None => std::env::remove_var("SOVA_ENV"),
434        }
435    }
436
437    #[test]
438    fn parses_canon_server_and_development_overlay() {
439        with_profile("development", || {
440            let mut app = App::new();
441            app.configure_from_str(
442                r#"
443[server]
444max_body = "2 MiB"
445request_timeout = "30s"
446max_connections = 100
447
448[development.server]
449max_connections = 10
450"#,
451            )
452            .unwrap();
453            assert_eq!(app.max_connections, 10);
454            assert_eq!(app.max_body_size, 2 * 1024 * 1024);
455            assert_eq!(app.request_timeout, Some(Duration::from_secs(30)));
456            assert_eq!(app.config_doc().unwrap().profile, "development");
457        });
458    }
459
460    #[test]
461    fn legacy_default_debug_still_works() {
462        with_profile("development", || {
463            let mut app = App::new();
464            app.configure_from_str(
465                r#"
466[default]
467max_body = "2 MiB"
468request_timeout = "30s"
469max_connections = 100
470
471[debug]
472max_connections = 10
473"#,
474            )
475            .unwrap();
476            assert_eq!(app.max_connections, 10);
477            assert_eq!(app.max_body_size, 2 * 1024 * 1024);
478        });
479    }
480
481    #[test]
482    fn section_merges_top_level_and_profile() {
483        with_profile("production", || {
484            let mut app = App::new();
485            app.configure_from_str(
486                r#"
487[mail]
488from = "base@example.com"
489
490[production.mail]
491from = "prod@example.com"
492"#,
493            )
494            .unwrap();
495            let doc = app.config_doc().unwrap();
496            assert_eq!(doc.profile, "production");
497            let mail = doc.section("mail").unwrap();
498            assert_eq!(
499                mail.get("from").and_then(|v| v.as_str()),
500                Some("prod@example.com")
501            );
502        });
503    }
504
505    #[test]
506    fn stores_http_named_clients_in_config_doc() {
507        with_profile("development", || {
508            let mut app = App::new();
509            app.configure_from_str(
510                r#"
511[server]
512request_timeout = "30s"
513
514[http.payments]
515base_url = "https://api.stripe.com"
516timeout = "10s"
517
518[development.http.payments]
519timeout = "5s"
520"#,
521            )
522            .unwrap();
523            let doc = app.config_doc().unwrap();
524            let clients = doc.http_clients();
525            let p = clients.get("payments").unwrap();
526            assert_eq!(
527                p.get("base_url").and_then(|v| v.as_str()),
528                Some("https://api.stripe.com")
529            );
530            assert_eq!(p.get("timeout").and_then(|v| v.as_str()), Some("5s"));
531        });
532    }
533
534    #[test]
535    fn debug_alias_normalizes_to_development() {
536        assert_eq!(normalize_profile_name("debug"), "development");
537        assert_eq!(normalize_profile_name("release"), "production");
538    }
539}