1use std::env;
32use std::fmt;
33use std::path::PathBuf;
34
35use tracing::warn;
36
37#[derive(Debug, Clone)]
53pub struct ServerConfig {
54 pub database_url: Option<String>,
56 pub jwt_secret: String,
58 pub worker_token: String,
60 pub port: u16,
62 pub allowed_origins: Option<String>,
64 pub dashboard_dir: Option<PathBuf>,
66 pub webhook_url: Option<String>,
68 pub is_production: bool,
70}
71
72#[derive(Debug, Clone)]
86pub struct ConfigError {
87 pub errors: Vec<String>,
89}
90
91impl ConfigError {
92 pub fn new(errors: Vec<String>) -> Self {
94 Self { errors }
95 }
96}
97
98impl fmt::Display for ConfigError {
99 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
100 writeln!(f, "configuration errors:")?;
101 for error in &self.errors {
102 writeln!(f, " - {error}")?;
103 }
104 Ok(())
105 }
106}
107
108impl std::error::Error for ConfigError {}
109
110const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
111const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
112
113impl ServerConfig {
114 pub fn from_env() -> Result<Self, ConfigError> {
138 let is_production = env::var("IRONFLOW_ENV")
139 .map(|v| v.eq_ignore_ascii_case("production"))
140 .unwrap_or(false);
141
142 let mut errors = Vec::new();
143
144 let database_url = env::var("DATABASE_URL").ok();
145 if is_production && database_url.is_none() {
146 errors.push("DATABASE_URL is required in production".to_string());
147 }
148
149 let jwt_secret_env = env::var("JWT_SECRET").ok();
150 let jwt_secret = match jwt_secret_env {
151 Some(val) => val,
152 None if is_production => {
153 errors.push("JWT_SECRET is required in production".to_string());
154 String::new()
155 }
156 None => {
157 warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
158 DEV_JWT_SECRET.to_string()
159 }
160 };
161
162 let worker_token_env = env::var("WORKER_TOKEN").ok();
163 let worker_token = match worker_token_env {
164 Some(val) => val,
165 None if is_production => {
166 errors.push("WORKER_TOKEN is required in production".to_string());
167 String::new()
168 }
169 None => {
170 warn!(
171 "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
172 );
173 DEV_WORKER_TOKEN.to_string()
174 }
175 };
176
177 let port = match env::var("PORT").ok() {
178 Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
179 errors.push(format!("PORT must be a valid u16, got: {raw}"));
180 0
181 }),
182 None => 3000,
183 };
184
185 let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
186 let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
187 let webhook_url = env::var("WEBHOOK_URL").ok();
188
189 if !errors.is_empty() {
190 return Err(ConfigError::new(errors));
191 }
192
193 Ok(Self {
194 database_url,
195 jwt_secret,
196 worker_token,
197 port,
198 allowed_origins,
199 dashboard_dir,
200 webhook_url,
201 is_production,
202 })
203 }
204}
205
206#[cfg(test)]
207mod tests {
208 use std::sync::Mutex;
209
210 use super::*;
211
212 static ENV_LOCK: Mutex<()> = Mutex::new(());
214
215 unsafe fn clear_env() {
219 unsafe {
220 env::remove_var("IRONFLOW_ENV");
221 env::remove_var("DATABASE_URL");
222 env::remove_var("JWT_SECRET");
223 env::remove_var("WORKER_TOKEN");
224 env::remove_var("PORT");
225 env::remove_var("ALLOWED_ORIGINS");
226 env::remove_var("DASHBOARD_DIR");
227 env::remove_var("WEBHOOK_URL");
228 }
229 }
230
231 #[test]
232 fn config_error_display_lists_all_errors() {
233 let err = ConfigError::new(vec![
234 "JWT_SECRET is required".to_string(),
235 "DATABASE_URL is required".to_string(),
236 ]);
237 let msg = err.to_string();
238 assert!(msg.contains("JWT_SECRET"));
239 assert!(msg.contains("DATABASE_URL"));
240 assert!(msg.contains("configuration errors:"));
241 }
242
243 #[test]
244 fn config_error_is_std_error() {
245 let err = ConfigError::new(vec!["test".to_string()]);
246 let _: &dyn std::error::Error = &err;
247 }
248
249 #[test]
250 fn default_dev_config_succeeds() {
251 let _guard = ENV_LOCK.lock().unwrap();
252 unsafe { clear_env() };
253
254 let config = ServerConfig::from_env().expect("dev config should succeed");
255 assert!(!config.is_production);
256 assert_eq!(config.port, 3000);
257 assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
258 assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
259 }
260
261 #[test]
262 fn production_without_secrets_fails() {
263 let _guard = ENV_LOCK.lock().unwrap();
264 unsafe {
265 clear_env();
266 env::set_var("IRONFLOW_ENV", "production");
267 }
268
269 let result = ServerConfig::from_env();
270 assert!(result.is_err());
271 let err = result.unwrap_err();
272 assert!(err.errors.len() >= 3);
273 assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
274 assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
275 assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
276
277 unsafe { env::remove_var("IRONFLOW_ENV") };
278 }
279
280 #[test]
281 fn invalid_port_returns_error() {
282 let _guard = ENV_LOCK.lock().unwrap();
283 unsafe {
284 clear_env();
285 env::set_var("PORT", "not-a-number");
286 }
287
288 let result = ServerConfig::from_env();
289 assert!(result.is_err());
290 let err = result.unwrap_err();
291 assert!(err.errors.iter().any(|e| e.contains("PORT")));
292
293 unsafe { env::remove_var("PORT") };
294 }
295}