use std::num::ParseIntError;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("required environment variable not set: {0}")]
Missing(&'static str),
#[error("invalid value for {var}: {reason}")]
Parse {
var: &'static str,
reason: String,
},
}
impl ConfigError {
fn parse_int(var: &'static str, e: ParseIntError) -> Self {
Self::Parse {
var,
reason: e.to_string(),
}
}
}
#[derive(Debug, Clone)]
pub struct Config {
pub port: u16,
pub bind_addr: Option<std::net::IpAddr>,
pub database_url: Option<String>,
pub redis_url: Option<String>,
pub sentry_dsn: Option<String>,
pub master_key: Option<String>,
}
impl Config {
pub fn from_env() -> Result<Self, ConfigError> {
let port = match std::env::var("PORT") {
Ok(v) => v
.parse::<u16>()
.map_err(|e| ConfigError::parse_int("PORT", e))?,
Err(_) => 8080,
};
let bind_addr = match opt("TT_BIND_ADDR") {
Some(v) => Some(
v.parse::<std::net::IpAddr>()
.map_err(|e| ConfigError::Parse {
var: "TT_BIND_ADDR",
reason: e.to_string(),
})?,
),
None => None,
};
Ok(Self {
port,
bind_addr,
database_url: opt("DATABASE_URL"),
redis_url: opt("REDIS_URL"),
sentry_dsn: opt("SENTRY_DSN"),
master_key: opt("TT_MASTER_KEY"),
})
}
}
fn opt(var: &str) -> Option<String> {
std::env::var(var).ok().filter(|s| !s.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
#[test]
fn defaults_when_only_required_missing() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
let prev_port = std::env::var("PORT").ok();
std::env::remove_var("PORT");
let cfg = Config::from_env().expect("defaults must load");
assert_eq!(cfg.port, 8080);
if let Some(v) = prev_port {
std::env::set_var("PORT", v);
}
}
#[test]
fn invalid_port_returns_parse_error() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("PORT", "not-a-number");
let err = Config::from_env().expect_err("invalid port must fail");
match err {
ConfigError::Parse { var, .. } => assert_eq!(var, "PORT"),
other => panic!("expected Parse, got {other:?}"),
}
std::env::remove_var("PORT");
}
#[test]
fn bind_addr_unset_is_none() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::remove_var("TT_BIND_ADDR");
let cfg = Config::from_env().expect("defaults must load");
assert!(cfg.bind_addr.is_none());
}
#[test]
fn bind_addr_parses_v4_and_v6() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("TT_BIND_ADDR", "127.0.0.1");
let cfg = Config::from_env().expect("valid v4 must load");
assert_eq!(
cfg.bind_addr,
Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
);
std::env::set_var("TT_BIND_ADDR", "::1");
let cfg = Config::from_env().expect("valid v6 must load");
assert_eq!(
cfg.bind_addr,
Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))
);
std::env::remove_var("TT_BIND_ADDR");
}
#[test]
fn invalid_bind_addr_returns_parse_error() {
let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
std::env::set_var("TT_BIND_ADDR", "not-an-ip");
let err = Config::from_env().expect_err("invalid bind addr must fail");
match err {
ConfigError::Parse { var, .. } => assert_eq!(var, "TT_BIND_ADDR"),
other => panic!("expected Parse, got {other:?}"),
}
std::env::remove_var("TT_BIND_ADDR");
}
}