Skip to main content

umbral_core/
settings.rs

1use figment::Figment;
2use figment::providers::{Env, Format, Serialized, Toml};
3use serde::Deserialize;
4use std::collections::HashSet;
5use std::sync::OnceLock;
6
7/// Ambient settings, published during `AppBuilder::build()`.
8pub(crate) static SETTINGS: OnceLock<Settings> = OnceLock::new();
9
10/// Initialize ambient settings. Called by `AppBuilder::build()` only.
11pub(crate) fn init(settings: &Settings) {
12    // Clone the settings into the OnceLock. The struct is cheap to clone
13    // (strings and vecs) and this avoids forcing the caller to surrender
14    // ownership of the original.
15    SETTINGS
16        .set(settings.clone())
17        .expect("umbral::settings::init called more than once");
18}
19
20/// Return a reference to the ambient settings.
21///
22/// # Panics
23///
24/// Panics if `App::build()` hasn't run.
25pub fn get() -> &'static Settings {
26    SETTINGS
27        .get()
28        .expect("umbral: settings not initialised — did you call App::build()?")
29}
30
31/// Return the ambient settings if they have been initialised, or `None`.
32///
33/// Unlike [`get`], this function never panics. Useful in plugin code
34/// that may run before `App::build()` (e.g. during tests or in
35/// route-builder helpers that check the environment at build time).
36pub fn get_opt() -> Option<&'static Settings> {
37    SETTINGS.get()
38}
39
40fn default_database_url() -> String {
41    // In-memory SQLite so first-run with all defaults works without any
42    // filesystem assumptions (a sqlite:// URL pointing at a non-existent
43    // file errors out without `?mode=rwc`). Real apps override this via
44    // umbral.toml or UMBRAL_DATABASE_URL.
45    "sqlite::memory:".into()
46}
47
48/// Default `Form<T>` body cap: 16 MiB — generous for urlencoded forms while
49/// still a DoS guard, and 8× the old hardcoded 2 MiB. Override via
50/// `UMBRAL_MAX_FORM_BODY_BYTES`, or set `0` to disable.
51fn default_max_form_body_bytes() -> Option<usize> {
52    Some(16 * 1024 * 1024)
53}
54
55fn default_secret_key() -> String {
56    "umbral-insecure-dev-key-change-me".into()
57}
58
59fn default_allowed_hosts() -> Vec<String> {
60    vec!["localhost".into(), "127.0.0.1".into()]
61}
62
63/// Deserialize a `Vec<String>` from either a real sequence (a TOML array, or a
64/// bracketed env value like `["a.com","b.com"]`) OR a single comma-separated
65/// string (`UMBRAL_ALLOWED_HOSTS=a.com,b.com`). Env vars are scalar strings, so
66/// without this a list-valued setting can only be set with the non-obvious
67/// bracketed form; the natural `HOST1,HOST2` comma-separated form would error
68/// with "expected a sequence". Whitespace is trimmed and empty entries dropped.
69fn deserialize_string_list<'de, D>(de: D) -> Result<Vec<String>, D::Error>
70where
71    D: serde::Deserializer<'de>,
72{
73    use serde::Deserialize;
74    #[derive(Deserialize)]
75    #[serde(untagged)]
76    enum OneOrMany {
77        One(String),
78        Many(Vec<String>),
79    }
80    Ok(match OneOrMany::deserialize(de)? {
81        OneOrMany::One(s) => s
82            .split(',')
83            .map(str::trim)
84            .filter(|h| !h.is_empty())
85            .map(str::to_string)
86            .collect(),
87        OneOrMany::Many(v) => v,
88    })
89}
90
91fn default_log_level() -> String {
92    "info".into()
93}
94
95/// PERF-5: pool size default (matches sqlx's own default of 10). Raise
96/// for a high-concurrency Postgres deploy via `UMBRAL_DB_MAX_CONNECTIONS`.
97fn default_db_max_connections() -> u32 {
98    10
99}
100
101/// PERF-5: seconds to wait for a free pooled connection before failing a
102/// request. A bounded timeout means a saturated pool fails fast (503)
103/// instead of blocking the request task forever.
104fn default_db_acquire_timeout_secs() -> u64 {
105    30
106}
107
108/// gaps2 #91: idle-connection floor. `0` means "shrink to zero idle
109/// connections" — sqlx's own default. Raise it on a busy service to keep
110/// warm connections ready (saves the per-request TCP+TLS+auth handshake).
111fn default_db_min_connections() -> u32 {
112    0
113}
114
115/// gaps2 #91: close a connection that's been idle this many seconds.
116/// Default 10 minutes — reclaims connections during quiet periods so the
117/// pool doesn't pin `max_connections` slots on the server forever. `None`
118/// (env `0`/empty) disables idle reaping.
119fn default_db_idle_timeout_secs() -> Option<u64> {
120    Some(600)
121}
122
123/// gaps2 #91: recycle any connection older than this many seconds,
124/// regardless of activity. Default 30 minutes — defends against stale
125/// connections silently dropped by a load balancer or reaped by
126/// Postgres's `idle_in_transaction_session_timeout`. `None` (env
127/// `0`/empty) disables lifetime recycling.
128fn default_db_max_lifetime_secs() -> Option<u64> {
129    Some(1800)
130}
131
132/// gaps2 #91: health-check a pooled connection (a cheap `SELECT`/ping)
133/// before handing it to a caller. Default `true` — a dead connection
134/// (server restarted, network blip) is silently replaced instead of
135/// surfacing as a mid-request error. Set `false` to trade safety for a
136/// few microseconds per acquire on a known-stable network.
137fn default_db_test_before_acquire() -> bool {
138    true
139}
140
141fn default_bind_addr() -> String {
142    // 127.0.0.1 only by default — exposing the server on 0.0.0.0
143    // is a deliberate keystroke. Override with UMBRAL_BIND_ADDR or
144    // umbral.toml.
145    "127.0.0.1:8000".into()
146}
147
148fn default_static_url() -> String {
149    "/static/".into()
150}
151
152fn default_static_root() -> String {
153    "staticfiles/".into()
154}
155
156/// Normalise a `static_url` so it always carries exactly one leading
157/// and one trailing slash. `"/static"`, `"static"`, and `"/static/"`
158/// all converge on `"/static/"`. A CDN-style absolute URL
159/// (`"https://cdn.example.com/s"`) keeps its scheme+host and gains the
160/// trailing slash (`"https://cdn.example.com/s/"`) without acquiring a
161/// spurious leading slash. An empty value normalises to `"/"`.
162///
163/// The leading-slash rule only applies to root-relative paths; a value
164/// that already starts with `http://`, `https://`, or `//` is treated
165/// as absolute and left with its prefix intact.
166fn normalize_static_url(raw: &str) -> String {
167    let trimmed = raw.trim();
168    let is_absolute = trimmed.starts_with("http://")
169        || trimmed.starts_with("https://")
170        || trimmed.starts_with("//");
171
172    let mut out = String::with_capacity(trimmed.len() + 2);
173    if is_absolute {
174        out.push_str(trimmed.trim_end_matches('/'));
175    } else {
176        out.push('/');
177        out.push_str(trimmed.trim_matches('/'));
178    }
179    if !out.ends_with('/') {
180        out.push('/');
181    }
182    out
183}
184
185/// Deserialize and normalise `static_url` in one step so the invariant
186/// (leading + trailing slash) holds no matter the source — toml, env,
187/// or the struct default. Serde applies this to the raw string before
188/// it ever reaches a reader.
189fn deserialize_static_url<'de, D>(de: D) -> Result<String, D::Error>
190where
191    D: serde::Deserializer<'de>,
192{
193    let raw = String::deserialize(de)?;
194    Ok(normalize_static_url(&raw))
195}
196
197/// Deserialize an `Option<u64>` where `0` (and an empty/missing string,
198/// as an env var might supply) maps to `None` — the "disabled" sentinel
199/// for the idle/max-lifetime timeouts (gaps2 #91). Accepts an integer
200/// (toml), a numeric string (env/dotenv), or an explicit null.
201fn deserialize_zero_as_none<'de, D>(de: D) -> Result<Option<u64>, D::Error>
202where
203    D: serde::Deserializer<'de>,
204{
205    use serde::de::Error as _;
206
207    #[derive(Deserialize)]
208    #[serde(untagged)]
209    enum Raw {
210        Int(u64),
211        Str(String),
212        Null,
213    }
214
215    let value = match Option::<Raw>::deserialize(de)? {
216        None | Some(Raw::Null) => return Ok(None),
217        Some(Raw::Int(n)) => n,
218        Some(Raw::Str(s)) => {
219            let trimmed = s.trim();
220            if trimmed.is_empty() {
221                return Ok(None);
222            }
223            trimmed.parse::<u64>().map_err(D::Error::custom)?
224        }
225    };
226
227    Ok(if value == 0 { None } else { Some(value) })
228}
229
230fn dotenv_key(key: &str) -> Option<String> {
231    const PREFIX: &str = "UMBRAL_";
232
233    let key = key.trim();
234    if key.len() <= PREFIX.len() || !key.get(..PREFIX.len())?.eq_ignore_ascii_case(PREFIX) {
235        return None;
236    }
237
238    let key = key[PREFIX.len()..].replace("__", ".").to_ascii_lowercase();
239    if key.split('.').any(str::is_empty) {
240        return None;
241    }
242
243    Some(key)
244}
245
246fn merge_dotenv(mut figment: Figment) -> Figment {
247    let Ok(iter) = dotenvy::from_filename_iter(".env") else {
248        return figment;
249    };
250    let mut seen = HashSet::new();
251
252    for (key, value) in iter.flatten() {
253        let Some(key) = dotenv_key(&key) else {
254            continue;
255        };
256        if !seen.insert(key.clone()) {
257            continue;
258        }
259        let value = value
260            .parse::<figment::value::Value>()
261            .expect("figment value parsing is infallible");
262        figment = figment.merge(Serialized::default(&key, value));
263    }
264
265    figment
266}
267
268#[derive(Clone, Debug, Deserialize)]
269pub struct Settings {
270    #[serde(default = "default_database_url")]
271    pub database_url: String,
272
273    #[serde(default)]
274    pub databases: std::collections::HashMap<String, String>,
275
276    /// Max request-body size (bytes) the `Form<T>` extractor buffers before
277    /// returning `413 Payload Too Large`. Default **16 MiB** (8× the old
278    /// hardcoded 2 MiB). Set `UMBRAL_MAX_FORM_BODY_BYTES` (or `max_form_body_bytes`
279    /// in `umbral.toml`); set it to `0` to **disable** the cap entirely — handy
280    /// in dev. (For large uploads use a file field / the storage backend, not
281    /// the form extractor.)
282    #[serde(default = "default_max_form_body_bytes")]
283    pub max_form_body_bytes: Option<usize>,
284
285    /// Max connections in the Postgres pool (PERF-5). Default 10. Set via
286    /// `UMBRAL_DB_MAX_CONNECTIONS` or `db_max_connections` in `umbral.toml`.
287    #[serde(default = "default_db_max_connections")]
288    pub db_max_connections: u32,
289
290    /// Seconds to wait for a free pooled connection before failing the
291    /// request (Postgres acquire timeout, PERF-5). Default 30. Set via
292    /// `UMBRAL_DB_ACQUIRE_TIMEOUT_SECS` or `db_acquire_timeout_secs`.
293    #[serde(default = "default_db_acquire_timeout_secs")]
294    pub db_acquire_timeout_secs: u64,
295
296    /// Idle-connection floor — the pool keeps at least this many warm
297    /// connections (gaps2 #91). Default 0. Set via
298    /// `UMBRAL_DB_MIN_CONNECTIONS` or `db_min_connections`.
299    #[serde(default = "default_db_min_connections")]
300    pub db_min_connections: u32,
301
302    /// Close a connection after it's been idle this many seconds (gaps2
303    /// #91). Default 600 (10 min). `0`/empty disables idle reaping. Set
304    /// via `UMBRAL_DB_IDLE_TIMEOUT_SECS` or `db_idle_timeout_secs`.
305    #[serde(
306        default = "default_db_idle_timeout_secs",
307        deserialize_with = "deserialize_zero_as_none"
308    )]
309    pub db_idle_timeout_secs: Option<u64>,
310
311    /// Recycle a connection older than this many seconds (gaps2 #91).
312    /// Default 1800 (30 min) — avoids stale connections behind a load
313    /// balancer / Postgres idle-reaping. `0`/empty disables. Set via
314    /// `UMBRAL_DB_MAX_LIFETIME_SECS` or `db_max_lifetime_secs`.
315    #[serde(
316        default = "default_db_max_lifetime_secs",
317        deserialize_with = "deserialize_zero_as_none"
318    )]
319    pub db_max_lifetime_secs: Option<u64>,
320
321    /// Health-check a pooled connection before handing it out (gaps2
322    /// #91). Default true. Set via `UMBRAL_DB_TEST_BEFORE_ACQUIRE` or
323    /// `db_test_before_acquire`.
324    #[serde(default = "default_db_test_before_acquire")]
325    pub db_test_before_acquire: bool,
326
327    #[serde(default = "default_secret_key")]
328    pub secret_key: String,
329
330    #[serde(default)]
331    pub environment: Environment,
332
333    #[serde(
334        default = "default_allowed_hosts",
335        deserialize_with = "deserialize_string_list"
336    )]
337    pub allowed_hosts: Vec<String>,
338
339    #[serde(default = "default_log_level")]
340    pub log_level: String,
341
342    /// The address the development server binds to.
343    /// `host:port` format, e.g. `127.0.0.1:8000` (default), `0.0.0.0:80`,
344    /// `[::1]:8000`. Override with `UMBRAL_BIND_ADDR` or `umbral.toml`.
345    #[serde(default = "default_bind_addr")]
346    pub bind_addr: String,
347
348    /// Gap 106 — timezone for marshalling naive datetimes on the
349    /// read and write boundary. `None` (default) keeps the
350    /// historical UTC-everywhere behaviour: naive input is treated
351    /// as UTC, admin-form display renders the stored UTC value
352    /// verbatim.
353    ///
354    /// `Some("Africa/Nairobi")` (any IANA tz name resolvable via
355    /// `chrono-tz`) flips both ends: HTML `<input type="datetime-
356    /// local">` values arriving naive are interpreted in the
357    /// configured tz then converted to UTC before storage; the
358    /// admin form renders stored UTC values converted back to the
359    /// configured tz so the user sees wall-clock time, not UTC.
360    /// Column type stays `TIMESTAMPTZ` (Postgres) / `TEXT`
361    /// (SQLite) — only the marshalling layer changes.
362    ///
363    /// Set via `UMBRAL_TIME_ZONE=Africa/Nairobi` or
364    /// `time_zone = "Africa/Nairobi"` in `umbral.toml`. An unknown
365    /// tz name falls back to UTC at lookup time with a tracing
366    /// warning rather than panicking — startup never fails on a
367    /// tz config error.
368    #[serde(default)]
369    pub time_zone: Option<String>,
370
371    /// URL prefix every collected/served static asset hangs under.
372    ///
373    /// Default `"/static/"`. The framework's static handler mounts at
374    /// this base and the `static()` template helper prepends it, so
375    /// `{{ static("admin/admin.css") }}` resolves to
376    /// `"/static/admin/admin.css"`. Set a CDN origin
377    /// (`UMBRAL_STATIC_URL=https://cdn.example.com/s/`) to serve assets
378    /// off a separate host in production — the helper then emits
379    /// absolute URLs and the local handler simply goes unused.
380    ///
381    /// Always normalised to carry exactly one leading and one trailing
382    /// slash: `"/static"`, `"static"`, and `"/static/"` all converge on
383    /// `"/static/"`. Set via `UMBRAL_STATIC_URL` or `static_url` in
384    /// `umbral.toml`.
385    #[serde(
386        default = "default_static_url",
387        deserialize_with = "deserialize_static_url"
388    )]
389    pub static_url: String,
390
391    /// On-disk directory collected static assets live under in
392    /// production.
393    ///
394    /// Default `"staticfiles/"` (relative to the binary's CWD). The
395    /// static handler resolves a request `/static/<ns>/<rest>` to
396    /// `<static_root>/<ns>/<rest>` in prod, and as the dev fallback
397    /// when a plugin's live source dir doesn't have the file. Set via
398    /// `UMBRAL_STATIC_ROOT` or `static_root` in `umbral.toml`.
399    #[serde(default = "default_static_root")]
400    pub static_root: String,
401
402    /// Catch-all for `UMBRAL_`-prefixed environment variables (and
403    /// `umbral.toml` keys) that don't map to a named field above.
404    ///
405    /// Real apps usually need keys the framework doesn't know about —
406    /// `OPENAI_API_KEY`, `STRIPE_SECRET`, third-party plugin
407    /// configuration. Setting `UMBRAL_OPENAI_API_KEY=sk-test` makes
408    /// `settings.extra.get("openai_api_key")` return a string value
409    /// without the user crate having to wire a second figment loader.
410    ///
411    /// Values are stored as `toml::Value` so a nested
412    /// `[external.openai]` table in `umbral.toml` round-trips with its
413    /// structure intact. The accessor [`Settings::extra_str`] handles
414    /// the common scalar-string case.
415    #[serde(flatten)]
416    pub extra: std::collections::HashMap<String, toml::Value>,
417}
418
419#[derive(Clone, Debug, Deserialize, Default)]
420pub enum Environment {
421    #[default]
422    Dev,
423    Test,
424    Prod,
425}
426
427impl Settings {
428    /// Read a scalar string from the `extra` map by key. Returns
429    /// `None` if the key is absent or the value isn't a string.
430    ///
431    /// Most app-defined settings are scalar (`UMBRAL_OPENAI_API_KEY=
432    /// sk-test`), so this helper is the right shape for the common
433    /// case. For nested tables (`[external.openai]` in `umbral.toml`)
434    /// the caller indexes into `extra` directly: `settings.extra.
435    /// get("external").and_then(|v| v.get("openai")).and_then(...)`.
436    pub fn extra_str(&self, key: &str) -> Option<&str> {
437        self.extra.get(key).and_then(|v| v.as_str())
438    }
439
440    /// Load settings from defaults, `.env`, `umbral.toml`, and `UMBRAL_`-prefixed env vars.
441    ///
442    /// Precedence (later wins): struct defaults → `umbral.toml` → env vars. A
443    /// local `.env` file is merged as an environment-shaped provider first,
444    /// but existing process env vars keep precedence over values from `.env`.
445    /// Implementation uses `merge` (not `join`) for both providers so each
446    /// subsequent source overrides the previous one's values. With `join`
447    /// the first provider to set a key would keep it, which would invert
448    /// the documented precedence.
449    ///
450    /// The error type is boxed because `figment::Error` is large (over 200
451    /// bytes); see `clippy::result_large_err`.
452    pub fn from_env() -> Result<Self, Box<figment::Error>> {
453        merge_dotenv(Figment::new().merge(Toml::file("umbral.toml")))
454            .merge(Env::prefixed("UMBRAL_").split("__"))
455            .extract()
456            .map_err(Box::new)
457    }
458}
459
460#[cfg(test)]
461#[allow(clippy::result_large_err)]
462// `Jail::expect_with` takes a closure returning `figment::Result<()>`, and
463// `figment::Error` is ~208 bytes. Boxing it here would only obscure tests
464// without any runtime benefit, so the lint is silenced module-wide.
465mod tests {
466    //! `Settings::init` and `settings::get` are intentionally out of scope here:
467    //! the process-wide `OnceLock` can be set exactly once per process, which
468    //! is incompatible with cargo test's parallel runner. Covering them
469    //! correctly needs `serial_test` or a thread-local refactor.
470    use super::*;
471    use figment::Jail;
472
473    #[test]
474    fn defaults_apply_when_nothing_is_set() {
475        Jail::expect_with(|_| {
476            let s = Settings::from_env().unwrap();
477            assert_eq!(s.database_url, "sqlite::memory:");
478            assert_eq!(s.secret_key, "umbral-insecure-dev-key-change-me");
479            assert_eq!(s.allowed_hosts, vec!["localhost", "127.0.0.1"]);
480            assert_eq!(s.log_level, "info");
481            assert!(matches!(s.environment, Environment::Dev));
482            assert!(s.databases.is_empty());
483            Ok(())
484        });
485    }
486
487    #[test]
488    fn allowed_hosts_accepts_comma_separated_env() {
489        // The natural comma-separated form: `UMBRAL_ALLOWED_HOSTS=a.com,b.com`.
490        Jail::expect_with(|jail| {
491            jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com, www.example.com");
492            let s = Settings::from_env().unwrap();
493            assert_eq!(s.allowed_hosts, vec!["example.com", "www.example.com"]);
494            Ok(())
495        });
496    }
497
498    #[test]
499    fn allowed_hosts_accepts_single_env_value() {
500        Jail::expect_with(|jail| {
501            jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com");
502            let s = Settings::from_env().unwrap();
503            assert_eq!(s.allowed_hosts, vec!["example.com"]);
504            Ok(())
505        });
506    }
507
508    #[test]
509    fn allowed_hosts_accepts_bracketed_env_and_toml_array() {
510        Jail::expect_with(|jail| {
511            jail.set_env("UMBRAL_ALLOWED_HOSTS", r#"["a.com","b.com"]"#);
512            assert_eq!(
513                Settings::from_env().unwrap().allowed_hosts,
514                vec!["a.com", "b.com"]
515            );
516            Ok(())
517        });
518        Jail::expect_with(|jail| {
519            jail.create_file("umbral.toml", r#"allowed_hosts = ["a.com", "b.com"]"#)?;
520            assert_eq!(
521                Settings::from_env().unwrap().allowed_hosts,
522                vec!["a.com", "b.com"]
523            );
524            Ok(())
525        });
526    }
527
528    #[test]
529    fn umbral_env_var_overrides_database_url() {
530        Jail::expect_with(|jail| {
531            jail.set_env("UMBRAL_DATABASE_URL", "postgres://example");
532            let s = Settings::from_env().unwrap();
533            assert_eq!(s.database_url, "postgres://example");
534            Ok(())
535        });
536    }
537
538    #[test]
539    fn nested_env_var_populates_databases_map() {
540        Jail::expect_with(|jail| {
541            jail.set_env("UMBRAL_DATABASES__REPLICA", "sqlite://replica.db");
542            let s = Settings::from_env().unwrap();
543            assert_eq!(
544                s.databases.get("replica").map(String::as_str),
545                Some("sqlite://replica.db"),
546            );
547            Ok(())
548        });
549    }
550
551    #[test]
552    fn umbral_toml_in_cwd_is_loaded() {
553        Jail::expect_with(|jail| {
554            jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
555            let s = Settings::from_env().unwrap();
556            assert_eq!(s.secret_key, "from-toml");
557            Ok(())
558        });
559    }
560
561    #[test]
562    fn env_var_overrides_toml() {
563        // Matches the precedence documented on `Settings::from_env`:
564        // env vars override toml. The implementation uses `merge` (not
565        // `join`) precisely so this assertion holds.
566        Jail::expect_with(|jail| {
567            jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
568            jail.set_env("UMBRAL_SECRET_KEY", "from-env");
569            let s = Settings::from_env().unwrap();
570            assert_eq!(s.secret_key, "from-env");
571            Ok(())
572        });
573    }
574
575    #[test]
576    fn dotenv_file_overrides_toml() {
577        Jail::expect_with(|jail| {
578            jail.create_file("umbral.toml", r#"database_url = "sqlite://from-toml.db""#)?;
579            jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
580            let s = Settings::from_env().unwrap();
581            assert_eq!(s.database_url, "postgres://from-dotenv");
582            Ok(())
583        });
584    }
585
586    #[test]
587    fn dotenv_file_populates_nested_databases_map() {
588        Jail::expect_with(|jail| {
589            jail.create_file(".env", "UMBRAL_DATABASES__REPLICA=sqlite://replica.db\n")?;
590            let s = Settings::from_env().unwrap();
591            assert_eq!(
592                s.databases.get("replica").map(String::as_str),
593                Some("sqlite://replica.db"),
594            );
595            Ok(())
596        });
597    }
598
599    #[test]
600    fn process_env_overrides_dotenv_file() {
601        Jail::expect_with(|jail| {
602            jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
603            jail.set_env("UMBRAL_DATABASE_URL", "postgres://from-process-env");
604            let s = Settings::from_env().unwrap();
605            assert_eq!(s.database_url, "postgres://from-process-env");
606            Ok(())
607        });
608    }
609
610    #[test]
611    fn static_url_and_root_defaults() {
612        Jail::expect_with(|_| {
613            let s = Settings::from_env().unwrap();
614            assert_eq!(s.static_url, "/static/");
615            assert_eq!(s.static_root, "staticfiles/");
616            Ok(())
617        });
618    }
619
620    #[test]
621    fn static_url_env_override_is_normalised() {
622        // No trailing slash on input -> normalised to one.
623        Jail::expect_with(|jail| {
624            jail.set_env("UMBRAL_STATIC_URL", "/assets");
625            assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
626            Ok(())
627        });
628        // No leading slash either.
629        Jail::expect_with(|jail| {
630            jail.set_env("UMBRAL_STATIC_URL", "assets");
631            assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
632            Ok(())
633        });
634        // Already-normalised value is left intact.
635        Jail::expect_with(|jail| {
636            jail.set_env("UMBRAL_STATIC_URL", "/assets/");
637            assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
638            Ok(())
639        });
640    }
641
642    #[test]
643    fn static_url_normalises_three_input_shapes() {
644        // The three canonical shapes from the spec all converge.
645        assert_eq!(normalize_static_url("/static"), "/static/");
646        assert_eq!(normalize_static_url("static"), "/static/");
647        assert_eq!(normalize_static_url("/static/"), "/static/");
648    }
649
650    #[test]
651    fn static_url_cdn_origin_keeps_scheme_and_host() {
652        // An absolute CDN URL keeps its scheme+host and only gains a
653        // trailing slash — no spurious leading slash collapsing `https://`.
654        assert_eq!(
655            normalize_static_url("https://cdn.example.com/s"),
656            "https://cdn.example.com/s/"
657        );
658        assert_eq!(
659            normalize_static_url("https://cdn.example.com/s/"),
660            "https://cdn.example.com/s/"
661        );
662    }
663
664    #[test]
665    fn static_root_env_override() {
666        Jail::expect_with(|jail| {
667            jail.set_env("UMBRAL_STATIC_ROOT", "build/assets/");
668            assert_eq!(Settings::from_env().unwrap().static_root, "build/assets/");
669            Ok(())
670        });
671    }
672
673    #[test]
674    fn db_pool_defaults_apply_when_nothing_is_set() {
675        Jail::expect_with(|_| {
676            let s = Settings::from_env().unwrap();
677            assert_eq!(s.db_max_connections, 10);
678            assert_eq!(s.db_min_connections, 0);
679            assert_eq!(s.db_acquire_timeout_secs, 30);
680            assert_eq!(s.db_idle_timeout_secs, Some(600));
681            assert_eq!(s.db_max_lifetime_secs, Some(1800));
682            assert!(s.db_test_before_acquire);
683            Ok(())
684        });
685    }
686
687    #[test]
688    fn db_pool_env_overrides_each_knob() {
689        Jail::expect_with(|jail| {
690            jail.set_env("UMBRAL_DB_MAX_CONNECTIONS", "42");
691            jail.set_env("UMBRAL_DB_MIN_CONNECTIONS", "4");
692            jail.set_env("UMBRAL_DB_ACQUIRE_TIMEOUT_SECS", "7");
693            jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "120");
694            jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "240");
695            jail.set_env("UMBRAL_DB_TEST_BEFORE_ACQUIRE", "false");
696            let s = Settings::from_env().unwrap();
697            assert_eq!(s.db_max_connections, 42);
698            assert_eq!(s.db_min_connections, 4);
699            assert_eq!(s.db_acquire_timeout_secs, 7);
700            assert_eq!(s.db_idle_timeout_secs, Some(120));
701            assert_eq!(s.db_max_lifetime_secs, Some(240));
702            assert!(!s.db_test_before_acquire);
703            Ok(())
704        });
705    }
706
707    #[test]
708    fn db_timeout_zero_means_disabled_none() {
709        Jail::expect_with(|jail| {
710            jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "0");
711            jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "0");
712            let s = Settings::from_env().unwrap();
713            assert_eq!(s.db_idle_timeout_secs, None);
714            assert_eq!(s.db_max_lifetime_secs, None);
715            Ok(())
716        });
717    }
718
719    #[test]
720    fn db_timeout_empty_string_means_disabled_none() {
721        Jail::expect_with(|jail| {
722            jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "");
723            let s = Settings::from_env().unwrap();
724            assert_eq!(s.db_idle_timeout_secs, None);
725            Ok(())
726        });
727    }
728
729    #[test]
730    fn environment_default_is_dev() {
731        assert!(matches!(Environment::default(), Environment::Dev));
732    }
733
734    #[test]
735    fn environment_prod_round_trips_through_toml() {
736        Jail::expect_with(|jail| {
737            jail.create_file("umbral.toml", r#"environment = "Prod""#)?;
738            let s = Settings::from_env().unwrap();
739            assert!(matches!(s.environment, Environment::Prod));
740            Ok(())
741        });
742    }
743
744    /// An `UMBRAL_`-prefixed env var that doesn't correspond to a known
745    /// `Settings` field falls into `extra` so user code can read it.
746    /// `OPENAI_API_KEY` stands in for the common "I have an external
747    /// service credential" case.
748    #[test]
749    fn unknown_env_var_is_captured_in_extra() {
750        Jail::expect_with(|jail| {
751            jail.set_env("UMBRAL_OPENAI_API_KEY", "sk-test-12345");
752            let s = Settings::from_env().unwrap();
753            assert_eq!(s.extra_str("openai_api_key"), Some("sk-test-12345"));
754            // Known fields still resolve normally.
755            assert_eq!(s.database_url, "sqlite::memory:");
756            Ok(())
757        });
758    }
759
760    /// A nested `umbral.toml` table that doesn't map to a known field
761    /// preserves its structure inside `extra`. The accessor walks the
762    /// nested table directly via `toml::Value`.
763    #[test]
764    fn unknown_toml_table_is_captured_in_extra() {
765        Jail::expect_with(|jail| {
766            jail.create_file(
767                "umbral.toml",
768                r#"
769                [external]
770                provider = "stripe"
771                "#,
772            )?;
773            let s = Settings::from_env().unwrap();
774            let provider = s
775                .extra
776                .get("external")
777                .and_then(|v| v.get("provider"))
778                .and_then(|v| v.as_str());
779            assert_eq!(provider, Some("stripe"));
780            Ok(())
781        });
782    }
783}