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)]
37pub enum EnvError {
38    #[error("environment variable `{0}` is not set")]
39    Missing(String),
40    #[error("environment variable `{name}` is not valid {ty}: {detail}")]
41    Parse {
42        name: String,
43        ty: &'static str,
44        detail: String,
45    },
46    /// A variable needed to assemble a value (see
47    /// [`database_url_from_env`]) is unset. The `hint` tells the
48    /// operator what to set.
49    #[error("environment variable `{name}` is required: {hint}")]
50    MissingRequired { name: String, hint: String },
51    /// `DB_DRIVER`, or the scheme read from `DATABASE_URL`, is not a
52    /// backend we support.
53    #[error("unsupported DB driver `{0}` — expected one of: postgres, postgresql, mysql")]
54    UnsupportedDriver(String),
55}
56
57fn lookup(name: &str) -> Option<String> {
58    match std::env::var(name) {
59        Ok(v) => Some(v),
60        Err(VarError::NotPresent) => None,
61        Err(VarError::NotUnicode(_)) => None,
62    }
63}
64
65/// Read a required env var and parse it into `T`.
66///
67/// # Errors
68/// [`EnvError::Missing`] if the variable is unset.
69/// [`EnvError::Parse`] if the value can't be parsed into `T`.
70pub fn required<T>(name: &str) -> Result<T, EnvError>
71where
72    T: FromStr,
73    T::Err: std::fmt::Display,
74{
75    let raw = lookup(name).ok_or_else(|| EnvError::Missing(name.to_owned()))?;
76    raw.parse::<T>().map_err(|e| EnvError::Parse {
77        name: name.to_owned(),
78        ty: std::any::type_name::<T>(),
79        detail: e.to_string(),
80    })
81}
82
83/// Read an env var, returning `default` when unset.
84///
85/// # Errors
86/// [`EnvError::Parse`] if the variable is set but does not parse into
87/// `T`. A typo is reported rather than replaced by the default,
88/// because a wrong value is more dangerous than a missing one.
89pub fn with_default<T>(name: &str, default: T) -> Result<T, EnvError>
90where
91    T: FromStr,
92    T::Err: std::fmt::Display,
93{
94    let Some(raw) = lookup(name) else {
95        return Ok(default);
96    };
97    raw.parse::<T>().map_err(|e| EnvError::Parse {
98        name: name.to_owned(),
99        ty: std::any::type_name::<T>(),
100        detail: e.to_string(),
101    })
102}
103
104/// Read an optional env var. Returns `None` if unset, `Some(parsed)` if set.
105///
106/// # Errors
107/// [`EnvError::Parse`] if the variable IS set but can't be parsed.
108pub fn optional<T>(name: &str) -> Result<Option<T>, EnvError>
109where
110    T: FromStr,
111    T::Err: std::fmt::Display,
112{
113    let Some(raw) = lookup(name) else {
114        return Ok(None);
115    };
116    raw.parse::<T>().map(Some).map_err(|e| EnvError::Parse {
117        name: name.to_owned(),
118        ty: std::any::type_name::<T>(),
119        detail: e.to_string(),
120    })
121}
122
123/// Read a comma-separated env var into a `Vec<T>`. Empty entries,
124/// such as the one a trailing comma makes, are dropped. Gives `None`
125/// when the variable is unset.
126///
127/// # Errors
128/// [`EnvError::Parse`] if any entry can't be parsed into `T`.
129pub fn list<T>(name: &str) -> Result<Option<Vec<T>>, EnvError>
130where
131    T: FromStr,
132    T::Err: std::fmt::Display,
133{
134    let Some(raw) = lookup(name) else {
135        return Ok(None);
136    };
137    let mut out = Vec::new();
138    for part in raw.split(',').map(str::trim).filter(|s| !s.is_empty()) {
139        let v = part.parse::<T>().map_err(|e| EnvError::Parse {
140            name: name.to_owned(),
141            ty: std::any::type_name::<T>(),
142            detail: format!("entry `{part}`: {e}"),
143        })?;
144        out.push(v);
145    }
146    Ok(Some(out))
147}
148
149/// Read an env var as a `Duration`, reading the value as seconds.
150///
151/// # Errors
152/// [`EnvError::Missing`] when unset.
153/// [`EnvError::Parse`] when the value isn't a non-negative integer.
154pub fn duration_secs(name: &str) -> Result<Duration, EnvError> {
155    let secs: u64 = required(name)?;
156    Ok(Duration::from_secs(secs))
157}
158
159/// Read an env var as a `Duration`, reading the value as milliseconds.
160///
161/// # Errors
162/// As [`duration_secs`].
163pub fn duration_millis(name: &str) -> Result<Duration, EnvError> {
164    let ms: u64 = required(name)?;
165    Ok(Duration::from_millis(ms))
166}
167
168// ------------------------------------------------------------------ Startup validation
169
170/// One declared environment-variable requirement.
171#[derive(Debug, Clone)]
172pub struct EnvRequirement {
173    pub name: String,
174    pub description: String,
175    pub required: bool,
176}
177
178/// Collects the env vars an app needs and reports every missing one
179/// at once.
180///
181/// Run it at startup so a bad config gives one clear message instead
182/// of a crash on the first DB call.
183///
184/// ## Quick start
185///
186/// ```ignore
187/// use rustango::env::Validator;
188///
189/// Validator::new()
190///     .require("DATABASE_URL", "Postgres connection string (postgres://...)")
191///     .require("SECRET_KEY", "32+ byte secret for cookie signing")
192///     .optional("REDIS_URL", "Redis connection string (defaults to in-memory cache)")
193///     .check_or_panic();
194/// ```
195#[derive(Default)]
196pub struct Validator {
197    reqs: Vec<EnvRequirement>,
198}
199
200impl Validator {
201    #[must_use]
202    pub fn new() -> Self {
203        Self::default()
204    }
205
206    /// Add a required env var.
207    #[must_use]
208    pub fn require(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
209        self.reqs.push(EnvRequirement {
210            name: name.into(),
211            description: description.into(),
212            required: true,
213        });
214        self
215    }
216
217    /// Add an optional env var. Missing ones are listed as a hint,
218    /// not a failure.
219    #[must_use]
220    pub fn optional(mut self, name: impl Into<String>, description: impl Into<String>) -> Self {
221        self.reqs.push(EnvRequirement {
222            name: name.into(),
223            description: description.into(),
224            required: false,
225        });
226        self
227    }
228
229    /// Return the required vars that are unset. Optional ones are
230    /// never included.
231    #[must_use]
232    pub fn check(&self) -> Vec<&EnvRequirement> {
233        self.reqs
234            .iter()
235            .filter(|r| r.required && lookup(&r.name).is_none())
236            .collect()
237    }
238
239    /// Like [`Self::check`], but returns a ready-to-print message
240    /// listing each missing var and an `export` line for it.
241    ///
242    /// # Errors
243    /// The formatted message, when a required var is missing.
244    pub fn check_or_error(&self) -> Result<(), String> {
245        let missing = self.check();
246        if missing.is_empty() {
247            return Ok(());
248        }
249        let mut out = String::from("Missing required environment variables:\n");
250        for req in &missing {
251            out.push_str(&format!("  - {} — {}\n", req.name, req.description));
252        }
253        out.push_str("\nSet them and re-run, e.g.:\n");
254        for req in &missing {
255            out.push_str(&format!("  export {}=...\n", req.name));
256        }
257        Err(out)
258    }
259
260    /// Like [`Self::check_or_error`], but panics with the message.
261    /// Meant for startup.
262    ///
263    /// # Panics
264    /// When a required var is missing.
265    pub fn check_or_panic(&self) {
266        if let Err(msg) = self.check_or_error() {
267            panic!("{msg}");
268        }
269    }
270
271    /// Number of declared requirements.
272    #[must_use]
273    pub fn len(&self) -> usize {
274        self.reqs.len()
275    }
276
277    #[must_use]
278    pub fn is_empty(&self) -> bool {
279        self.reqs.is_empty()
280    }
281}
282
283// ------------------------------------------------------------------ Database URL assembly
284
285/// Backends recognized by [`DatabaseUrlBuilder`] and
286/// [`database_url_from_env`].
287#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum DbDriver {
289    Postgres,
290    Mysql,
291}
292
293impl DbDriver {
294    /// The scheme as it appears in the URL: `"postgres"` or
295    /// `"mysql"`. `postgresql` parses, but comes back as `postgres`.
296    #[must_use]
297    pub fn scheme(self) -> &'static str {
298        match self {
299            DbDriver::Postgres => "postgres",
300            DbDriver::Mysql => "mysql",
301        }
302    }
303
304    /// Default TCP port for the backend.
305    #[must_use]
306    pub fn default_port(self) -> u16 {
307        match self {
308            DbDriver::Postgres => 5432,
309            DbDriver::Mysql => 3306,
310        }
311    }
312
313    fn parse(s: &str) -> Result<Self, EnvError> {
314        match s.trim().to_ascii_lowercase().as_str() {
315            "postgres" | "postgresql" | "pg" => Ok(DbDriver::Postgres),
316            "mysql" | "mariadb" => Ok(DbDriver::Mysql),
317            other => Err(EnvError::UnsupportedDriver(other.to_owned())),
318        }
319    }
320}
321
322/// Builds a database URL from separate fields.
323///
324/// [`database_url_from_env`] uses it when `DATABASE_URL` is unset and
325/// the details come from `DB_HOST`, `DB_USER` and friends. The user
326/// and password are percent-encoded, so `@`, `:`, `/`, `#`, `?`, `%`
327/// and other URL-special characters in a password cannot break the
328/// URL apart.
329///
330/// ```ignore
331/// use rustango::env::{DatabaseUrlBuilder, DbDriver};
332///
333/// let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
334///     .host("db.internal")
335///     .user("svc")
336///     .password("p@ss/word")  // auto-encoded
337///     .database("app")
338///     .build();
339/// assert_eq!(url, "postgres://svc:p%40ss%2Fword@db.internal:5432/app");
340/// ```
341#[derive(Debug, Clone)]
342pub struct DatabaseUrlBuilder {
343    driver: DbDriver,
344    host: Option<String>,
345    port: Option<u16>,
346    user: Option<String>,
347    password: Option<String>,
348    database: Option<String>,
349    params: Option<String>,
350}
351
352impl DatabaseUrlBuilder {
353    #[must_use]
354    pub fn new(driver: DbDriver) -> Self {
355        Self {
356            driver,
357            host: None,
358            port: None,
359            user: None,
360            password: None,
361            database: None,
362            params: None,
363        }
364    }
365
366    #[must_use]
367    pub fn host(mut self, host: impl Into<String>) -> Self {
368        self.host = Some(host.into());
369        self
370    }
371    #[must_use]
372    pub fn port(mut self, port: u16) -> Self {
373        self.port = Some(port);
374        self
375    }
376    #[must_use]
377    pub fn user(mut self, user: impl Into<String>) -> Self {
378        self.user = Some(user.into());
379        self
380    }
381    #[must_use]
382    pub fn password(mut self, password: impl Into<String>) -> Self {
383        self.password = Some(password.into());
384        self
385    }
386    #[must_use]
387    pub fn database(mut self, database: impl Into<String>) -> Self {
388        self.database = Some(database.into());
389        self
390    }
391    /// Raw query string put after `?`, such as
392    /// `"sslmode=require&application_name=app"`. You must
393    /// percent-encode it yourself; this builder does not.
394    #[must_use]
395    pub fn params(mut self, params: impl Into<String>) -> Self {
396        self.params = Some(params.into());
397        self
398    }
399
400    /// Build `scheme://[user[:pw]@]host:port[/db][?params]`. Host
401    /// falls back to `localhost` and port to the driver's default.
402    #[must_use]
403    pub fn build(&self) -> String {
404        let scheme = self.driver.scheme();
405        let host = self.host.as_deref().unwrap_or("localhost");
406        let port = self.port.unwrap_or(self.driver.default_port());
407        let mut out = format!("{scheme}://");
408        if let Some(user) = &self.user {
409            out.push_str(&percent_encode_userinfo(user));
410            if let Some(pw) = &self.password {
411                out.push(':');
412                out.push_str(&percent_encode_userinfo(pw));
413            }
414            out.push('@');
415        }
416        out.push_str(host);
417        out.push(':');
418        out.push_str(&port.to_string());
419        if let Some(db) = &self.database {
420            out.push('/');
421            out.push_str(&percent_encode_path(db));
422        }
423        if let Some(params) = &self.params {
424            if !params.is_empty() {
425                out.push('?');
426                out.push_str(params);
427            }
428        }
429        out
430    }
431}
432
433/// Resolve a database URL from environment variables.
434///
435/// Resolution order:
436/// 1. `DATABASE_URL` if set. It is returned as given, except that
437///    `postgresql://` becomes `postgres://`.
438/// 2. Otherwise assembled from:
439///    - `DB_DRIVER` (default `postgres`): one of `postgres`,
440///      `postgresql`, `mysql`, `mariadb`.
441///    - `DB_HOST` (default `localhost`)
442///    - `DB_PORT` (default `5432` for postgres, `3306` for mysql)
443///    - `DB_USER` (**required** when assembling)
444///    - `DB_PASSWORD` (optional; auto percent-encoded)
445///    - `DB_NAME` (**required** when assembling)
446///    - `DB_PARAMS` (optional raw query string)
447///
448/// # Errors
449/// - [`EnvError::MissingRequired`] when assembling without `DB_USER`
450///   or `DB_NAME`.
451/// - [`EnvError::UnsupportedDriver`] for an unknown `DB_DRIVER`.
452/// - [`EnvError::Parse`] when `DB_PORT` is not a number.
453pub fn database_url_from_env() -> Result<String, EnvError> {
454    if let Some(url) = lookup("DATABASE_URL") {
455        // One scheme downstream, so callers match on `postgres://`.
456        if let Some(rest) = url.strip_prefix("postgresql://") {
457            return Ok(format!("postgres://{rest}"));
458        }
459        return Ok(url);
460    }
461    let driver = match lookup("DB_DRIVER") {
462        Some(s) => DbDriver::parse(&s)?,
463        None => DbDriver::Postgres,
464    };
465    let user = lookup("DB_USER").ok_or_else(|| EnvError::MissingRequired {
466        name: "DB_USER".into(),
467        hint: "set DATABASE_URL or DB_USER (e.g. `export DB_USER=app`)".into(),
468    })?;
469    let name = lookup("DB_NAME").ok_or_else(|| EnvError::MissingRequired {
470        name: "DB_NAME".into(),
471        hint: "set DATABASE_URL or DB_NAME (e.g. `export DB_NAME=app`)".into(),
472    })?;
473    let mut b = DatabaseUrlBuilder::new(driver).user(user).database(name);
474    if let Some(host) = lookup("DB_HOST") {
475        b = b.host(host);
476    }
477    if let Some(port) = optional::<u16>("DB_PORT")? {
478        b = b.port(port);
479    }
480    if let Some(pw) = lookup("DB_PASSWORD") {
481        b = b.password(pw);
482    }
483    if let Some(params) = lookup("DB_PARAMS") {
484        b = b.params(params);
485    }
486    Ok(b.build())
487}
488
489/// RFC 3986 percent-encoding for the userinfo part (user and
490/// password). Everything outside the unreserved set is encoded. This
491/// over-encodes on purpose: a `@` or `:` left raw in a password would
492/// change how the other side parses the URL.
493fn percent_encode_userinfo(s: &str) -> String {
494    use std::fmt::Write as _;
495    let mut out = String::with_capacity(s.len());
496    for &b in s.as_bytes() {
497        if is_userinfo_unreserved(b) {
498            out.push(b as char);
499        } else {
500            // Encoding to a String can't fail.
501            let _ = write!(out, "%{b:02X}");
502        }
503    }
504    out
505}
506
507/// Percent-encoding for the path part, used for the database name.
508fn percent_encode_path(s: &str) -> String {
509    use std::fmt::Write as _;
510    let mut out = String::with_capacity(s.len());
511    for &b in s.as_bytes() {
512        if is_path_safe(b) {
513            out.push(b as char);
514        } else {
515            let _ = write!(out, "%{b:02X}");
516        }
517    }
518    out
519}
520
521fn is_userinfo_unreserved(b: u8) -> bool {
522    matches!(b,
523        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
524    )
525}
526
527fn is_path_safe(b: u8) -> bool {
528    matches!(b,
529        b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~'
530    )
531}
532
533#[cfg(test)]
534mod tests {
535    use super::*;
536    use std::sync::Mutex;
537
538    // Env vars are process-global, so these tests run one at a time.
539    fn env_lock() -> &'static Mutex<()> {
540        static M: std::sync::OnceLock<Mutex<()>> = std::sync::OnceLock::new();
541        M.get_or_init(|| Mutex::new(()))
542    }
543
544    fn with_env<F: FnOnce()>(name: &str, value: &str, f: F) {
545        let _g = env_lock().lock().unwrap();
546        std::env::set_var(name, value);
547        f();
548        std::env::remove_var(name);
549    }
550
551    fn without_env<F: FnOnce()>(name: &str, f: F) {
552        let _g = env_lock().lock().unwrap();
553        std::env::remove_var(name);
554        f();
555    }
556
557    #[test]
558    fn required_returns_parsed_value() {
559        with_env("RUSTANGO_TEST_PORT", "8080", || {
560            let v: u16 = required("RUSTANGO_TEST_PORT").unwrap();
561            assert_eq!(v, 8080);
562        });
563    }
564
565    #[test]
566    fn required_errors_when_missing() {
567        without_env("RUSTANGO_TEST_MISSING_PORT", || {
568            let r = required::<u16>("RUSTANGO_TEST_MISSING_PORT");
569            assert!(matches!(r, Err(EnvError::Missing(_))));
570        });
571    }
572
573    #[test]
574    fn required_errors_on_parse_failure() {
575        with_env("RUSTANGO_TEST_BAD_PORT", "not-a-number", || {
576            let r = required::<u16>("RUSTANGO_TEST_BAD_PORT");
577            assert!(matches!(r, Err(EnvError::Parse { .. })));
578        });
579    }
580
581    #[test]
582    fn with_default_returns_default_when_missing() {
583        without_env("RUSTANGO_TEST_DEFAULT_PORT", || {
584            let v = with_default("RUSTANGO_TEST_DEFAULT_PORT", 9090u16).unwrap();
585            assert_eq!(v, 9090);
586        });
587    }
588
589    #[test]
590    fn with_default_returns_set_value() {
591        with_env("RUSTANGO_TEST_SET_PORT", "1234", || {
592            let v = with_default("RUSTANGO_TEST_SET_PORT", 9090u16).unwrap();
593            assert_eq!(v, 1234);
594        });
595    }
596
597    #[test]
598    fn with_default_errors_on_bad_value() {
599        with_env("RUSTANGO_TEST_BAD_DEFAULT", "garbage", || {
600            let r = with_default("RUSTANGO_TEST_BAD_DEFAULT", 1u16);
601            assert!(matches!(r, Err(EnvError::Parse { .. })));
602        });
603    }
604
605    #[test]
606    fn optional_returns_none_when_missing() {
607        without_env("RUSTANGO_TEST_OPT_MISSING", || {
608            let v: Option<i32> = optional("RUSTANGO_TEST_OPT_MISSING").unwrap();
609            assert_eq!(v, None);
610        });
611    }
612
613    #[test]
614    fn optional_returns_some_when_set() {
615        with_env("RUSTANGO_TEST_OPT_SET", "42", || {
616            let v: Option<i32> = optional("RUSTANGO_TEST_OPT_SET").unwrap();
617            assert_eq!(v, Some(42));
618        });
619    }
620
621    #[test]
622    fn list_parses_comma_separated() {
623        with_env(
624            "RUSTANGO_TEST_HOSTS",
625            "a.example.com, b.example.com,c.example.com",
626            || {
627                let v: Vec<String> = list("RUSTANGO_TEST_HOSTS").unwrap().unwrap();
628                assert_eq!(v, vec!["a.example.com", "b.example.com", "c.example.com"]);
629            },
630        );
631    }
632
633    #[test]
634    fn list_drops_empty_entries() {
635        with_env("RUSTANGO_TEST_LIST_TRAILING", "a,b,,", || {
636            let v: Vec<String> = list("RUSTANGO_TEST_LIST_TRAILING").unwrap().unwrap();
637            assert_eq!(v, vec!["a", "b"]);
638        });
639    }
640
641    #[test]
642    fn list_returns_none_when_missing() {
643        without_env("RUSTANGO_TEST_LIST_MISSING", || {
644            let v: Option<Vec<String>> = list("RUSTANGO_TEST_LIST_MISSING").unwrap();
645            assert_eq!(v, None);
646        });
647    }
648
649    #[test]
650    fn list_parses_typed_values() {
651        with_env("RUSTANGO_TEST_PORTS", "8080, 8081, 8082", || {
652            let v: Vec<u16> = list("RUSTANGO_TEST_PORTS").unwrap().unwrap();
653            assert_eq!(v, vec![8080, 8081, 8082]);
654        });
655    }
656
657    #[test]
658    fn duration_secs_parses() {
659        with_env("RUSTANGO_TEST_TTL", "60", || {
660            let d = duration_secs("RUSTANGO_TEST_TTL").unwrap();
661            assert_eq!(d, Duration::from_secs(60));
662        });
663    }
664
665    // -------------------------------------------------------------- Validator tests
666
667    #[test]
668    fn validator_check_passes_when_all_required_set() {
669        // Set both vars inline: nesting `with_env` would deadlock.
670        let _g = env_lock().lock().unwrap();
671        std::env::set_var("RUSTANGO_TEST_VALID_DB", "x");
672        std::env::set_var("RUSTANGO_TEST_VALID_KEY", "y");
673        let v = Validator::new()
674            .require("RUSTANGO_TEST_VALID_DB", "db url")
675            .require("RUSTANGO_TEST_VALID_KEY", "key");
676        assert!(v.check().is_empty());
677        assert!(v.check_or_error().is_ok());
678        std::env::remove_var("RUSTANGO_TEST_VALID_DB");
679        std::env::remove_var("RUSTANGO_TEST_VALID_KEY");
680    }
681
682    #[test]
683    fn validator_check_lists_missing_required() {
684        without_env("RUSTANGO_TEST_MISSING_REQ", || {
685            let v = Validator::new().require("RUSTANGO_TEST_MISSING_REQ", "needed for X");
686            let missing = v.check();
687            assert_eq!(missing.len(), 1);
688            assert_eq!(missing[0].name, "RUSTANGO_TEST_MISSING_REQ");
689        });
690    }
691
692    #[test]
693    fn validator_optional_not_in_missing_list() {
694        without_env("RUSTANGO_TEST_OPT", || {
695            let v = Validator::new().optional("RUSTANGO_TEST_OPT", "fallback OK");
696            assert!(v.check().is_empty());
697        });
698    }
699
700    #[test]
701    fn validator_check_or_error_returns_formatted_message() {
702        without_env("RUSTANGO_TEST_FORMAT_REQ", || {
703            let v = Validator::new().require("RUSTANGO_TEST_FORMAT_REQ", "Postgres URL");
704            let err = v.check_or_error().unwrap_err();
705            assert!(err.contains("RUSTANGO_TEST_FORMAT_REQ"));
706            assert!(err.contains("Postgres URL"));
707            assert!(err.contains("export RUSTANGO_TEST_FORMAT_REQ=..."));
708        });
709    }
710
711    #[test]
712    fn validator_len_and_is_empty() {
713        let v = Validator::new();
714        assert!(v.is_empty());
715        assert_eq!(v.len(), 0);
716        let v = v.require("X", "x").optional("Y", "y");
717        assert_eq!(v.len(), 2);
718        assert!(!v.is_empty());
719    }
720
721    // -------------------------------------------------------------- DatabaseUrlBuilder tests
722
723    #[test]
724    fn builder_assembles_minimal_postgres_url() {
725        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
726            .user("app")
727            .database("appdb")
728            .build();
729        assert_eq!(url, "postgres://app@localhost:5432/appdb");
730    }
731
732    #[test]
733    fn builder_assembles_full_postgres_url() {
734        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
735            .host("db.internal")
736            .port(6543)
737            .user("svc")
738            .password("secret")
739            .database("app")
740            .params("sslmode=require")
741            .build();
742        assert_eq!(
743            url,
744            "postgres://svc:secret@db.internal:6543/app?sslmode=require"
745        );
746    }
747
748    #[test]
749    fn builder_percent_encodes_password_specials() {
750        // `@`, `:`, `/`, `#`, `?`, `%` must all be encoded inside the password.
751        let url = DatabaseUrlBuilder::new(DbDriver::Postgres)
752            .user("u")
753            .password("p@ss:/word#?%")
754            .database("db")
755            .build();
756        assert_eq!(
757            url,
758            "postgres://u:p%40ss%3A%2Fword%23%3F%25@localhost:5432/db"
759        );
760    }
761
762    #[test]
763    fn builder_uses_mysql_default_port() {
764        let url = DatabaseUrlBuilder::new(DbDriver::Mysql)
765            .user("u")
766            .database("d")
767            .build();
768        assert_eq!(url, "mysql://u@localhost:3306/d");
769    }
770
771    #[test]
772    fn db_driver_parses_aliases() {
773        assert_eq!(DbDriver::parse("postgres").unwrap(), DbDriver::Postgres);
774        assert_eq!(DbDriver::parse("PostgreSQL").unwrap(), DbDriver::Postgres);
775        assert_eq!(DbDriver::parse("pg").unwrap(), DbDriver::Postgres);
776        assert_eq!(DbDriver::parse("mysql").unwrap(), DbDriver::Mysql);
777        assert_eq!(DbDriver::parse("MariaDB").unwrap(), DbDriver::Mysql);
778        assert!(matches!(
779            DbDriver::parse("oracle"),
780            Err(EnvError::UnsupportedDriver(_))
781        ));
782    }
783
784    /// Drop guard that puts a set of env vars back as it found them.
785    /// The developer's shell may already export `DATABASE_URL`.
786    struct EnvSnapshot {
787        saved: Vec<(&'static str, Option<String>)>,
788    }
789    impl EnvSnapshot {
790        fn capture(names: &[&'static str]) -> Self {
791            let saved = names.iter().map(|n| (*n, std::env::var(n).ok())).collect();
792            for n in names {
793                std::env::remove_var(n);
794            }
795            Self { saved }
796        }
797    }
798    impl Drop for EnvSnapshot {
799        fn drop(&mut self) {
800            for (name, prev) in &self.saved {
801                match prev {
802                    Some(v) => std::env::set_var(name, v),
803                    None => std::env::remove_var(name),
804                }
805            }
806        }
807    }
808
809    const DB_VARS: &[&str] = &[
810        "DATABASE_URL",
811        "DB_DRIVER",
812        "DB_HOST",
813        "DB_PORT",
814        "DB_USER",
815        "DB_PASSWORD",
816        "DB_NAME",
817        "DB_PARAMS",
818    ];
819
820    #[test]
821    fn database_url_from_env_prefers_database_url() {
822        let _g = env_lock().lock().unwrap();
823        let _snap = EnvSnapshot::capture(DB_VARS);
824        std::env::set_var("DATABASE_URL", "postgres://x@y:1/z");
825        let url = database_url_from_env().unwrap();
826        assert_eq!(url, "postgres://x@y:1/z");
827    }
828
829    #[test]
830    fn database_url_normalizes_postgresql_scheme() {
831        let _g = env_lock().lock().unwrap();
832        let _snap = EnvSnapshot::capture(DB_VARS);
833        std::env::set_var("DATABASE_URL", "postgresql://x@y:1/z");
834        let url = database_url_from_env().unwrap();
835        assert_eq!(url, "postgres://x@y:1/z");
836    }
837
838    #[test]
839    fn database_url_assembles_from_split_vars() {
840        let _g = env_lock().lock().unwrap();
841        let _snap = EnvSnapshot::capture(DB_VARS);
842        std::env::set_var("DB_HOST", "db.example.com");
843        std::env::set_var("DB_PORT", "6543");
844        std::env::set_var("DB_USER", "app");
845        std::env::set_var("DB_PASSWORD", "p@ss");
846        std::env::set_var("DB_NAME", "appdb");
847        let url = database_url_from_env().unwrap();
848        assert_eq!(url, "postgres://app:p%40ss@db.example.com:6543/appdb");
849    }
850
851    #[test]
852    fn database_url_assembles_mysql_when_driver_set() {
853        let _g = env_lock().lock().unwrap();
854        let _snap = EnvSnapshot::capture(DB_VARS);
855        std::env::set_var("DB_DRIVER", "mysql");
856        std::env::set_var("DB_USER", "u");
857        std::env::set_var("DB_NAME", "d");
858        let url = database_url_from_env().unwrap();
859        assert_eq!(url, "mysql://u@localhost:3306/d");
860    }
861
862    #[test]
863    fn database_url_errors_when_required_missing() {
864        let _g = env_lock().lock().unwrap();
865        let _snap = EnvSnapshot::capture(DB_VARS);
866        std::env::set_var("DB_NAME", "x");
867        // DB_USER missing
868        let err = database_url_from_env().unwrap_err();
869        match err {
870            EnvError::MissingRequired { name, .. } => assert_eq!(name, "DB_USER"),
871            other => panic!("wrong variant: {other:?}"),
872        }
873    }
874
875    #[test]
876    fn database_url_errors_on_bad_driver() {
877        let _g = env_lock().lock().unwrap();
878        let _snap = EnvSnapshot::capture(DB_VARS);
879        std::env::set_var("DB_DRIVER", "oracle");
880        std::env::set_var("DB_USER", "u");
881        std::env::set_var("DB_NAME", "d");
882        let err = database_url_from_env().unwrap_err();
883        assert!(matches!(err, EnvError::UnsupportedDriver(_)));
884    }
885}