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