Skip to main content

ironflow_api/
config.rs

1//! Server configuration with startup validation.
2//!
3//! Loads configuration from environment variables and validates that all
4//! required values are present **at startup**, not at first use.
5//!
6//! # Environment Variables
7//!
8//! | Variable | Required | Default | Description |
9//! |----------|----------|---------|-------------|
10//! | `DATABASE_URL` | **prod** | - | PostgreSQL connection string |
11//! | `JWT_SECRET` | **prod** | dev default | JWT signing secret |
12//! | `WORKER_TOKEN` | **prod** | dev default | Worker-to-API auth token |
13//! | `PORT` | no | `3000` | HTTP listen port |
14//! | `ALLOWED_ORIGINS` | no | same-origin | Comma-separated CORS origins |
15//! | `DASHBOARD_DIR` | no | embedded | Filesystem path to dashboard assets |
16//! | `WEBHOOK_URL` | no | - | Outbound webhook URL for notifications |
17//! | `IRONFLOW_ENV` | no | `development` | `production` or `development` |
18//! | `RATE_LIMIT_AUTH` | no | `10` | Auth rate limit (req/min/IP). `0` = disabled |
19//! | `RATE_LIMIT_GENERAL` | no | `60` | General rate limit (req/min/IP). `0` = disabled |
20//! | `ARTIFACTS_DIR` | no | - | Filesystem root for artifact blobs. Unset disables artifacts |
21//! | `ARTIFACT_MAX_BYTES` | no | `104857600` | Maximum size of a single artifact |
22//! | `PURGE_MAX_AGE_DAYS` | no | `90` | Runs older than this are purged |
23//! | `PURGE_MAX_RUNS_PER_WORKFLOW` | no | `1000` | Max terminal runs kept per workflow |
24//! | `PURGE_DRY_RUN` | no | `false` | Log what would be purged without deleting |
25//! | `PURGE_INTERVAL_SECS` | no | `86400` | Seconds between purge ticks (min 60) |
26//! | `ARTIFACT_BACKEND` | no | `local` | Blob storage backend: `local` or `s3` |
27//! | `ARTIFACT_S3_BUCKET` | **if s3** | - | S3 bucket name |
28//! | `ARTIFACT_S3_REGION` | no | `eu-west-1` | S3 region |
29//! | `ARTIFACT_S3_ENDPOINT` | no | - | Custom S3 endpoint (MinIO, R2) |
30//! | `ARTIFACT_S3_PREFIX` | no | - | Key prefix within the bucket |
31//! | `ARTIFACT_GC_INTERVAL_SECS` | no | `86400` | Seconds between GC ticks (min 60) |
32//! | `ARTIFACT_GC_GRACE_DAYS` | no | `7` | Days before an orphan blob is deleted |
33//! | `ARTIFACT_GC_DRY_RUN` | no | `false` | Log what would be GC'd without deleting |
34//!
35//! # Examples
36//!
37//! ```no_run
38//! use ironflow_api::config::ServerConfig;
39//!
40//! # fn example() -> Result<(), ironflow_api::config::ConfigError> {
41//! let config = ServerConfig::from_env()?;
42//! println!("Listening on port {}", config.port);
43//! # Ok(())
44//! # }
45//! ```
46
47use std::env;
48use std::fmt;
49use std::path::PathBuf;
50
51use ironflow_artifacts::local::DEFAULT_MAX_ARTIFACT_BYTES;
52use tracing::warn;
53
54/// Server configuration loaded from environment variables.
55///
56/// Use [`ServerConfig::from_env`] to load and validate at startup.
57///
58/// # Examples
59///
60/// ```no_run
61/// use ironflow_api::config::ServerConfig;
62///
63/// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
64/// let config = ServerConfig::from_env()?;
65/// assert!(config.port > 0);
66/// # Ok(())
67/// # }
68/// ```
69#[derive(Debug, Clone)]
70pub struct ServerConfig {
71    /// PostgreSQL connection string. Required in production.
72    pub database_url: Option<String>,
73    /// JWT signing secret.
74    pub jwt_secret: String,
75    /// Worker-to-API authentication token.
76    pub worker_token: String,
77    /// HTTP listen port.
78    pub port: u16,
79    /// Comma-separated list of allowed CORS origins.
80    pub allowed_origins: Option<String>,
81    /// Filesystem path to dashboard assets (overrides embedded).
82    pub dashboard_dir: Option<PathBuf>,
83    /// Outbound webhook URL for event notifications.
84    pub webhook_url: Option<String>,
85    /// Filesystem root for artifact blobs.
86    ///
87    /// `None` leaves artifacts disabled: the artifact routes answer `501` and
88    /// a step that declares one fails explicitly. Every other endpoint is
89    /// unaffected, so an existing deployment upgrades without changes.
90    ///
91    /// Read from `ARTIFACTS_DIR`.
92    pub artifacts_dir: Option<PathBuf>,
93    /// Maximum size of a single artifact, in bytes.
94    ///
95    /// Read from `ARTIFACT_MAX_BYTES`, defaulting to 100 MiB.
96    pub artifact_max_bytes: u64,
97    /// Maximum age of a run before it becomes eligible for purging, in days.
98    ///
99    /// Read from `PURGE_MAX_AGE_DAYS`, defaulting to 90.
100    pub purge_max_age_days: u32,
101    /// Maximum number of terminal runs to keep per workflow.
102    ///
103    /// Read from `PURGE_MAX_RUNS_PER_WORKFLOW`, defaulting to 1000.
104    pub purge_max_runs_per_workflow: u32,
105    /// When `true`, the purger logs what would be deleted but does not delete.
106    ///
107    /// Read from `PURGE_DRY_RUN`, defaulting to `false`.
108    pub purge_dry_run: bool,
109    /// Interval between purge ticks, in seconds.
110    ///
111    /// Read from `PURGE_INTERVAL_SECS`, defaulting to 86400 (once per day).
112    pub purge_interval_secs: u64,
113    /// Whether the server is running in production mode.
114    pub is_production: bool,
115    /// Rate limit for auth credential routes (sign-in, sign-up) in requests
116    /// per minute per IP. `None` disables rate limiting on these routes.
117    pub rate_limit_auth: Option<u32>,
118    /// Rate limit for general public API routes in requests per minute per IP.
119    /// `None` disables rate limiting on these routes.
120    pub rate_limit_general: Option<u32>,
121    /// Blob storage backend: `local` or `s3`.
122    pub artifact_backend: String,
123    /// S3 bucket name (required when `artifact_backend` is `s3`).
124    pub artifact_s3_bucket: Option<String>,
125    /// S3 region, defaults to `eu-west-1`.
126    pub artifact_s3_region: String,
127    /// Custom S3 endpoint for MinIO, R2, or GCS S3-compat.
128    pub artifact_s3_endpoint: Option<String>,
129    /// Key prefix within the S3 bucket.
130    pub artifact_s3_prefix: Option<String>,
131    /// Seconds between GC ticks, defaults to 86400 (once per day).
132    pub artifact_gc_interval_secs: u64,
133    /// Days before an orphan blob is eligible for deletion, defaults to 7.
134    pub artifact_gc_grace_days: u32,
135    /// When `true`, the GC logs what would be deleted without deleting.
136    pub artifact_gc_dry_run: bool,
137}
138
139/// Configuration validation error.
140///
141/// Collects all missing/invalid values so the operator sees every problem
142/// in a single error message, not one at a time.
143///
144/// # Examples
145///
146/// ```
147/// use ironflow_api::config::ConfigError;
148///
149/// let err = ConfigError::new(vec!["JWT_SECRET is required in production".to_string()]);
150/// assert!(err.to_string().contains("JWT_SECRET"));
151/// ```
152#[derive(Debug, Clone)]
153pub struct ConfigError {
154    /// Individual validation failure messages.
155    pub errors: Vec<String>,
156}
157
158impl ConfigError {
159    /// Create a new `ConfigError` from a list of validation messages.
160    pub fn new(errors: Vec<String>) -> Self {
161        Self { errors }
162    }
163}
164
165impl fmt::Display for ConfigError {
166    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167        writeln!(f, "configuration errors:")?;
168        for error in &self.errors {
169            writeln!(f, "  - {error}")?;
170        }
171        Ok(())
172    }
173}
174
175impl std::error::Error for ConfigError {}
176
177const DEV_JWT_SECRET: &str = "ironflow-dev-secret";
178const DEV_WORKER_TOKEN: &str = "ironflow-dev-worker-token";
179
180/// Parse an optional u32 env var. Returns `Some(default)` if unset,
181/// `Some(value)` if set to a positive number, `None` if set to `0`
182/// (meaning disabled). Pushes to `errors` if the value is not a valid u32.
183fn parse_optional_u32(name: &str, default: u32, errors: &mut Vec<String>) -> Option<u32> {
184    match env::var(name).ok() {
185        Some(raw) => match raw.parse::<u32>() {
186            Ok(0) => None,
187            Ok(v) => Some(v),
188            Err(_) => {
189                errors.push(format!(
190                    "{name} must be a valid u32 (0 to disable), got: {raw}"
191                ));
192                Some(default)
193            }
194        },
195        None => Some(default),
196    }
197}
198
199impl ServerConfig {
200    /// Load configuration from environment variables and validate.
201    ///
202    /// In production mode (`IRONFLOW_ENV=production`), `JWT_SECRET` and
203    /// `WORKER_TOKEN` must be explicitly set (dev defaults are rejected).
204    /// `DATABASE_URL` is required in production.
205    ///
206    /// In development mode, insecure defaults are used with a warning.
207    ///
208    /// # Errors
209    ///
210    /// Returns [`ConfigError`] with all validation failures collected,
211    /// so the operator can fix everything in one pass.
212    ///
213    /// # Examples
214    ///
215    /// ```no_run
216    /// use ironflow_api::config::ServerConfig;
217    ///
218    /// # fn example() -> Result<(), ironflow_api::config::ConfigError> {
219    /// let config = ServerConfig::from_env()?;
220    /// # Ok(())
221    /// # }
222    /// ```
223    pub fn from_env() -> Result<Self, ConfigError> {
224        let is_production = env::var("IRONFLOW_ENV")
225            .map(|v| v.eq_ignore_ascii_case("production"))
226            .unwrap_or(false);
227
228        let mut errors = Vec::new();
229
230        let database_url = env::var("DATABASE_URL").ok();
231        if is_production && database_url.is_none() {
232            errors.push("DATABASE_URL is required in production".to_string());
233        }
234
235        let jwt_secret_env = env::var("JWT_SECRET").ok();
236        let jwt_secret = match jwt_secret_env {
237            Some(val) => val,
238            None if is_production => {
239                errors.push("JWT_SECRET is required in production".to_string());
240                String::new()
241            }
242            None => {
243                warn!("JWT_SECRET not set, using insecure dev default -- do NOT use in production");
244                DEV_JWT_SECRET.to_string()
245            }
246        };
247
248        let worker_token_env = env::var("WORKER_TOKEN").ok();
249        let worker_token = match worker_token_env {
250            Some(val) => val,
251            None if is_production => {
252                errors.push("WORKER_TOKEN is required in production".to_string());
253                String::new()
254            }
255            None => {
256                warn!(
257                    "WORKER_TOKEN not set, using insecure dev default -- do NOT use in production"
258                );
259                DEV_WORKER_TOKEN.to_string()
260            }
261        };
262
263        let port = match env::var("PORT").ok() {
264            Some(raw) => raw.parse::<u16>().unwrap_or_else(|_| {
265                errors.push(format!("PORT must be a valid u16, got: {raw}"));
266                0
267            }),
268            None => 3000,
269        };
270
271        let allowed_origins = env::var("ALLOWED_ORIGINS").ok();
272        let dashboard_dir = env::var("DASHBOARD_DIR").ok().map(PathBuf::from);
273        let webhook_url = env::var("WEBHOOK_URL").ok();
274
275        let rate_limit_auth = parse_optional_u32("RATE_LIMIT_AUTH", 10, &mut errors);
276        let rate_limit_general = parse_optional_u32("RATE_LIMIT_GENERAL", 60, &mut errors);
277
278        let artifacts_dir = env::var("ARTIFACTS_DIR").ok().map(PathBuf::from);
279        let artifact_max_bytes = match env::var("ARTIFACT_MAX_BYTES").ok() {
280            Some(raw) => raw.parse::<u64>().unwrap_or_else(|_| {
281                errors.push(format!(
282                    "ARTIFACT_MAX_BYTES must be a valid u64, got: {raw}"
283                ));
284                DEFAULT_MAX_ARTIFACT_BYTES
285            }),
286            None => DEFAULT_MAX_ARTIFACT_BYTES,
287        };
288
289        let purge_max_age_days = match env::var("PURGE_MAX_AGE_DAYS").ok() {
290            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
291                errors.push(format!(
292                    "PURGE_MAX_AGE_DAYS must be a valid u32, got: {raw}"
293                ));
294                90
295            }),
296            None => 90,
297        };
298        let purge_max_runs_per_workflow = match env::var("PURGE_MAX_RUNS_PER_WORKFLOW").ok() {
299            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
300                errors.push(format!(
301                    "PURGE_MAX_RUNS_PER_WORKFLOW must be a valid u32, got: {raw}"
302                ));
303                1000
304            }),
305            None => 1000,
306        };
307        let purge_dry_run = env::var("PURGE_DRY_RUN")
308            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
309            .unwrap_or(false);
310        let purge_interval_secs = match env::var("PURGE_INTERVAL_SECS").ok() {
311            Some(raw) => {
312                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
313                    errors.push(format!(
314                        "PURGE_INTERVAL_SECS must be a valid u64, got: {raw}"
315                    ));
316                    86400
317                });
318                if parsed < 60 {
319                    errors.push(format!(
320                        "PURGE_INTERVAL_SECS must be at least 60, got: {parsed}"
321                    ));
322                }
323                parsed
324            }
325            None => 86400,
326        };
327
328        let artifact_backend = env::var("ARTIFACT_BACKEND")
329            .unwrap_or_else(|_| "local".to_string())
330            .to_lowercase();
331        let artifact_s3_bucket = env::var("ARTIFACT_S3_BUCKET").ok();
332        let artifact_s3_region =
333            env::var("ARTIFACT_S3_REGION").unwrap_or_else(|_| "eu-west-1".to_string());
334        let artifact_s3_endpoint = env::var("ARTIFACT_S3_ENDPOINT").ok();
335        let artifact_s3_prefix = env::var("ARTIFACT_S3_PREFIX").ok();
336
337        if artifact_backend == "s3" && artifact_s3_bucket.is_none() {
338            errors.push("ARTIFACT_S3_BUCKET is required when ARTIFACT_BACKEND=s3".to_string());
339        }
340        if artifact_backend != "local" && artifact_backend != "s3" {
341            errors.push(format!(
342                "ARTIFACT_BACKEND must be 'local' or 's3', got: {artifact_backend}"
343            ));
344        }
345
346        let artifact_gc_interval_secs = match env::var("ARTIFACT_GC_INTERVAL_SECS").ok() {
347            Some(raw) => {
348                let parsed = raw.parse::<u64>().unwrap_or_else(|_| {
349                    errors.push(format!(
350                        "ARTIFACT_GC_INTERVAL_SECS must be a valid u64, got: {raw}"
351                    ));
352                    86400
353                });
354                if parsed < 60 {
355                    errors.push(format!(
356                        "ARTIFACT_GC_INTERVAL_SECS must be at least 60, got: {parsed}"
357                    ));
358                }
359                parsed
360            }
361            None => 86400,
362        };
363        let artifact_gc_grace_days = match env::var("ARTIFACT_GC_GRACE_DAYS").ok() {
364            Some(raw) => raw.parse::<u32>().unwrap_or_else(|_| {
365                errors.push(format!(
366                    "ARTIFACT_GC_GRACE_DAYS must be a valid u32, got: {raw}"
367                ));
368                7
369            }),
370            None => 7,
371        };
372        let artifact_gc_dry_run = env::var("ARTIFACT_GC_DRY_RUN")
373            .map(|v| v.eq_ignore_ascii_case("true") || v == "1")
374            .unwrap_or(false);
375
376        if !errors.is_empty() {
377            return Err(ConfigError::new(errors));
378        }
379
380        Ok(Self {
381            database_url,
382            jwt_secret,
383            worker_token,
384            port,
385            allowed_origins,
386            dashboard_dir,
387            webhook_url,
388            is_production,
389            rate_limit_auth,
390            rate_limit_general,
391            artifacts_dir,
392            artifact_max_bytes,
393            purge_max_age_days,
394            purge_max_runs_per_workflow,
395            purge_dry_run,
396            purge_interval_secs,
397            artifact_backend,
398            artifact_s3_bucket,
399            artifact_s3_region,
400            artifact_s3_endpoint,
401            artifact_s3_prefix,
402            artifact_gc_interval_secs,
403            artifact_gc_grace_days,
404            artifact_gc_dry_run,
405        })
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use std::sync::Mutex;
412
413    use super::*;
414
415    // Env var mutations are not thread-safe -- serialize all tests that touch them.
416    static ENV_LOCK: Mutex<()> = Mutex::new(());
417
418    /// # Safety
419    ///
420    /// Must be called while holding `ENV_LOCK`.
421    unsafe fn clear_env() {
422        unsafe {
423            env::remove_var("IRONFLOW_ENV");
424            env::remove_var("DATABASE_URL");
425            env::remove_var("JWT_SECRET");
426            env::remove_var("WORKER_TOKEN");
427            env::remove_var("PORT");
428            env::remove_var("ALLOWED_ORIGINS");
429            env::remove_var("DASHBOARD_DIR");
430            env::remove_var("WEBHOOK_URL");
431            env::remove_var("RATE_LIMIT_AUTH");
432            env::remove_var("RATE_LIMIT_GENERAL");
433            env::remove_var("PURGE_MAX_AGE_DAYS");
434            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
435            env::remove_var("PURGE_DRY_RUN");
436            env::remove_var("PURGE_INTERVAL_SECS");
437            env::remove_var("ARTIFACT_BACKEND");
438            env::remove_var("ARTIFACT_S3_BUCKET");
439            env::remove_var("ARTIFACT_S3_REGION");
440            env::remove_var("ARTIFACT_S3_ENDPOINT");
441            env::remove_var("ARTIFACT_S3_PREFIX");
442            env::remove_var("ARTIFACT_GC_INTERVAL_SECS");
443            env::remove_var("ARTIFACT_GC_GRACE_DAYS");
444            env::remove_var("ARTIFACT_GC_DRY_RUN");
445        }
446    }
447
448    #[test]
449    fn config_error_display_lists_all_errors() {
450        let err = ConfigError::new(vec![
451            "JWT_SECRET is required".to_string(),
452            "DATABASE_URL is required".to_string(),
453        ]);
454        let msg = err.to_string();
455        assert!(msg.contains("JWT_SECRET"));
456        assert!(msg.contains("DATABASE_URL"));
457        assert!(msg.contains("configuration errors:"));
458    }
459
460    #[test]
461    fn config_error_is_std_error() {
462        let err = ConfigError::new(vec!["test".to_string()]);
463        let _: &dyn std::error::Error = &err;
464    }
465
466    #[test]
467    fn default_dev_config_succeeds() {
468        let _guard = ENV_LOCK.lock().unwrap();
469        unsafe { clear_env() };
470
471        let config = ServerConfig::from_env().expect("dev config should succeed");
472        assert!(!config.is_production);
473        assert_eq!(config.port, 3000);
474        assert_eq!(config.jwt_secret, DEV_JWT_SECRET);
475        assert_eq!(config.worker_token, DEV_WORKER_TOKEN);
476    }
477
478    #[test]
479    fn production_without_secrets_fails() {
480        let _guard = ENV_LOCK.lock().unwrap();
481        unsafe {
482            clear_env();
483            env::set_var("IRONFLOW_ENV", "production");
484        }
485
486        let result = ServerConfig::from_env();
487        assert!(result.is_err());
488        let err = result.unwrap_err();
489        assert!(err.errors.len() >= 3);
490        assert!(err.errors.iter().any(|e| e.contains("DATABASE_URL")));
491        assert!(err.errors.iter().any(|e| e.contains("JWT_SECRET")));
492        assert!(err.errors.iter().any(|e| e.contains("WORKER_TOKEN")));
493
494        unsafe { env::remove_var("IRONFLOW_ENV") };
495    }
496
497    #[test]
498    fn invalid_port_returns_error() {
499        let _guard = ENV_LOCK.lock().unwrap();
500        unsafe {
501            clear_env();
502            env::set_var("PORT", "not-a-number");
503        }
504
505        let result = ServerConfig::from_env();
506        assert!(result.is_err());
507        let err = result.unwrap_err();
508        assert!(err.errors.iter().any(|e| e.contains("PORT")));
509
510        unsafe { env::remove_var("PORT") };
511    }
512
513    #[test]
514    fn default_rate_limits() {
515        let _guard = ENV_LOCK.lock().unwrap();
516        unsafe { clear_env() };
517
518        let config = ServerConfig::from_env().unwrap();
519        assert_eq!(config.rate_limit_auth, Some(10));
520        assert_eq!(config.rate_limit_general, Some(60));
521    }
522
523    #[test]
524    fn custom_rate_limits() {
525        let _guard = ENV_LOCK.lock().unwrap();
526        unsafe {
527            clear_env();
528            env::set_var("RATE_LIMIT_AUTH", "20");
529            env::set_var("RATE_LIMIT_GENERAL", "120");
530        }
531
532        let config = ServerConfig::from_env().unwrap();
533        assert_eq!(config.rate_limit_auth, Some(20));
534        assert_eq!(config.rate_limit_general, Some(120));
535
536        unsafe {
537            env::remove_var("RATE_LIMIT_AUTH");
538            env::remove_var("RATE_LIMIT_GENERAL");
539        }
540    }
541
542    #[test]
543    fn zero_rate_limit_disables() {
544        let _guard = ENV_LOCK.lock().unwrap();
545        unsafe {
546            clear_env();
547            env::set_var("RATE_LIMIT_AUTH", "0");
548            env::set_var("RATE_LIMIT_GENERAL", "0");
549        }
550
551        let config = ServerConfig::from_env().unwrap();
552        assert!(config.rate_limit_auth.is_none());
553        assert!(config.rate_limit_general.is_none());
554
555        unsafe {
556            env::remove_var("RATE_LIMIT_AUTH");
557            env::remove_var("RATE_LIMIT_GENERAL");
558        }
559    }
560
561    #[test]
562    fn invalid_rate_limit_returns_error() {
563        let _guard = ENV_LOCK.lock().unwrap();
564        unsafe {
565            clear_env();
566            env::set_var("RATE_LIMIT_AUTH", "not-a-number");
567        }
568
569        let result = ServerConfig::from_env();
570        assert!(result.is_err());
571        let err = result.unwrap_err();
572        assert!(err.errors.iter().any(|e| e.contains("RATE_LIMIT_AUTH")));
573
574        unsafe { env::remove_var("RATE_LIMIT_AUTH") };
575    }
576
577    #[test]
578    fn default_purge_config() {
579        let _guard = ENV_LOCK.lock().unwrap();
580        unsafe { clear_env() };
581
582        let config = ServerConfig::from_env().unwrap();
583        assert_eq!(config.purge_max_age_days, 90);
584        assert_eq!(config.purge_max_runs_per_workflow, 1000);
585        assert!(!config.purge_dry_run);
586        assert_eq!(config.purge_interval_secs, 86400);
587    }
588
589    #[test]
590    fn custom_purge_config() {
591        let _guard = ENV_LOCK.lock().unwrap();
592        unsafe {
593            clear_env();
594            env::set_var("PURGE_MAX_AGE_DAYS", "30");
595            env::set_var("PURGE_MAX_RUNS_PER_WORKFLOW", "500");
596            env::set_var("PURGE_DRY_RUN", "true");
597            env::set_var("PURGE_INTERVAL_SECS", "3600");
598        }
599
600        let config = ServerConfig::from_env().unwrap();
601        assert_eq!(config.purge_max_age_days, 30);
602        assert_eq!(config.purge_max_runs_per_workflow, 500);
603        assert!(config.purge_dry_run);
604        assert_eq!(config.purge_interval_secs, 3600);
605
606        unsafe {
607            env::remove_var("PURGE_MAX_AGE_DAYS");
608            env::remove_var("PURGE_MAX_RUNS_PER_WORKFLOW");
609            env::remove_var("PURGE_DRY_RUN");
610            env::remove_var("PURGE_INTERVAL_SECS");
611        }
612    }
613
614    #[test]
615    fn invalid_purge_max_age_days_returns_error() {
616        let _guard = ENV_LOCK.lock().unwrap();
617        unsafe {
618            clear_env();
619            env::set_var("PURGE_MAX_AGE_DAYS", "not-a-number");
620        }
621
622        let result = ServerConfig::from_env();
623        assert!(result.is_err());
624        let err = result.unwrap_err();
625        assert!(err.errors.iter().any(|e| e.contains("PURGE_MAX_AGE_DAYS")));
626
627        unsafe { env::remove_var("PURGE_MAX_AGE_DAYS") };
628    }
629}