use figment::Figment;
use figment::providers::{Env, Format, Serialized, Toml};
use serde::Deserialize;
use std::collections::HashSet;
use std::sync::OnceLock;
pub(crate) static SETTINGS: OnceLock<Settings> = OnceLock::new();
pub(crate) fn init(settings: &Settings) {
SETTINGS
.set(settings.clone())
.expect("umbral::settings::init called more than once");
}
pub fn get() -> &'static Settings {
SETTINGS
.get()
.expect("umbral: settings not initialised — did you call App::build()?")
}
pub fn get_opt() -> Option<&'static Settings> {
SETTINGS.get()
}
fn default_database_url() -> String {
"sqlite::memory:".into()
}
fn default_max_form_body_bytes() -> Option<usize> {
Some(16 * 1024 * 1024)
}
fn default_secret_key() -> String {
"umbral-insecure-dev-key-change-me".into()
}
fn default_allowed_hosts() -> Vec<String> {
vec!["localhost".into(), "127.0.0.1".into()]
}
fn deserialize_string_list<'de, D>(de: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::Deserialize;
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
Ok(match OneOrMany::deserialize(de)? {
OneOrMany::One(s) => s
.split(',')
.map(str::trim)
.filter(|h| !h.is_empty())
.map(str::to_string)
.collect(),
OneOrMany::Many(v) => v,
})
}
fn default_log_level() -> String {
"info".into()
}
fn default_db_max_connections() -> u32 {
10
}
fn default_db_acquire_timeout_secs() -> u64 {
30
}
fn default_db_min_connections() -> u32 {
0
}
fn default_db_idle_timeout_secs() -> Option<u64> {
Some(600)
}
fn default_db_max_lifetime_secs() -> Option<u64> {
Some(1800)
}
fn default_db_test_before_acquire() -> bool {
true
}
fn default_bind_addr() -> String {
"127.0.0.1:8000".into()
}
fn default_static_url() -> String {
"/static/".into()
}
fn default_static_root() -> String {
"staticfiles/".into()
}
fn normalize_static_url(raw: &str) -> String {
let trimmed = raw.trim();
let is_absolute = trimmed.starts_with("http://")
|| trimmed.starts_with("https://")
|| trimmed.starts_with("//");
let mut out = String::with_capacity(trimmed.len() + 2);
if is_absolute {
out.push_str(trimmed.trim_end_matches('/'));
} else {
out.push('/');
out.push_str(trimmed.trim_matches('/'));
}
if !out.ends_with('/') {
out.push('/');
}
out
}
fn deserialize_static_url<'de, D>(de: D) -> Result<String, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(de)?;
Ok(normalize_static_url(&raw))
}
fn deserialize_zero_as_none<'de, D>(de: D) -> Result<Option<u64>, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error as _;
#[derive(Deserialize)]
#[serde(untagged)]
enum Raw {
Int(u64),
Str(String),
Null,
}
let value = match Option::<Raw>::deserialize(de)? {
None | Some(Raw::Null) => return Ok(None),
Some(Raw::Int(n)) => n,
Some(Raw::Str(s)) => {
let trimmed = s.trim();
if trimmed.is_empty() {
return Ok(None);
}
trimmed.parse::<u64>().map_err(D::Error::custom)?
}
};
Ok(if value == 0 { None } else { Some(value) })
}
fn dotenv_key(key: &str) -> Option<String> {
const PREFIX: &str = "UMBRAL_";
let key = key.trim();
if key.len() <= PREFIX.len() || !key.get(..PREFIX.len())?.eq_ignore_ascii_case(PREFIX) {
return None;
}
let key = key[PREFIX.len()..].replace("__", ".").to_ascii_lowercase();
if key.split('.').any(str::is_empty) {
return None;
}
Some(key)
}
fn merge_dotenv(mut figment: Figment) -> Figment {
let Ok(iter) = dotenvy::from_filename_iter(".env") else {
return figment;
};
let mut seen = HashSet::new();
for (key, value) in iter.flatten() {
let Some(key) = dotenv_key(&key) else {
continue;
};
if !seen.insert(key.clone()) {
continue;
}
let value = value
.parse::<figment::value::Value>()
.expect("figment value parsing is infallible");
figment = figment.merge(Serialized::default(&key, value));
}
figment
}
#[derive(Clone, Debug, Deserialize)]
pub struct Settings {
#[serde(default = "default_database_url")]
pub database_url: String,
#[serde(default)]
pub databases: std::collections::HashMap<String, String>,
#[serde(default = "default_max_form_body_bytes")]
pub max_form_body_bytes: Option<usize>,
#[serde(default = "default_db_max_connections")]
pub db_max_connections: u32,
#[serde(default = "default_db_acquire_timeout_secs")]
pub db_acquire_timeout_secs: u64,
#[serde(default = "default_db_min_connections")]
pub db_min_connections: u32,
#[serde(
default = "default_db_idle_timeout_secs",
deserialize_with = "deserialize_zero_as_none"
)]
pub db_idle_timeout_secs: Option<u64>,
#[serde(
default = "default_db_max_lifetime_secs",
deserialize_with = "deserialize_zero_as_none"
)]
pub db_max_lifetime_secs: Option<u64>,
#[serde(default = "default_db_test_before_acquire")]
pub db_test_before_acquire: bool,
#[serde(default = "default_secret_key")]
pub secret_key: String,
#[serde(default)]
pub environment: Environment,
#[serde(
default = "default_allowed_hosts",
deserialize_with = "deserialize_string_list"
)]
pub allowed_hosts: Vec<String>,
#[serde(default = "default_log_level")]
pub log_level: String,
#[serde(default = "default_bind_addr")]
pub bind_addr: String,
#[serde(default)]
pub time_zone: Option<String>,
#[serde(
default = "default_static_url",
deserialize_with = "deserialize_static_url"
)]
pub static_url: String,
#[serde(default = "default_static_root")]
pub static_root: String,
#[serde(flatten)]
pub extra: std::collections::HashMap<String, toml::Value>,
}
#[derive(Clone, Debug, Deserialize, Default)]
pub enum Environment {
#[default]
Dev,
Test,
Prod,
}
impl Settings {
pub fn extra_str(&self, key: &str) -> Option<&str> {
self.extra.get(key).and_then(|v| v.as_str())
}
pub fn from_env() -> Result<Self, Box<figment::Error>> {
merge_dotenv(Figment::new().merge(Toml::file("umbral.toml")))
.merge(Env::prefixed("UMBRAL_").split("__"))
.extract()
.map_err(Box::new)
}
}
#[cfg(test)]
#[allow(clippy::result_large_err)]
mod tests {
use super::*;
use figment::Jail;
#[test]
fn defaults_apply_when_nothing_is_set() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "sqlite::memory:");
assert_eq!(s.secret_key, "umbral-insecure-dev-key-change-me");
assert_eq!(s.allowed_hosts, vec!["localhost", "127.0.0.1"]);
assert_eq!(s.log_level, "info");
assert!(matches!(s.environment, Environment::Dev));
assert!(s.databases.is_empty());
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_comma_separated_env() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com, www.example.com");
let s = Settings::from_env().unwrap();
assert_eq!(s.allowed_hosts, vec!["example.com", "www.example.com"]);
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_single_env_value() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", "example.com");
let s = Settings::from_env().unwrap();
assert_eq!(s.allowed_hosts, vec!["example.com"]);
Ok(())
});
}
#[test]
fn allowed_hosts_accepts_bracketed_env_and_toml_array() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_ALLOWED_HOSTS", r#"["a.com","b.com"]"#);
assert_eq!(
Settings::from_env().unwrap().allowed_hosts,
vec!["a.com", "b.com"]
);
Ok(())
});
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"allowed_hosts = ["a.com", "b.com"]"#)?;
assert_eq!(
Settings::from_env().unwrap().allowed_hosts,
vec!["a.com", "b.com"]
);
Ok(())
});
}
#[test]
fn umbral_env_var_overrides_database_url() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DATABASE_URL", "postgres://example");
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://example");
Ok(())
});
}
#[test]
fn nested_env_var_populates_databases_map() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DATABASES__REPLICA", "sqlite://replica.db");
let s = Settings::from_env().unwrap();
assert_eq!(
s.databases.get("replica").map(String::as_str),
Some("sqlite://replica.db"),
);
Ok(())
});
}
#[test]
fn umbral_toml_in_cwd_is_loaded() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
let s = Settings::from_env().unwrap();
assert_eq!(s.secret_key, "from-toml");
Ok(())
});
}
#[test]
fn env_var_overrides_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"secret_key = "from-toml""#)?;
jail.set_env("UMBRAL_SECRET_KEY", "from-env");
let s = Settings::from_env().unwrap();
assert_eq!(s.secret_key, "from-env");
Ok(())
});
}
#[test]
fn dotenv_file_overrides_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"database_url = "sqlite://from-toml.db""#)?;
jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://from-dotenv");
Ok(())
});
}
#[test]
fn dotenv_file_populates_nested_databases_map() {
Jail::expect_with(|jail| {
jail.create_file(".env", "UMBRAL_DATABASES__REPLICA=sqlite://replica.db\n")?;
let s = Settings::from_env().unwrap();
assert_eq!(
s.databases.get("replica").map(String::as_str),
Some("sqlite://replica.db"),
);
Ok(())
});
}
#[test]
fn process_env_overrides_dotenv_file() {
Jail::expect_with(|jail| {
jail.create_file(".env", "UMBRAL_DATABASE_URL=postgres://from-dotenv\n")?;
jail.set_env("UMBRAL_DATABASE_URL", "postgres://from-process-env");
let s = Settings::from_env().unwrap();
assert_eq!(s.database_url, "postgres://from-process-env");
Ok(())
});
}
#[test]
fn static_url_and_root_defaults() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.static_url, "/static/");
assert_eq!(s.static_root, "staticfiles/");
Ok(())
});
}
#[test]
fn static_url_env_override_is_normalised() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "/assets");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "assets");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_URL", "/assets/");
assert_eq!(Settings::from_env().unwrap().static_url, "/assets/");
Ok(())
});
}
#[test]
fn static_url_normalises_three_input_shapes() {
assert_eq!(normalize_static_url("/static"), "/static/");
assert_eq!(normalize_static_url("static"), "/static/");
assert_eq!(normalize_static_url("/static/"), "/static/");
}
#[test]
fn static_url_cdn_origin_keeps_scheme_and_host() {
assert_eq!(
normalize_static_url("https://cdn.example.com/s"),
"https://cdn.example.com/s/"
);
assert_eq!(
normalize_static_url("https://cdn.example.com/s/"),
"https://cdn.example.com/s/"
);
}
#[test]
fn static_root_env_override() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_STATIC_ROOT", "build/assets/");
assert_eq!(Settings::from_env().unwrap().static_root, "build/assets/");
Ok(())
});
}
#[test]
fn db_pool_defaults_apply_when_nothing_is_set() {
Jail::expect_with(|_| {
let s = Settings::from_env().unwrap();
assert_eq!(s.db_max_connections, 10);
assert_eq!(s.db_min_connections, 0);
assert_eq!(s.db_acquire_timeout_secs, 30);
assert_eq!(s.db_idle_timeout_secs, Some(600));
assert_eq!(s.db_max_lifetime_secs, Some(1800));
assert!(s.db_test_before_acquire);
Ok(())
});
}
#[test]
fn db_pool_env_overrides_each_knob() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_MAX_CONNECTIONS", "42");
jail.set_env("UMBRAL_DB_MIN_CONNECTIONS", "4");
jail.set_env("UMBRAL_DB_ACQUIRE_TIMEOUT_SECS", "7");
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "120");
jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "240");
jail.set_env("UMBRAL_DB_TEST_BEFORE_ACQUIRE", "false");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_max_connections, 42);
assert_eq!(s.db_min_connections, 4);
assert_eq!(s.db_acquire_timeout_secs, 7);
assert_eq!(s.db_idle_timeout_secs, Some(120));
assert_eq!(s.db_max_lifetime_secs, Some(240));
assert!(!s.db_test_before_acquire);
Ok(())
});
}
#[test]
fn db_timeout_zero_means_disabled_none() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "0");
jail.set_env("UMBRAL_DB_MAX_LIFETIME_SECS", "0");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_idle_timeout_secs, None);
assert_eq!(s.db_max_lifetime_secs, None);
Ok(())
});
}
#[test]
fn db_timeout_empty_string_means_disabled_none() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_DB_IDLE_TIMEOUT_SECS", "");
let s = Settings::from_env().unwrap();
assert_eq!(s.db_idle_timeout_secs, None);
Ok(())
});
}
#[test]
fn environment_default_is_dev() {
assert!(matches!(Environment::default(), Environment::Dev));
}
#[test]
fn environment_prod_round_trips_through_toml() {
Jail::expect_with(|jail| {
jail.create_file("umbral.toml", r#"environment = "Prod""#)?;
let s = Settings::from_env().unwrap();
assert!(matches!(s.environment, Environment::Prod));
Ok(())
});
}
#[test]
fn unknown_env_var_is_captured_in_extra() {
Jail::expect_with(|jail| {
jail.set_env("UMBRAL_OPENAI_API_KEY", "sk-test-12345");
let s = Settings::from_env().unwrap();
assert_eq!(s.extra_str("openai_api_key"), Some("sk-test-12345"));
assert_eq!(s.database_url, "sqlite::memory:");
Ok(())
});
}
#[test]
fn unknown_toml_table_is_captured_in_extra() {
Jail::expect_with(|jail| {
jail.create_file(
"umbral.toml",
r#"
[external]
provider = "stripe"
"#,
)?;
let s = Settings::from_env().unwrap();
let provider = s
.extra
.get("external")
.and_then(|v| v.get("provider"))
.and_then(|v| v.as_str());
assert_eq!(provider, Some("stripe"));
Ok(())
});
}
}