use std::env;
use std::fmt;
use std::path::PathBuf;
use tracing::warn;
#[derive(Debug, Clone)]
pub struct ServerConfig {
pub database_url: Option<String>,
pub jwt_secret: String,
pub worker_token: String,
pub port: u16,
pub allowed_origins: Option<String>,
pub dashboard_dir: Option<PathBuf>,
pub webhook_url: Option<String>,
pub is_production: bool,
pub rate_limit_auth: Option<u32>,
pub rate_limit_general: Option<u32>,
}
#[derive(Debug, Clone)]
pub struct ConfigError {
pub errors: Vec<String>,
}
impl ConfigError {
pub fn new(errors: Vec<String>) -> Self {
Self { errors }
}
}
impl fmt::Display for ConfigError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
writeln!(f, "configuration errors:")?;
for error in &self.errors {
writeln!(f, " - {error}")?;
}
Ok(())
}
}
impl std::error::Error for ConfigError {}
const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
match env::var(name).ok() {
Some(raw) => match raw.parse::<u32>() {
Ok(0) => None,
Ok(v) => Some(v),
Err(_) => {
errors.push(format!(
"{name} must be a valid u32 (0 to disable), got: {raw}"
));
Some(default)
}
},
None => Some(default),
}
}
impl ServerConfig {
pub fn from_env() -> Result<Self, ConfigError> {
let is_production = env::var("IRONFLOW_ENV")
.map(|v| v.eq_ignore_ascii_case("production"))
.unwrap_or(false);
let mut errors = Vec::new();
let database_url = env::var("DATABASE_URL").ok();
if is_production && database_url.is_none() {
errors.push("DATABASE_URL is required in production".to_string());
}
let jwt_secret_env = env::var("JWT_SECRET").ok();
let jwt_secret = match jwt_secret_env {
Some(val) => val,
None if is_production => {
errors.push("JWT_SECRET is required in production".to_string());
String::new()
}
None => {
warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
DEV_JWT_SECRET.to_string()
}
};
let worker_token_env = env::var("WORKER_TOKEN").ok();
let worker_token = match worker_token_env {
Some(val) => val,
None if is_production => {
errors.push("WORKER_TOKEN is required in production".to_string());
String::new()
}
None => {
warn!(
"WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
);
DEV_WORKER_TOKEN.to_string()
}
};
let port = match env::var("PORT").ok() {
Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
errors.push(format!("PORT must be a valid u16, got: {raw}"));
0
}),
None => 3000,
};
let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
let webhook_url = env::var("WEBHOOK_URL").ok();
let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
if !errors.is_empty() {
return Err(ConfigError::new(errors));
}
Ok(Self {
database_url,
jwt_secret,
worker_token,
port,
allowed_origins,
dashboard_dir,
webhook_url,
is_production,
rate_limit_auth,
rate_limit_general,
})
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use super::*;
static ENV_LOCK: Mutex<()> = Mutex::new(());
unsafe fn clear_env() {
unsafe {
env::remove_var("IRONFLOW_ENV");
env::remove_var("DATABASE_URL");
env::remove_var("JWT_SECRET");
env::remove_var("WORKER_TOKEN");
env::remove_var("PORT");
env::remove_var("ALLOWED_ORIGINS");
env::remove_var("DASHBOARD_DIR");
env::remove_var("WEBHOOK_URL");
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
}
}
#[test]
fn config_error_display_lists_all_errors() {
let err = ConfigError::new(vec![
"JWT_SECRET is required".to_string(),
"DATABASE_URL is required".to_string(),
]);
let msg = err.to_string();
assert!(msg.contains("JWT_SECRET"));
assert!(msg.contains("DATABASE_URL"));
assert!(msg.contains("configuration errors:"));
}
#[test]
fn config_error_is_std_error() {
let err = ConfigError::new(vec!["test".to_string()]);
let _: &dyn std::error::Error = &err;
}
#[test]
fn default_dev_config_succeeds() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { clear_env() };
let config = ServerConfig::from_env().expect("dev config should succeed");
assert!(!config.is_production);
assert_eq!(config.port, 3000);
assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
}
#[test]
fn production_without_secrets_fails() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("IRONFLOW_ENV", "production");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.len() >= 3);
assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
unsafe { env::remove_var("IRONFLOW_ENV") };
}
#[test]
fn invalid_port_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("PORT", "not-a-number");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("PORT")));
unsafe { env::remove_var("PORT") };
}
#[test]
fn default_rate_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe { clear_env() };
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.rate_limit_auth, Some(10));
assert_eq!(config.rate_limit_general, Some(60));
}
#[test]
fn custom_rate_limits() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("RATE_LIMIT_AUTH", "20");
env::set_var("RATE_LIMIT_GENERAL", "120");
}
let config = ServerConfig::from_env().unwrap();
assert_eq!(config.rate_limit_auth, Some(20));
assert_eq!(config.rate_limit_general, Some(120));
unsafe {
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
}
}
#[test]
fn zero_rate_limit_disables() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("RATE_LIMIT_AUTH", "0");
env::set_var("RATE_LIMIT_GENERAL", "0");
}
let config = ServerConfig::from_env().unwrap();
assert!(config.rate_limit_auth.is_none());
assert!(config.rate_limit_general.is_none());
unsafe {
env::remove_var("RATE_LIMIT_AUTH");
env::remove_var("RATE_LIMIT_GENERAL");
}
}
#[test]
fn invalid_rate_limit_returns_error() {
let _guard = ENV_LOCK.lock().unwrap();
unsafe {
clear_env();
env::set_var("RATE_LIMIT_AUTH", "not-a-number");
}
let result = ServerConfig::from_env();
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
unsafe { env::remove_var("RATE_LIMIT_AUTH") };
}
}