Skip to main content

rustango/
env.rs

1//! Typed readers for environment variables.
2//!
3//! Each reader parses `std::env::var` into the type you ask for and
4//! returns a `Result`, so a missing or bad value fails at startup
5//! instead of quietly defaulting inside a handler.
6//!
7//! ## Quick start
8//!
9//! ```ignore
10//! use rustango::env;
11//! use std::time::Duration;
12//!
13//! struct AppConfig {
14//!     database_url: String,
15//!     port: u16,
16//!     debug: bool,
17//!     allowed_hosts: Vec<String>,
18//!     session_ttl: Duration,
19//! }
20//!
21//! fn load_config() -> Result<AppConfig, env::EnvError> {
22//!     Ok(AppConfig {
23//!         database_url:  env::required::<String>("DATABASE_URL")?,
24//!         port:          env::with_default("PORT", 8080)?,
25//!         debug:         env::with_default("DEBUG", false)?,
26//!         allowed_hosts: env::list("ALLOWED_HOSTS").unwrap_or_default(),
27//!         session_ttl:   env::duration_secs("SESSION_TTL_SECS").unwrap_or(Duration::from_secs(3600)),
28//!     })
29//! }
30//! ```
31
32use std::env::VarError;
33use std::str::FromStr;
34use std::time::Duration;
35
36#[derive(Debug, thiserror::Error)]
37#[non_exhaustive]
38pub enum EnvError {
39    #[error("environment variable `{0}` is not set")]
40    Missing(String),
41    #[error("environment variable `{name}` is not valid {ty}: {detail}")]
42    Parse {
43        name: String,
44        ty: &'static str,
45        detail: String,
46    },
47    /// A variable needed to assemble a value (see
48    /// [`database_url_from_env`]) is unset. The `hint` tells the
49    /// operator what to set.
50    #[error("environment variable `{name}` is required: {hint}")]
51    MissingRequired { name: String, hint: String },
52    /// `DB_DRIVER`, or the scheme read from `DATABASE_URL`, is not a
53    /// backend we support.
54    #[error("unsupported DB driver `{0}` — expected one of: postgres, postgresql, mysql, mariadb, sqlite")]
55    UnsupportedDriver(String),
56}
57
58fn lookup(name: &str) -> Option<String> {
59    match std::env::var(name) {
60        Ok(v) => Some(v),
61        Err(VarError::NotPresent) => None,
62        Err(VarError::NotUnicode(_)) => None,
63    }
64}
65
66/// Read a required env var and parse it into `T`.
67///
68/// # Errors
69/// [`EnvError::Missing`] if the variable is unset.
70/// [`EnvError::Parse`] if the value can't be parsed into `T`.
71pub fn required<T>(name: &str) -> Result<T, EnvError>
72where
73    T: FromStr,
74    T::Err: std::fmt::Display,
75{
76    let raw = lookup(name).ok_or_else(|| EnvError::Missing(name.to_owned()))?;
77    raw.parse::<T>().map_err(|e| EnvError::Parse {
78        name: name.to_owned(),
79        ty: std::any::type_name::<T>(),
80        detail: e.to_string(),
81    })
82}
83
84/// Read an env var, returning `default` when unset.
85///
86/// # Errors
87/// [`EnvError::Parse`] if the variable is set but does not parse into
88/// `T`. A typo is reported rather than replaced by the default,
89/// because a wrong value is more dangerous than a missing one.
90pub fn with_default<T>(name: &str, default: T) -> Result<T, EnvError>
91where
92    T: FromStr,
93    T::Err: std::fmt::Display,
94{
95    let Some(raw) = lookup(name) else {
96        return Ok(default);
97    };
98    raw.parse::<T>().map_err(|e| EnvError::Parse {
99        name: name.to_owned(),
100        ty: std::any::type_name::<T>(),
101        detail: e.to_string(),
102    })
103}
104
105/// Read an optional env var. Returns `None` if unset, `Some(parsed)` if set.
106///
107/// # Errors
108/// [`EnvError::Parse`] if the variable IS set but can't be parsed.
109pub fn optional<T>(name: &str) -> Result<Option<T>, EnvError>
110where
111    T: FromStr,
112    T::Err: std::fmt::Display,
113{
114    let Some(raw) = lookup(name) else {
115        return Ok(None);
116    };
117    raw.parse::<T>().map(Some).map_err(|e| EnvError::Parse {
118        name: name.to_owned(),
119        ty: std::any::type_name::<T>(),
120        detail: e.to_string(),
121    })
122}
123
124/// Read a comma-separated env var into a `Vec<T>`. Empty entries,
125/// such as the one a trailing comma makes, are dropped. Gives `None`
126/// when the variable is unset.
127///
128/// # Errors
129/// [`EnvError::Parse`] if any entry can't be parsed into `T`.
130pub fn list<T>(name: &str) -> Result<Option<Vec<T>>, EnvError>
131where
132    T: FromStr,
133    T::Err: std::fmt::Display,
134{
135    let Some(raw) = lookup(name) else {
136        return Ok(None);
137    };
138    let mut out = Vec::new();
139    for part in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
140        let v = part.parse::<T>().map_err(|e| EnvError::Parse {
141            name: name.to_owned(),
142            ty: std::any::type_name::<T>(),
143            detail: format!("entry `{part}`: {e}"),
144        })?;
145        out.push(v);
146    }
147    Ok(Some(out))
148}
149
150/// Read an env var as a `Duration`, reading the value as seconds.
151///
152/// # Errors
153/// [`EnvError::Missing`] when unset.
154/// [`EnvError::Parse`] when the value isn't a non-negative integer.
155pub fn duration_secs(name: &str) -> Result<Duration, EnvError> {
156    let secs: u64 = required(name)?;
157    Ok(Duration::from_secs(secs))
158}
159
160/// Read an env var as a `Duration`, reading the value as milliseconds.
161///
162/// # Errors
163/// As [`duration_secs`].
164pub fn duration_millis(name: &str) -> Result<Duration, EnvError> {
165    let ms: u64 = required(name)?;
166    Ok(Duration::from_millis(ms))
167}
168
169/// The one yes/no set rustango reads flags with, any case: `1`/`true`/
170/// `yes`/`on` and `0`/`false`/`no`/`off`. `None` for anything else.
171pub(crate) fn parse_flag(raw: &str) -> Option<bool> {
172    match raw.trim().to_ascii_lowercase().as_str() {
173        "1" | "true" | "yes" | "on" => Some(true),
174        "0" | "false" | "no" | "off" => Some(false),
175        _ => None,
176    }
177}
178
179/// The one tier parser: trimmed, lowercase, `production` as `prod`,
180/// `dev` when blank. Config files, cookies, logging and the audit all use it.
181pub(crate) fn canonical_tier(raw: &str) -> String {
182    match raw.trim().to_ascii_lowercase().as_str() {
183        "" => "dev".to_owned(),
184        "production" => "prod".to_owned(),
185        other => other.to_owned(),
186    }
187}
188
189/// `RUSTANGO_ENV` through [`canonical_tier`].
190pub(crate) fn tier_from_env() -> String {
191    canonical_tier(&std::env::var("RUSTANGO_ENV").unwrap_or_default())
192}
193
194/// `true` when `raw` names the production tier.
195pub(crate) fn is_prod_tier(raw: &str) -> bool {
196    canonical_tier(raw) == "prod"
197}
198
199/// Listener address when neither `RUSTANGO_BIND` nor `[server] bind` is set:
200/// all interfaces on prod and staging, like their scaffolded tiers; loopback elsewhere.
201#[cfg_attr(not(any(feature = "manage", feature = "tenancy")), allow(dead_code))]
202pub(crate) fn default_bind(tier: &str) -> &'static str {
203    match canonical_tier(tier).as_str() {
204        "prod" | "staging" => "0.0.0.0:8080",
205        _ => "127.0.0.1:8080",
206    }
207}
208
209// ------------------------------------------------------------------ Startup validation
210
211/// One declared environment-variable requirement.
212#[derive(Debug, Clone)]
213pub struct EnvRequirement {
214    pub name: String,
215    pub description: String,
216    pub required: bool,
217}
218
219/// Collects the env vars an app needs and reports every missing one
220/// at once.
221///
222/// Run it at startup so a bad config gives one clear message instead
223/// of a crash on the first DB call.
224///
225/// ## Quick start
226///
227/// ```ignore
228/// use rustango::env::Validator;
229///
230/// Validator::new()
231///     .require("DATABASE_URL", "Postgres connection string (postgres://...)")
232///     .require("SECRET_KEY", "32+ byte secret for cookie signing")
233///     .optional("REDIS_URL", "Redis connection string (defaults to in-memory cache)")
234///     .check_or_panic();
235/// ```
236#[derive(Default)]
237pub struct Validator {
238    reqs: Vec<EnvRequirement>,
239}
240
241impl Validator {
242    #[must_use]
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Add a required env var.
248    #[must_use]
249    pub fn require(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
250        self.reqs.push(EnvRequirement {
251            name: name.into(),
252            description: description.into(),
253            required: true,
254        });
255        self
256    }
257
258    /// Add an optional env var. Missing ones are listed as a hint,
259    /// not a failure.
260    #[must_use]
261    pub fn optional(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
262        self.reqs.push(EnvRequirement {
263            name: name.into(),
264            description: description.into(),
265            required: false,
266        });
267        self
268    }
269
270    /// Return the required vars that are unset. Optional ones are
271    /// never included.
272    #[must_use]
273    pub fn check(&self) -> Vec<&EnvRequirement> {
274        self.reqs
275            .iter()
276            .filter(|r| r.required && lookup(&r.name).is_none())
277            .collect()
278    }
279
280    /// Like [`Self::check`], but returns a ready-to-print message
281    /// listing each missing var and an `export` line for it.
282    ///
283    /// # Errors
284    /// The formatted message, when a required var is missing.
285    pub fn check_or_error(&self) -> Result<(), String> {
286        let missing = self.check();
287        if missing.is_empty() {
288            return Ok(());
289        }
290        let mut out = String::from("Missing required environment variables:\n");
291        for req in &missing {
292            out.push_str(&format!("  - {} — {}\n", req.name, req.description));
293        }
294        out.push_str("\nSet them and re-run, e.g.:\n");
295        for req in &missing {
296            out.push_str(&format!("  export {}=...\n", req.name));
297        }
298        Err(out)
299    }
300
301    /// Like [`Self::check_or_error`], but panics with the message.
302    /// Meant for startup.
303    ///
304    /// # Panics
305    /// When a required var is missing.
306    pub fn check_or_panic(&self) {
307        if let Err(msg) = self.check_or_error() {
308            panic!("{msg}");
309        }
310    }
311
312    /// Number of declared requirements.
313    #[must_use]
314    pub fn len(&self) -> usize {
315        self.reqs.len()
316    }
317
318    #[must_use]
319    pub fn is_empty(&self) -> bool {
320        self.reqs.is_empty()
321    }
322}
323
324// ------------------------------------------------------------------ Database URL assembly
325
326/// Backends recognized by [`DatabaseUrlBuilder`] and
327/// [`database_url_from_env`].
328#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum DbDriver {
330    Postgres,
331    Mysql,
332}
333
334impl DbDriver {
335    /// The scheme as it appears in the URL: `"postgres"` or
336    /// `"mysql"`. `postgresql` parses, but comes back as `postgres`.
337    #[must_use]
338    pub fn scheme(self) -> &'static str {
339        match self {
340            DbDriver::Postgres => "postgres",
341            DbDriver::Mysql => "mysql",
342        }
343    }
344
345    /// Default TCP port for the backend.
346    #[must_use]
347    pub fn default_port(self) -> u16 {
348        match self {
349            DbDriver::Postgres => 5432,
350            DbDriver::Mysql => 3306,
351        }
352    }
353
354    fn parse(s: &str) -> Result<Self, EnvError> {
355        match s.trim().to_ascii_lowercase().as_str() {
356            "postgres" | "postgresql" | "pg" => Ok(DbDriver::Postgres),
357            "mysql" | "mariadb" => Ok(DbDriver::Mysql),
358            other => Err(EnvError::UnsupportedDriver(other.to_owned())),
359        }
360    }
361}
362
363/// Builds a database URL from separate fields.
364///
365/// [`database_url_from_env`] uses it when `DATABASE_URL` is unset and
366/// the details come from `DB_HOST`, `DB_USER` and friends. The user
367/// and password are percent-encoded, so `@`, `:`, `/`, `#`, `?`, `%`
368/// and other URL-special characters in a password cannot break the
369/// URL apart.
370///
371/// ```ignore
372/// use rustango::env::{DatabaseUrlBuilder, DbDriver};
373///
374/// let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
375///     .host("db.internal")
376///     .user("svc")
377///     .password("p@ss/word")  // auto-encoded
378///     .database("app")
379///     .build();
380/// assert_eq!(url, "postgres://svc:p%40ss%2Fword@db.internal:5432/app");
381/// ```
382#[derive(Clone)]
383pub struct DatabaseUrlBuilder {
384    driver: DbDriver,
385    host: Option<String>,
386    port: Option<u16>,
387    user: Option<String>,
388    password: Option<String>,
389    database: Option<String>,
390    params: Option<String>,
391}
392
393redacted_debug!(DatabaseUrlBuilder { driver, host, port, user, database, params; password });
394
395impl DatabaseUrlBuilder {
396    #[must_use]
397    pub fn new(driver: DbDriver) -> Self {
398        Self {
399            driver,
400            host: None,
401            port: None,
402            user: None,
403            password: None,
404            database: None,
405            params: None,
406        }
407    }
408
409    #[must_use]
410    pub fn host(mut self, host: impl Into<String>) -> Self {
411        self.host = Some(host.into());
412        self
413    }
414    #[must_use]
415    pub fn port(mut self, port: u16) -> Self {
416        self.port = Some(port);
417        self
418    }
419    #[must_use]
420    pub fn user(mut self, user: impl Into<String>) -> Self {
421        self.user = Some(user.into());
422        self
423    }
424    #[must_use]
425    pub fn password(mut self, password: impl Into<String>) -> Self {
426        self.password = Some(password.into());
427        self
428    }
429    #[must_use]
430    pub fn database(mut self, database: impl Into<String>) -> Self {
431        self.database = Some(database.into());
432        self
433    }
434    /// Raw query string put after `?`, such as
435    /// `"sslmode=require&application_name=app"`. You must
436    /// percent-encode it yourself; this builder does not.
437    #[must_use]
438    pub fn params(mut self, params: impl Into<String>) -> Self {
439        self.params = Some(params.into());
440        self
441    }
442
443    /// Build `scheme://[user[:pw]@]host:port[/db][?params]`. Host
444    /// falls back to `localhost` and port to the driver's default; an
445    /// IPv6 host such as `::1` is bracketed.
446    #[must_use]
447    pub fn build(&self) -> String {
448        let scheme = self.driver.scheme();
449        let host = self.host.as_deref().unwrap_or("localhost");
450        let port = self.port.unwrap_or(self.driver.default_port());
451        let mut out = format!("{scheme}://");
452        if let Some(user) = &self.user {
453            out.push_str(&percent_encode_userinfo(user));
454            if let Some(pw) = &self.password {
455                out.push(':');
456                out.push_str(&percent_encode_userinfo(pw));
457            }
458            out.push('@');
459        }
460        // An IPv6 literal needs brackets; a zone id's `%` is `%25` in a URL.
461        if host.contains(':') && !host.starts_with('[') {
462            out.push('[');
463            out.push_str(&host.replace('%', "%25"));
464            out.push(']');
465        } else {
466            out.push_str(host);
467        }
468        out.push(':');
469        out.push_str(&port.to_string());
470        if let Some(db) = &self.database {
471            out.push('/');
472            out.push_str(&percent_encode_path(db));
473        }
474        if let Some(params) = &self.params {
475            if !params.is_empty() {
476                out.push('?');
477                out.push_str(params);
478            }
479        }
480        out
481    }
482}
483
484/// The separate connection vars [`database_url_from_env`] assembles from.
485const DB_PART_VARS: &[&str] = &[
486    "DB_DRIVER",
487    "DB_HOST",
488    "DB_PORT",
489    "DB_USER",
490    "DB_PASSWORD",
491    "DB_PASSWORD_FILE",
492    "DB_NAME",
493    "DB_PARAMS",
494];
495
496/// What to tell an operator who set no connection style.
497const DB_UNSET_HINT: &str = "set DATABASE_URL, or DB_USER + DB_NAME \
498     (with DB_HOST, DB_PORT, DB_PASSWORD or DB_PASSWORD_FILE, DB_DRIVER); \
499     for SQLite, DB_DRIVER=sqlite + DB_NAME=<file path>; \
500     or RUSTANGO__DATABASE__URL, or [database] url in the settings file";
501
502/// Why the prod tier ignores `[database] url`.
503const DB_PROD_FILE_HINT: &str = "on the prod tier the database URL must come from \
504     the environment (DATABASE_URL, the DB_* vars or RUSTANGO__DATABASE__URL); \
505     [database] url in a settings file is refused there";
506
507/// The settings-file `[database] url` override var.
508const DATABASE_URL_OVERRIDE: &str = "RUSTANGO__DATABASE__URL";
509
510/// Where a setting's value came from, highest precedence first.
511#[derive(Debug, Clone, PartialEq, Eq)]
512pub(crate) enum SettingSource {
513    /// A bare environment variable, such as `DATABASE_URL`.
514    Env(&'static str),
515    /// A `RUSTANGO__SECTION__KEY` variable.
516    Override(String),
517    /// A settings file.
518    File(String),
519    /// Set nowhere.
520    #[cfg(feature = "config")]
521    Default,
522}
523
524impl std::fmt::Display for SettingSource {
525    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
526        match self {
527            Self::Env(var) => write!(f, "env {var}"),
528            Self::Override(var) => write!(f, "env {var}"),
529            Self::File(file) => f.write_str(file),
530            #[cfg(feature = "config")]
531            Self::Default => f.write_str("default"),
532        }
533    }
534}
535
536/// `[database] url` as `Cli::with_settings` loaded it; first call wins.
537static SETTINGS_DATABASE_URL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
538
539/// What [`set_settings_database_url`] recorded.
540#[cfg(all(test, feature = "manage", feature = "config"))]
541pub(crate) fn settings_database_url() -> Option<&'static str> {
542    SETTINGS_DATABASE_URL.get().map(String::as_str)
543}
544
545/// Hand the settings' `[database] url` to the resolver, below every env var.
546#[cfg(all(feature = "manage", feature = "config"))]
547pub(crate) fn set_settings_database_url(url: &str) {
548    if !url.trim().is_empty() {
549        let _ = SETTINGS_DATABASE_URL.set(url.to_owned());
550    }
551}
552
553/// Resolve a database URL from environment variables.
554///
555/// Resolution order:
556/// 1. `DATABASE_URL` if set. It is returned as given, except that
557///    `postgresql://` becomes `postgres://`.
558/// 2. Otherwise assembled from:
559///    - `DB_DRIVER` (default `postgres`): one of `postgres`,
560///      `postgresql`, `mysql`, `mariadb`, `sqlite`.
561///    - `DB_HOST` (default `localhost`)
562///    - `DB_PORT` (default `5432` for postgres, `3306` for mysql)
563///    - `DB_USER` (**required** when assembling, except for SQLite)
564///    - `DB_PASSWORD`, or `DB_PASSWORD_FILE` naming a file that holds
565///      it (Docker / Kubernetes secrets); optional, auto percent-encoded
566///    - `DB_NAME` (**required** when assembling; the file path for SQLite)
567///    - `DB_PARAMS` (optional raw query string)
568///
569/// 3. `RUSTANGO__DATABASE__URL`.
570/// 4. `[database] url` from the settings file, once `Cli::with_settings`
571///    has loaded it. The environment always wins over the file.
572///
573/// A blank value counts as unset, so `DATABASE_URL=` falls through.
574///
575/// When both env styles are set, `DATABASE_URL` wins and one warning per
576/// process says so. 0.62.0 flips this: the separate vars will win.
577///
578/// # Errors
579/// - [`EnvError::MissingRequired`] when neither style is set, or when
580///   assembling without `DB_USER` or `DB_NAME`.
581/// - [`EnvError::UnsupportedDriver`] for an unknown `DB_DRIVER`.
582/// - [`EnvError::Parse`] when `DB_PORT` is not a number, or
583///   `DB_PASSWORD_FILE` can't be read or is set with `DB_PASSWORD`.
584pub fn database_url_from_env() -> Result<String, EnvError> {
585    let (url, source) = database_url_with_source()?;
586    note_database_url_source(&DATABASE_URL_NOTED, &source);
587    if let Some(msg) = database_style_conflict_with(&lookup) {
588        warn_style_conflict_once(&msg);
589    }
590    Ok(url)
591}
592
593/// [`database_url_from_env`] plus where the URL came from.
594pub(crate) fn database_url_with_source() -> Result<(String, SettingSource), EnvError> {
595    resolve_database_url_from(&lookup, SETTINGS_DATABASE_URL.get().map(String::as_str))
596}
597
598/// [`database_url_from_env`] with the CLI's advice on where to set it.
599#[cfg(feature = "manage")]
600pub(crate) fn database_url_or_advice() -> Result<String, String> {
601    database_url_from_env()
602        .map_err(|e| format!("{e}. Set it in your shell, or copy `.env.example` to `.env`."))
603}
604
605/// The warning [`database_url_from_env`] logs when `DATABASE_URL` and
606/// `DB_*` vars are both set; `None` when only one style is.
607pub(crate) fn database_style_conflict() -> Option<String> {
608    database_style_conflict_with(&lookup)
609}
610
611fn database_style_conflict_with(get: &dyn Fn(&str) -> Option<String>) -> Option<String> {
612    let url = non_blank(get, "DATABASE_URL")?;
613    let parts: Vec<&str> = DB_PART_VARS
614        .iter()
615        .copied()
616        .filter(|n| non_blank(get, n).is_some())
617        .collect();
618    if parts.is_empty() {
619        return None;
620    }
621    Some(format!(
622        "both DATABASE_URL and {} are set; using DATABASE_URL ({}). \
623         From 0.62.0 the DB_* vars win; unset one of the two styles",
624        parts.join(", "),
625        crate::sql::connect_diagnosis::redact(&url),
626    ))
627}
628
629static DATABASE_URL_NOTED: std::sync::atomic::AtomicBool =
630    std::sync::atomic::AtomicBool::new(false);
631
632/// Log once per `noted` flag where the database URL came from, never the
633/// URL itself. `true` when this call logged; tests pass their own flag.
634fn note_database_url_source(noted: &std::sync::atomic::AtomicBool, source: &SettingSource) -> bool {
635    if noted.swap(true, std::sync::atomic::Ordering::Relaxed) {
636        return false;
637    }
638    tracing::info!(target: "rustango::server", "database URL from {source}");
639    true
640}
641
642/// One warning per process: every pool open resolves the URL again.
643fn warn_style_conflict_once(msg: &str) {
644    static WARNED: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
645    if WARNED.swap(true, std::sync::atomic::Ordering::Relaxed) {
646        return;
647    }
648    // CLI verbs often run with no subscriber; stderr then.
649    if tracing::dispatcher::has_been_set() {
650        tracing::warn!(target: "rustango::sql", "{msg}");
651    } else {
652        eprintln!("warning: {msg}");
653    }
654}
655
656/// `get`, with a blank value read as unset: `.env` templates ship
657/// `DATABASE_URL=` and `DB_HOST=`, which mean "not set", not "empty".
658fn non_blank(get: &dyn Fn(&str) -> Option<String>, name: &str) -> Option<String> {
659    get(name).filter(|v| !v.trim().is_empty())
660}
661
662#[cfg(test)]
663fn resolve_database_url(get: &dyn Fn(&str) -> Option<String>) -> Result<String, EnvError> {
664    resolve_database_url_from(get, None).map(|(url, _)| url)
665}
666
667/// The one database URL resolver: bare env vars, then
668/// `RUSTANGO__DATABASE__URL`, then the settings file's `url`.
669pub(crate) fn resolve_database_url_from(
670    get: &dyn Fn(&str) -> Option<String>,
671    settings_url: Option<&str>,
672) -> Result<(String, SettingSource), EnvError> {
673    let get = &|name: &str| non_blank(get, name);
674    // One scheme downstream, so callers match on `postgres://`.
675    let normal = |url: String| match url.strip_prefix("postgresql://") {
676        Some(rest) => format!("postgres://{rest}"),
677        None => url,
678    };
679    if let Some(url) = get("DATABASE_URL") {
680        return Ok((normal(url), SettingSource::Env("DATABASE_URL")));
681    }
682    if let Some(var) = DB_PART_VARS.iter().copied().find(|n| get(n).is_some()) {
683        return assemble_database_url(get).map(|url| (url, SettingSource::Env(var)));
684    }
685    if let Some(url) = get(DATABASE_URL_OVERRIDE) {
686        return Ok((
687            normal(url),
688            SettingSource::Override(DATABASE_URL_OVERRIDE.into()),
689        ));
690    }
691    if let Some(url) = settings_url.filter(|u| !u.trim().is_empty()) {
692        // A committed dev URL must never reach prod when its secret fails to mount.
693        if is_prod_tier(&get("RUSTANGO_ENV").unwrap_or_default()) {
694            return Err(EnvError::MissingRequired {
695                name: "DATABASE_URL".into(),
696                hint: DB_PROD_FILE_HINT.into(),
697            });
698        }
699        return Ok((
700            normal(url.to_owned()),
701            SettingSource::File("settings file [database] url".into()),
702        ));
703    }
704    Err(EnvError::MissingRequired {
705        name: "DATABASE_URL".into(),
706        hint: DB_UNSET_HINT.into(),
707    })
708}
709
710/// The URL from the separate `DB_*` vars; at least one is set.
711fn assemble_database_url(get: &dyn Fn(&str) -> Option<String>) -> Result<String, EnvError> {
712    let name = |var: &str| {
713        get(var).ok_or_else(|| EnvError::MissingRequired {
714            name: var.into(),
715            hint: format!("set DATABASE_URL or {var} (e.g. `export {var}=app`)"),
716        })
717    };
718    // `DbDriver` has no SQLite variant until 0.62.0: adding one breaks matches.
719    let driver = match get("DB_DRIVER") {
720        Some(s) if s.trim().eq_ignore_ascii_case("sqlite") => {
721            return Ok(sqlite_url(&name("DB_NAME")?, get("DB_PARAMS")));
722        }
723        Some(s) => DbDriver::parse(&s)?,
724        None => DbDriver::Postgres,
725    };
726    let user = name("DB_USER")?;
727    let mut b = DatabaseUrlBuilder::new(driver)
728        .user(user)
729        .database(name("DB_NAME")?);
730    if let Some(host) = get("DB_HOST") {
731        b = b.host(host);
732    }
733    if let Some(port) = get("DB_PORT") {
734        b = b.port(
735            port.parse()
736                .map_err(|e: std::num::ParseIntError| EnvError::Parse {
737                    name: "DB_PORT".into(),
738                    ty: "u16",
739                    detail: e.to_string(),
740                })?,
741        );
742    }
743    if let Some(pw) = db_password(get)? {
744        b = b.password(pw);
745    }
746    if let Some(params) = get("DB_PARAMS") {
747        b = b.params(params);
748    }
749    Ok(b.build())
750}
751
752/// `DB_PASSWORD`, or the contents of `DB_PASSWORD_FILE` less its
753/// trailing newline. Both set is an error: neither silently wins.
754fn db_password(get: &dyn Fn(&str) -> Option<String>) -> Result<Option<String>, EnvError> {
755    let file_err = |detail: String| EnvError::Parse {
756        name: "DB_PASSWORD_FILE".into(),
757        ty: "readable file path",
758        detail,
759    };
760    match (get("DB_PASSWORD"), get("DB_PASSWORD_FILE")) {
761        (Some(_), Some(_)) => Err(file_err(
762            "set DB_PASSWORD or DB_PASSWORD_FILE, not both".into(),
763        )),
764        (Some(pw), None) => Ok(Some(pw)),
765        (None, Some(path)) => {
766            let raw =
767                std::fs::read_to_string(&path).map_err(|e| file_err(format!("{path}: {e}")))?;
768            Ok(Some(raw.trim_end_matches(['\n', '\r']).to_owned()))
769        }
770        (None, None) => Ok(None),
771    }
772}
773
774/// `sqlite:<path>`; `:memory:` stays the in-memory database.
775fn sqlite_url(path: &str, params: Option<String>) -> String {
776    let mut url = format!("sqlite:{path}");
777    if let Some(p) = params.filter(|p| !p.is_empty()) {
778        url.push('?');
779        url.push_str(&p);
780    }
781    url
782}
783
784/// RFC 3986 percent-encoding for the userinfo part (user and
785/// password). Everything outside the unreserved set is encoded. This
786/// over-encodes on purpose: a `@` or `:` left raw in a password would
787/// change how the other side parses the URL.
788fn percent_encode_userinfo(s: &str) -> String {
789    use std::fmt::Write as _;
790    let mut out = String::with_capacity(s.len());
791    for &b in s.as_bytes() {
792        if is_userinfo_unreserved(b) {
793            out.push(b as char);
794        } else {
795            // Encoding to a String can't fail.
796            let _ = write!(out, "%{b:02X}");
797        }
798    }
799    out
800}
801
802/// Percent-encoding for the path part, used for the database name.
803fn percent_encode_path(s: &str) -> String {
804    use std::fmt::Write as _;
805    let mut out = String::with_capacity(s.len());
806    for &b in s.as_bytes() {
807        if is_path_safe(b) {
808            out.push(b as char);
809        } else {
810            let _ = write!(out, "%{b:02X}");
811        }
812    }
813    out
814}
815
816fn is_userinfo_unreserved(b: u8) -> bool {
817    matches!(b,
818        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
819    )
820}
821
822fn is_path_safe(b: u8) -> bool {
823    matches!(b,
824        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
825    )
826}
827
828#[cfg(test)]
829mod tests {
830    use super::*;
831
832    fn with_env<F: FnOnce()>(name: &str, value: &str, f: F) {
833        let _g = crate::error::test_env::lock();
834        std::env::set_var(name, value);
835        f();
836        std::env::remove_var(name);
837    }
838
839    fn without_env<F: FnOnce()>(name: &str, f: F) {
840        let _g = crate::error::test_env::lock();
841        std::env::remove_var(name);
842        f();
843    }
844
845    /// With no bind configured, only the deployed tiers listen on every interface.
846    #[test]
847    fn fallback_bind_is_loopback_off_the_deployed_tiers() {
848        for tier in ["", "dev", "test", "qa"] {
849            assert_eq!(default_bind(tier), "127.0.0.1:8080", "tier {tier:?}");
850        }
851        for tier in ["prod", "PRODUCTION", "staging"] {
852            assert_eq!(default_bind(tier), "0.0.0.0:8080", "tier {tier:?}");
853        }
854    }
855
856    #[test]
857    fn required_returns_parsed_value() {
858        with_env("RUSTANGO_TEST_PORT", "8080", || {
859            let v: u16 = required("RUSTANGO_TEST_PORT").unwrap();
860            assert_eq!(v, 8080);
861        });
862    }
863
864    #[test]
865    fn required_errors_when_missing() {
866        without_env("RUSTANGO_TEST_MISSING_PORT", || {
867            let r = required::<u16>("RUSTANGO_TEST_MISSING_PORT");
868            assert!(matches!(r, Err(EnvError::Missing(_))));
869        });
870    }
871
872    #[test]
873    fn required_errors_on_parse_failure() {
874        with_env("RUSTANGO_TEST_BAD_PORT", "not-a-number", || {
875            let r = required::<u16>("RUSTANGO_TEST_BAD_PORT");
876            assert!(matches!(r, Err(EnvError::Parse { .. })));
877        });
878    }
879
880    #[test]
881    fn with_default_returns_default_when_missing() {
882        without_env("RUSTANGO_TEST_DEFAULT_PORT", || {
883            let v = with_default("RUSTANGO_TEST_DEFAULT_PORT", 9090u16).unwrap();
884            assert_eq!(v, 9090);
885        });
886    }
887
888    #[test]
889    fn with_default_returns_set_value() {
890        with_env("RUSTANGO_TEST_SET_PORT", "1234", || {
891            let v = with_default("RUSTANGO_TEST_SET_PORT", 9090u16).unwrap();
892            assert_eq!(v, 1234);
893        });
894    }
895
896    #[test]
897    fn with_default_errors_on_bad_value() {
898        with_env("RUSTANGO_TEST_BAD_DEFAULT", "garbage", || {
899            let r = with_default("RUSTANGO_TEST_BAD_DEFAULT", 1u16);
900            assert!(matches!(r, Err(EnvError::Parse { .. })));
901        });
902    }
903
904    #[test]
905    fn optional_returns_none_when_missing() {
906        without_env("RUSTANGO_TEST_OPT_MISSING", || {
907            let v: Option<i32> = optional("RUSTANGO_TEST_OPT_MISSING").unwrap();
908            assert_eq!(v, None);
909        });
910    }
911
912    #[test]
913    fn optional_returns_some_when_set() {
914        with_env("RUSTANGO_TEST_OPT_SET", "42", || {
915            let v: Option<i32> = optional("RUSTANGO_TEST_OPT_SET").unwrap();
916            assert_eq!(v, Some(42));
917        });
918    }
919
920    #[test]
921    fn list_parses_comma_separated() {
922        with_env(
923            "RUSTANGO_TEST_HOSTS",
924            "a.example.com, b.example.com,c.example.com",
925            || {
926                let v: Vec<String> = list("RUSTANGO_TEST_HOSTS").unwrap().unwrap();
927                assert_eq!(v, vec!["a.example.com", "b.example.com", "c.example.com"]);
928            },
929        );
930    }
931
932    #[test]
933    fn list_drops_empty_entries() {
934        with_env("RUSTANGO_TEST_LIST_TRAILING", "a,b,,", || {
935            let v: Vec<String> = list("RUSTANGO_TEST_LIST_TRAILING").unwrap().unwrap();
936            assert_eq!(v, vec!["a", "b"]);
937        });
938    }
939
940    #[test]
941    fn list_returns_none_when_missing() {
942        without_env("RUSTANGO_TEST_LIST_MISSING", || {
943            let v: Option<Vec<String>> = list("RUSTANGO_TEST_LIST_MISSING").unwrap();
944            assert_eq!(v, None);
945        });
946    }
947
948    #[test]
949    fn list_parses_typed_values() {
950        with_env("RUSTANGO_TEST_PORTS", "8080, 8081, 8082", || {
951            let v: Vec<u16> = list("RUSTANGO_TEST_PORTS").unwrap().unwrap();
952            assert_eq!(v, vec![8080, 8081, 8082]);
953        });
954    }
955
956    #[test]
957    fn duration_secs_parses() {
958        with_env("RUSTANGO_TEST_TTL", "60", || {
959            let d = duration_secs("RUSTANGO_TEST_TTL").unwrap();
960            assert_eq!(d, Duration::from_secs(60));
961        });
962    }
963
964    // -------------------------------------------------------------- Validator tests
965
966    #[test]
967    fn validator_check_passes_when_all_required_set() {
968        // Set both vars inline: nesting `with_env` would deadlock.
969        let _g = crate::error::test_env::lock();
970        std::env::set_var("RUSTANGO_TEST_VALID_DB", "x");
971        std::env::set_var("RUSTANGO_TEST_VALID_KEY", "y");
972        let v = Validator::new()
973            .require("RUSTANGO_TEST_VALID_DB", "db url")
974            .require("RUSTANGO_TEST_VALID_KEY", "key");
975        assert!(v.check().is_empty());
976        assert!(v.check_or_error().is_ok());
977        std::env::remove_var("RUSTANGO_TEST_VALID_DB");
978        std::env::remove_var("RUSTANGO_TEST_VALID_KEY");
979    }
980
981    #[test]
982    fn validator_check_lists_missing_required() {
983        without_env("RUSTANGO_TEST_MISSING_REQ", || {
984            let v = Validator::new().require("RUSTANGO_TEST_MISSING_REQ", "needed for X");
985            let missing = v.check();
986            assert_eq!(missing.len(), 1);
987            assert_eq!(missing[0].name, "RUSTANGO_TEST_MISSING_REQ");
988        });
989    }
990
991    #[test]
992    fn validator_optional_not_in_missing_list() {
993        without_env("RUSTANGO_TEST_OPT", || {
994            let v = Validator::new().optional("RUSTANGO_TEST_OPT", "fallback OK");
995            assert!(v.check().is_empty());
996        });
997    }
998
999    #[test]
1000    fn validator_check_or_error_returns_formatted_message() {
1001        without_env("RUSTANGO_TEST_FORMAT_REQ", || {
1002            let v = Validator::new().require("RUSTANGO_TEST_FORMAT_REQ", "Postgres URL");
1003            let err = v.check_or_error().unwrap_err();
1004            assert!(err.contains("RUSTANGO_TEST_FORMAT_REQ"));
1005            assert!(err.contains("Postgres URL"));
1006            assert!(err.contains("export RUSTANGO_TEST_FORMAT_REQ=..."));
1007        });
1008    }
1009
1010    #[test]
1011    fn validator_len_and_is_empty() {
1012        let v = Validator::new();
1013        assert!(v.is_empty());
1014        assert_eq!(v.len(), 0);
1015        let v = v.require("X", "x").optional("Y", "y");
1016        assert_eq!(v.len(), 2);
1017        assert!(!v.is_empty());
1018    }
1019
1020    // -------------------------------------------------------------- DatabaseUrlBuilder tests
1021
1022    #[test]
1023    fn builder_assembles_minimal_postgres_url() {
1024        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
1025            .user("app")
1026            .database("appdb")
1027            .build();
1028        assert_eq!(url, "postgres://app@localhost:5432/appdb");
1029    }
1030
1031    #[test]
1032    fn builder_assembles_full_postgres_url() {
1033        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
1034            .host("db.internal")
1035            .port(6543)
1036            .user("svc")
1037            .password("secret")
1038            .database("app")
1039            .params("sslmode=require")
1040            .build();
1041        assert_eq!(
1042            url,
1043            "postgres://svc:secret@db.internal:6543/app?sslmode=require"
1044        );
1045    }
1046
1047    #[test]
1048    fn builder_percent_encodes_password_specials() {
1049        // `@`, `:`, `/`, `#`, `?`, `%` must all be encoded inside the password.
1050        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
1051            .user("u")
1052            .password("p@ss:/word#?%")
1053            .database("db")
1054            .build();
1055        assert_eq!(
1056            url,
1057            "postgres://u:p%40ss%3A%2Fword%23%3F%25@localhost:5432/db"
1058        );
1059    }
1060
1061    #[test]
1062    fn builder_uses_mysql_default_port() {
1063        let url = DatabaseUrlBuilder::new(DbDriver::Mysql)
1064            .user("u")
1065            .database("d")
1066            .build();
1067        assert_eq!(url, "mysql://u@localhost:3306/d");
1068    }
1069
1070    #[test]
1071    fn db_driver_parses_aliases() {
1072        assert_eq!(DbDriver::parse("postgres").unwrap(), DbDriver::Postgres);
1073        assert_eq!(DbDriver::parse("PostgreSQL").unwrap(), DbDriver::Postgres);
1074        assert_eq!(DbDriver::parse("pg").unwrap(), DbDriver::Postgres);
1075        assert_eq!(DbDriver::parse("mysql").unwrap(), DbDriver::Mysql);
1076        assert_eq!(DbDriver::parse("MariaDB").unwrap(), DbDriver::Mysql);
1077        assert!(matches!(
1078            DbDriver::parse("oracle"),
1079            Err(EnvError::UnsupportedDriver(_))
1080        ));
1081    }
1082
1083    /// Drop guard that puts a set of env vars back as it found them.
1084    /// The developer's shell may already export `DATABASE_URL`.
1085    struct EnvSnapshot {
1086        saved: Vec<(&'static str, Option<String>)>,
1087    }
1088    impl EnvSnapshot {
1089        fn capture(names: &[&'static str]) -> Self {
1090            let saved = names.iter().map(|n| (*n, std::env::var(n).ok())).collect();
1091            for n in names {
1092                std::env::remove_var(n);
1093            }
1094            Self { saved }
1095        }
1096    }
1097    impl Drop for EnvSnapshot {
1098        fn drop(&mut self) {
1099            for (name, prev) in &self.saved {
1100                match prev {
1101                    Some(v) => std::env::set_var(name, v),
1102                    None => std::env::remove_var(name),
1103                }
1104            }
1105        }
1106    }
1107
1108    const DB_VARS: &[&str] = &[
1109        "DATABASE_URL",
1110        "DB_DRIVER",
1111        "DB_HOST",
1112        "DB_PORT",
1113        "DB_USER",
1114        "DB_PASSWORD",
1115        "DB_PASSWORD_FILE",
1116        "DB_NAME",
1117        "DB_PARAMS",
1118        "RUSTANGO__DATABASE__URL",
1119    ];
1120
1121    #[test]
1122    fn database_url_from_env_prefers_database_url() {
1123        let _g = crate::error::test_env::lock();
1124        let _snap = EnvSnapshot::capture(DB_VARS);
1125        std::env::set_var("DATABASE_URL", "postgres://x@y:1/z");
1126        let url = database_url_from_env().unwrap();
1127        assert_eq!(url, "postgres://x@y:1/z");
1128    }
1129
1130    #[test]
1131    fn database_url_normalizes_postgresql_scheme() {
1132        let _g = crate::error::test_env::lock();
1133        let _snap = EnvSnapshot::capture(DB_VARS);
1134        std::env::set_var("DATABASE_URL", "postgresql://x@y:1/z");
1135        let url = database_url_from_env().unwrap();
1136        assert_eq!(url, "postgres://x@y:1/z");
1137    }
1138
1139    #[test]
1140    fn database_url_assembles_from_split_vars() {
1141        let _g = crate::error::test_env::lock();
1142        let _snap = EnvSnapshot::capture(DB_VARS);
1143        std::env::set_var("DB_HOST", "db.example.com");
1144        std::env::set_var("DB_PORT", "6543");
1145        std::env::set_var("DB_USER", "app");
1146        std::env::set_var("DB_PASSWORD", "p@ss");
1147        std::env::set_var("DB_NAME", "appdb");
1148        let url = database_url_from_env().unwrap();
1149        assert_eq!(url, "postgres://app:p%40ss@db.example.com:6543/appdb");
1150    }
1151
1152    #[test]
1153    fn database_url_assembles_mysql_when_driver_set() {
1154        let _g = crate::error::test_env::lock();
1155        let _snap = EnvSnapshot::capture(DB_VARS);
1156        std::env::set_var("DB_DRIVER", "mysql");
1157        std::env::set_var("DB_USER", "u");
1158        std::env::set_var("DB_NAME", "d");
1159        let url = database_url_from_env().unwrap();
1160        assert_eq!(url, "mysql://u@localhost:3306/d");
1161    }
1162
1163    #[test]
1164    fn database_url_errors_when_required_missing() {
1165        let _g = crate::error::test_env::lock();
1166        let _snap = EnvSnapshot::capture(DB_VARS);
1167        std::env::set_var("DB_NAME", "x");
1168        // DB_USER missing
1169        let err = database_url_from_env().unwrap_err();
1170        match err {
1171            EnvError::MissingRequired { name, .. } => assert_eq!(name, "DB_USER"),
1172            other => panic!("wrong variant: {other:?}"),
1173        }
1174    }
1175
1176    /// A lookup over fixed pairs, so these need no process env.
1177    fn vars(pairs: &[(&str, &str)]) -> impl Fn(&str) -> Option<String> {
1178        let map: std::collections::HashMap<String, String> = pairs
1179            .iter()
1180            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
1181            .collect();
1182        move |name| map.get(name).cloned()
1183    }
1184
1185    #[test]
1186    fn database_url_assembles_sqlite_from_db_name() {
1187        let get = vars(&[("DB_DRIVER", "SQLite"), ("DB_NAME", "./var/app.db")]);
1188        assert_eq!(resolve_database_url(&get).unwrap(), "sqlite:./var/app.db");
1189        let get = vars(&[("DB_DRIVER", "sqlite"), ("DB_NAME", ":memory:")]);
1190        assert_eq!(resolve_database_url(&get).unwrap(), "sqlite::memory:");
1191        let get = vars(&[
1192            ("DB_DRIVER", "sqlite"),
1193            ("DB_NAME", "/abs/x.db"),
1194            ("DB_PARAMS", "mode=rwc"),
1195        ]);
1196        assert_eq!(
1197            resolve_database_url(&get).unwrap(),
1198            "sqlite:/abs/x.db?mode=rwc"
1199        );
1200        let get = vars(&[("DB_DRIVER", "sqlite")]);
1201        match resolve_database_url(&get).unwrap_err() {
1202            EnvError::MissingRequired { name, .. } => assert_eq!(name, "DB_NAME"),
1203            other => panic!("wrong variant: {other:?}"),
1204        }
1205    }
1206
1207    #[test]
1208    fn database_url_reads_the_password_file() {
1209        let dir = tempfile::tempdir().unwrap();
1210        let path = dir.path().join("pw");
1211        std::fs::write(&path, "p@ss word\n").unwrap();
1212        let path = path.to_string_lossy().into_owned();
1213        let get = vars(&[
1214            ("DB_USER", "u"),
1215            ("DB_NAME", "d"),
1216            ("DB_PASSWORD_FILE", path.as_str()),
1217        ]);
1218        assert_eq!(
1219            resolve_database_url(&get).unwrap(),
1220            "postgres://u:p%40ss%20word@localhost:5432/d"
1221        );
1222        let both = vars(&[
1223            ("DB_USER", "u"),
1224            ("DB_NAME", "d"),
1225            ("DB_PASSWORD", "x"),
1226            ("DB_PASSWORD_FILE", path.as_str()),
1227        ]);
1228        assert!(matches!(
1229            resolve_database_url(&both),
1230            Err(EnvError::Parse { ref name, .. }) if name == "DB_PASSWORD_FILE"
1231        ));
1232        let missing = vars(&[
1233            ("DB_USER", "u"),
1234            ("DB_NAME", "d"),
1235            ("DB_PASSWORD_FILE", "/nonexistent/rustango-pw"),
1236        ]);
1237        assert!(resolve_database_url(&missing).is_err());
1238    }
1239
1240    /// A blank var (a `.env` template's `DATABASE_URL=`) counts as unset.
1241    #[test]
1242    fn database_url_treats_blank_vars_as_unset() {
1243        let get = vars(&[
1244            ("DATABASE_URL", " "),
1245            ("DB_DRIVER", ""),
1246            ("DB_HOST", ""),
1247            ("DB_PORT", ""),
1248            ("DB_PASSWORD", ""),
1249            ("DB_PARAMS", ""),
1250            ("DB_USER", "u"),
1251            ("DB_NAME", "d"),
1252        ]);
1253        assert_eq!(
1254            resolve_database_url(&get).unwrap(),
1255            "postgres://u@localhost:5432/d"
1256        );
1257        assert!(database_style_conflict_with(&get).is_none());
1258        match resolve_database_url(&vars(&[("DB_USER", ""), ("DB_NAME", "d")])).unwrap_err() {
1259            EnvError::MissingRequired { name, .. } => assert_eq!(name, "DB_USER"),
1260            other => panic!("wrong variant: {other:?}"),
1261        }
1262        assert!(matches!(
1263            resolve_database_url(&vars(&[("DATABASE_URL", ""), ("DB_HOST", "")])),
1264            Err(EnvError::MissingRequired { ref name, .. }) if name == "DATABASE_URL"
1265        ));
1266    }
1267
1268    #[test]
1269    fn database_url_brackets_an_ipv6_host() {
1270        let get = vars(&[("DB_HOST", "::1"), ("DB_USER", "u"), ("DB_NAME", "d")]);
1271        assert_eq!(
1272            resolve_database_url(&get).unwrap(),
1273            "postgres://u@[::1]:5432/d"
1274        );
1275        let b = DatabaseUrlBuilder::new(DbDriver::Mysql).host("[fe80::1]");
1276        assert_eq!(b.build(), "mysql://[fe80::1]:3306");
1277        let b = DatabaseUrlBuilder::new(DbDriver::Mysql).host("fe80::1%eth0");
1278        assert_eq!(b.build(), "mysql://[fe80::1%25eth0]:3306");
1279    }
1280
1281    #[test]
1282    fn database_url_unset_error_names_both_styles() {
1283        let err = resolve_database_url(&vars(&[])).unwrap_err().to_string();
1284        for needle in [
1285            "DATABASE_URL",
1286            "DB_USER",
1287            "DB_NAME",
1288            "DB_PASSWORD_FILE",
1289            "RUSTANGO__DATABASE__URL",
1290            "[database] url",
1291        ] {
1292            assert!(err.contains(needle), "{needle} missing from: {err}");
1293        }
1294    }
1295
1296    #[test]
1297    fn database_url_still_wins_and_the_conflict_is_redacted() {
1298        let get = vars(&[
1299            ("DATABASE_URL", "postgres://app:s3cret@db/app"),
1300            ("DB_USER", "other"),
1301            ("DB_NAME", "otherdb"),
1302            ("DB_HOST", ""),
1303        ]);
1304        assert_eq!(
1305            resolve_database_url(&get).unwrap(),
1306            "postgres://app:s3cret@db/app"
1307        );
1308        let msg = database_style_conflict_with(&get).expect("conflict");
1309        assert!(!msg.contains("s3cret"), "{msg}");
1310        assert!(
1311            msg.contains("DB_USER, DB_NAME") && !msg.contains("DB_HOST"),
1312            "{msg}"
1313        );
1314        assert!(msg.contains("0.62.0"), "{msg}");
1315        assert!(
1316            database_style_conflict_with(&vars(&[("DATABASE_URL", "sqlite::memory:")])).is_none()
1317        );
1318        assert!(database_style_conflict_with(&vars(&[("DB_USER", "u")])).is_none());
1319    }
1320
1321    /// Bare env, then `RUSTANGO__DATABASE__URL`, then the settings file.
1322    #[test]
1323    fn database_url_precedence_puts_the_environment_over_the_file() {
1324        let file = Some("postgres://from-file/dev");
1325        let resolve = |pairs: &[(&str, &str)]| resolve_database_url_from(&vars(pairs), file);
1326        assert_eq!(
1327            resolve(&[]).unwrap(),
1328            (
1329                "postgres://from-file/dev".to_owned(),
1330                SettingSource::File("settings file [database] url".into())
1331            )
1332        );
1333        let over = [("RUSTANGO__DATABASE__URL", "postgresql://over/x")];
1334        assert_eq!(
1335            resolve(&over).unwrap(),
1336            (
1337                "postgres://over/x".to_owned(),
1338                SettingSource::Override("RUSTANGO__DATABASE__URL".into())
1339            )
1340        );
1341        let parts = [
1342            ("RUSTANGO__DATABASE__URL", "postgres://over/x"),
1343            ("DB_USER", "u"),
1344            ("DB_NAME", "d"),
1345        ];
1346        assert_eq!(resolve(&parts).unwrap().1, SettingSource::Env("DB_USER"));
1347        let bare = [
1348            ("RUSTANGO__DATABASE__URL", "postgres://over/x"),
1349            ("DATABASE_URL", "postgres://env/prod"),
1350        ];
1351        assert_eq!(
1352            resolve(&bare).unwrap(),
1353            (
1354                "postgres://env/prod".to_owned(),
1355                SettingSource::Env("DATABASE_URL")
1356            )
1357        );
1358        // A blank override and a blank file value count as unset.
1359        let blank = resolve_database_url_from(&vars(&[("RUSTANGO__DATABASE__URL", " ")]), Some(""));
1360        assert!(matches!(blank, Err(EnvError::MissingRequired { .. })));
1361    }
1362
1363    /// On prod a settings-file URL is refused; env channels still work.
1364    #[test]
1365    fn prod_refuses_a_database_url_from_the_settings_file() {
1366        let file = Some("postgres://dev-host/dev");
1367        let prod = |extra: &[(&str, &str)]| {
1368            let mut pairs = vec![("RUSTANGO_ENV", "Production")];
1369            pairs.extend_from_slice(extra);
1370            resolve_database_url_from(&vars(&pairs), file)
1371        };
1372        let err = prod(&[]).unwrap_err().to_string();
1373        assert!(
1374            err.contains("RUSTANGO__DATABASE__URL") && err.contains("DATABASE_URL"),
1375            "{err}"
1376        );
1377        assert!(!err.contains("dev-host"), "{err}");
1378        let over = [("RUSTANGO__DATABASE__URL", "postgres://p/x")];
1379        assert_eq!(prod(&over).unwrap().0, "postgres://p/x");
1380        let bare = [("DATABASE_URL", "postgres://p/y")];
1381        assert_eq!(prod(&bare).unwrap().0, "postgres://p/y");
1382        let staging = vars(&[("RUSTANGO_ENV", "staging")]);
1383        assert!(resolve_database_url_from(&staging, file).is_ok());
1384    }
1385
1386    /// The source is logged once per flag, and the line has no URL. A
1387    /// fresh flag, so tests resolving the URL first can't burn it.
1388    #[test]
1389    fn the_database_url_source_is_noted_once() {
1390        let noted = std::sync::atomic::AtomicBool::new(false);
1391        let src = SettingSource::File("settings file [database] url".into());
1392        assert_eq!(src.to_string(), "settings file [database] url");
1393        assert!(note_database_url_source(&noted, &src));
1394        assert!(!note_database_url_source(
1395            &noted,
1396            &SettingSource::Env("DATABASE_URL")
1397        ));
1398    }
1399
1400    #[test]
1401    fn database_url_errors_on_bad_driver() {
1402        let _g = crate::error::test_env::lock();
1403        let _snap = EnvSnapshot::capture(DB_VARS);
1404        std::env::set_var("DB_DRIVER", "oracle");
1405        std::env::set_var("DB_USER", "u");
1406        std::env::set_var("DB_NAME", "d");
1407        let err = database_url_from_env().unwrap_err();
1408        assert!(matches!(err, EnvError::UnsupportedDriver(_)));
1409    }
1410}