1use 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 #[error("environment variable `{name}` is required: {hint}")]
50 MissingRequired { name: String, hint: String },
51 #[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
65pub 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
83pub 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
104pub 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
123pub 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
149pub fn duration_secs(name: &str) -> Result<Duration, EnvError> {
155 let secs: u64 = required(name)?;
156 Ok(Duration::from_secs(secs))
157}
158
159pub fn duration_millis(name: &str) -> Result<Duration, EnvError> {
164 let ms: u64 = required(name)?;
165 Ok(Duration::from_millis(ms))
166}
167
168#[derive(Debug, Clone)]
172pub struct EnvRequirement {
173 pub name: String,
174 pub description: String,
175 pub required: bool,
176}
177
178#[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 #[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 #[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 #[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 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 pub fn check_or_panic(&self) {
266 if let Err(msg) = self.check_or_error() {
267 panic!("{msg}");
268 }
269 }
270
271 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
288pub enum DbDriver {
289 Postgres,
290 Mysql,
291}
292
293impl DbDriver {
294 #[must_use]
297 pub fn scheme(self) -> &'static str {
298 match self {
299 DbDriver::Postgres => "postgres",
300 DbDriver::Mysql => "mysql",
301 }
302 }
303
304 #[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#[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 #[must_use]
395 pub fn params(mut self, params: impl Into<String>) -> Self {
396 self.params = Some(params.into());
397 self
398 }
399
400 #[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
433pub fn database_url_from_env() -> Result<String, EnvError> {
454 if let Some(url) = lookup("DATABASE_URL") {
455 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
489fn 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 let _ = write!(out, "%{b:02X}");
502 }
503 }
504 out
505}
506
507fn 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 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 #[test]
668 fn validator_check_passes_when_all_required_set() {
669 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 #[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 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 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 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}