1use std::num::ParseIntError;
20
21use thiserror::Error;
22
23#[derive(Debug, Error)]
25pub enum ConfigError {
26 #[error("required environment variable not set: {0}")]
28 Missing(&'static str),
29 #[error("invalid value for {var}: {reason}")]
31 Parse {
32 var: &'static str,
34 reason: String,
36 },
37}
38
39impl ConfigError {
40 fn parse_int(var: &'static str, e: ParseIntError) -> Self {
41 Self::Parse {
42 var,
43 reason: e.to_string(),
44 }
45 }
46}
47
48#[derive(Debug, Clone)]
54pub struct Config {
55 pub port: u16,
57 pub bind_addr: Option<std::net::IpAddr>,
61 pub database_url: Option<String>,
63 pub redis_url: Option<String>,
65 pub sentry_dsn: Option<String>,
67 pub master_key: Option<String>,
71}
72
73impl Config {
74 pub fn from_env() -> Result<Self, ConfigError> {
80 let port = match std::env::var("PORT") {
81 Ok(v) => v
82 .parse::<u16>()
83 .map_err(|e| ConfigError::parse_int("PORT", e))?,
84 Err(_) => 8080,
85 };
86
87 let bind_addr = match opt("TT_BIND_ADDR") {
88 Some(v) => Some(
89 v.parse::<std::net::IpAddr>()
90 .map_err(|e| ConfigError::Parse {
91 var: "TT_BIND_ADDR",
92 reason: e.to_string(),
93 })?,
94 ),
95 None => None,
96 };
97
98 Ok(Self {
99 port,
100 bind_addr,
101 database_url: opt("DATABASE_URL"),
102 redis_url: opt("REDIS_URL"),
103 sentry_dsn: opt("SENTRY_DSN"),
104 master_key: opt("TT_MASTER_KEY"),
105 })
106 }
107}
108
109fn opt(var: &str) -> Option<String> {
110 std::env::var(var).ok().filter(|s| !s.is_empty())
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116 use std::sync::Mutex;
117
118 static ENV_LOCK: Mutex<()> = Mutex::new(());
123
124 #[test]
127 fn defaults_when_only_required_missing() {
128 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
129 let prev_port = std::env::var("PORT").ok();
132 std::env::remove_var("PORT");
133
134 let cfg = Config::from_env().expect("defaults must load");
135 assert_eq!(cfg.port, 8080);
136
137 if let Some(v) = prev_port {
138 std::env::set_var("PORT", v);
139 }
140 }
141
142 #[test]
143 fn invalid_port_returns_parse_error() {
144 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
145 std::env::set_var("PORT", "not-a-number");
146 let err = Config::from_env().expect_err("invalid port must fail");
147 match err {
148 ConfigError::Parse { var, .. } => assert_eq!(var, "PORT"),
149 other => panic!("expected Parse, got {other:?}"),
150 }
151 std::env::remove_var("PORT");
152 }
153
154 #[test]
155 fn bind_addr_unset_is_none() {
156 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
157 std::env::remove_var("TT_BIND_ADDR");
158 let cfg = Config::from_env().expect("defaults must load");
159 assert!(cfg.bind_addr.is_none());
160 }
161
162 #[test]
163 fn bind_addr_parses_v4_and_v6() {
164 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
165 std::env::set_var("TT_BIND_ADDR", "127.0.0.1");
166 let cfg = Config::from_env().expect("valid v4 must load");
167 assert_eq!(
168 cfg.bind_addr,
169 Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
170 );
171 std::env::set_var("TT_BIND_ADDR", "::1");
172 let cfg = Config::from_env().expect("valid v6 must load");
173 assert_eq!(
174 cfg.bind_addr,
175 Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))
176 );
177 std::env::remove_var("TT_BIND_ADDR");
178 }
179
180 #[test]
181 fn invalid_bind_addr_returns_parse_error() {
182 let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
183 std::env::set_var("TT_BIND_ADDR", "not-an-ip");
184 let err = Config::from_env().expect_err("invalid bind addr must fail");
185 match err {
186 ConfigError::Parse { var, .. } => assert_eq!(var, "TT_BIND_ADDR"),
187 other => panic!("expected Parse, got {other:?}"),
188 }
189 std::env::remove_var("TT_BIND_ADDR");
190 }
191}