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}