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