1use 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 #[error("environment variable `{name}` is required: {hint}")]
51 MissingRequired { name: String, hint: String },
52 #[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
66pub 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
84pub 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
105pub 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
124pub 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
150pub fn duration_secs(name: &str) -> Result<Duration, EnvError> {
156 let secs: u64 = required(name)?;
157 Ok(Duration::from_secs(secs))
158}
159
160pub fn duration_millis(name: &str) -> Result<Duration, EnvError> {
165 let ms: u64 = required(name)?;
166 Ok(Duration::from_millis(ms))
167}
168
169pub(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
179pub(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
189pub(crate) fn tier_from_env() -> String {
191 canonical_tier(&std::env::var("RUSTANGO_ENV").unwrap_or_default())
192}
193
194pub(crate) fn is_prod_tier(raw: &str) -> bool {
196 canonical_tier(raw) == "prod"
197}
198
199#[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#[derive(Debug, Clone)]
213pub struct EnvRequirement {
214 pub name: String,
215 pub description: String,
216 pub required: bool,
217}
218
219#[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 #[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 #[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 #[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 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 pub fn check_or_panic(&self) {
307 if let Err(msg) = self.check_or_error() {
308 panic!("{msg}");
309 }
310 }
311
312 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum DbDriver {
330 Postgres,
331 Mysql,
332}
333
334impl DbDriver {
335 #[must_use]
338 pub fn scheme(self) -> &'static str {
339 match self {
340 DbDriver::Postgres => "postgres",
341 DbDriver::Mysql => "mysql",
342 }
343 }
344
345 #[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#[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 #[must_use]
438 pub fn params(mut self, params: impl Into<String>) -> Self {
439 self.params = Some(params.into());
440 self
441 }
442
443 #[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 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
484const 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
496const 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
502const 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
507const DATABASE_URL_OVERRIDE: &str = "RUSTANGO__DATABASE__URL";
509
510#[derive(Debug, Clone, PartialEq, Eq)]
512pub(crate) enum SettingSource {
513 Env(&'static str),
515 Override(String),
517 File(String),
519 #[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
536static SETTINGS_DATABASE_URL: std::sync::OnceLock<String> = std::sync::OnceLock::new();
538
539#[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#[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
553pub 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
593pub(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#[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
605pub(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
632fn 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
642fn 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 if tracing::dispatcher::has_been_set() {
650 tracing::warn!(target: "rustango::sql", "{msg}");
651 } else {
652 eprintln!("warning: {msg}");
653 }
654}
655
656fn 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
667pub(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 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 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
710fn 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 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
752fn 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
774fn 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
784fn 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 let _ = write!(out, "%{b:02X}");
797 }
798 }
799 out
800}
801
802fn 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 #[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 #[test]
967 fn validator_check_passes_when_all_required_set() {
968 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 #[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 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 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 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 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 #[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 #[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 let blank = resolve_database_url_from(&vars(&[("RUSTANGO__DATABASE__URL", " ")]), Some(""));
1360 assert!(matches!(blank, Err(EnvError::MissingRequired { .. })));
1361 }
1362
1363 #[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 #[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(¬ed, &src));
1394 assert!(!note_database_url_source(
1395 ¬ed,
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}