Skip to main content

runtara_core/
config.rs

1// Copyright (C) 2025 SyncMyOrders Sp. z o.o.
2// SPDX-License-Identifier: AGPL-3.0-or-later
3//! Configuration loading from environment variables.
4
5use std::net::SocketAddr;
6
7/// Runtara Core configuration
8#[derive(Debug, Clone)]
9pub struct Config {
10    /// PostgreSQL or SQLite connection URL
11    pub database_url: String,
12    /// HTTP server address for instance communication
13    pub http_addr: SocketAddr,
14    /// Maximum concurrent instances
15    pub max_concurrent_instances: u32,
16}
17
18impl Config {
19    /// Load configuration from environment variables.
20    ///
21    /// Required:
22    /// - `RUNTARA_DATABASE_URL`: PostgreSQL or SQLite connection string
23    ///
24    /// Optional (with defaults):
25    /// - `RUNTARA_HTTP_PORT`: HTTP server port (default: 8001)
26    /// - `RUNTARA_MAX_CONCURRENT_INSTANCES`: Max concurrent instances (default: 32)
27    pub fn from_env() -> Result<Self, ConfigError> {
28        let database_url = std::env::var("RUNTARA_DATABASE_URL")
29            .map_err(|_| ConfigError::Missing("RUNTARA_DATABASE_URL"))?;
30
31        let http_port: u16 = std::env::var("RUNTARA_HTTP_PORT")
32            .unwrap_or_else(|_| "8001".to_string())
33            .parse()
34            .map_err(|_| {
35                ConfigError::Invalid("RUNTARA_HTTP_PORT", "must be a valid port number")
36            })?;
37
38        let max_concurrent_instances: u32 = std::env::var("RUNTARA_MAX_CONCURRENT_INSTANCES")
39            .unwrap_or_else(|_| "32".to_string())
40            .parse()
41            .map_err(|_| {
42                ConfigError::Invalid(
43                    "RUNTARA_MAX_CONCURRENT_INSTANCES",
44                    "must be a positive integer",
45                )
46            })?;
47
48        Ok(Self {
49            database_url,
50            http_addr: SocketAddr::from(([0, 0, 0, 0], http_port)),
51            max_concurrent_instances,
52        })
53    }
54}
55
56/// Configuration errors
57#[derive(Debug, thiserror::Error)]
58pub enum ConfigError {
59    /// A required environment variable is missing.
60    #[error("missing required environment variable: {0}")]
61    Missing(&'static str),
62
63    /// An environment variable has an invalid value.
64    #[error("invalid value for {0}: {1}")]
65    Invalid(&'static str, &'static str),
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71    use std::env;
72    use std::sync::Mutex;
73
74    // Mutex to serialize tests that modify environment variables
75    static ENV_MUTEX: Mutex<()> = Mutex::new(());
76
77    /// Helper to set env vars for a test and restore them after
78    struct EnvGuard {
79        vars: Vec<(String, Option<String>)>,
80    }
81
82    impl EnvGuard {
83        fn new() -> Self {
84            Self { vars: Vec::new() }
85        }
86
87        fn set(&mut self, key: &str, value: &str) {
88            let old = env::var(key).ok();
89            self.vars.push((key.to_string(), old));
90            // SAFETY: Tests are serialized via ENV_MUTEX, so no concurrent access
91            unsafe { env::set_var(key, value) };
92        }
93
94        fn remove(&mut self, key: &str) {
95            let old = env::var(key).ok();
96            self.vars.push((key.to_string(), old));
97            // SAFETY: Tests are serialized via ENV_MUTEX, so no concurrent access
98            unsafe { env::remove_var(key) };
99        }
100    }
101
102    impl Drop for EnvGuard {
103        fn drop(&mut self) {
104            for (key, value) in self.vars.drain(..).rev() {
105                // SAFETY: Tests are serialized via ENV_MUTEX, so no concurrent access
106                unsafe {
107                    match value {
108                        Some(v) => env::set_var(&key, v),
109                        None => env::remove_var(&key),
110                    }
111                }
112            }
113        }
114    }
115
116    #[test]
117    fn test_config_from_env_with_defaults() {
118        let _lock = ENV_MUTEX.lock().unwrap();
119        let mut guard = EnvGuard::new();
120
121        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
122        guard.remove("RUNTARA_HTTP_PORT");
123        guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
124
125        let config = Config::from_env().unwrap();
126
127        assert_eq!(config.database_url, "postgres://localhost/test");
128        assert_eq!(config.http_addr.port(), 8001);
129        assert_eq!(config.max_concurrent_instances, 32);
130    }
131
132    #[test]
133    fn test_config_from_env_with_custom_port() {
134        let _lock = ENV_MUTEX.lock().unwrap();
135        let mut guard = EnvGuard::new();
136
137        guard.set("RUNTARA_DATABASE_URL", "sqlite:test.db");
138        guard.set("RUNTARA_HTTP_PORT", "9999");
139        guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
140
141        let config = Config::from_env().unwrap();
142
143        assert_eq!(config.database_url, "sqlite:test.db");
144        assert_eq!(config.http_addr.port(), 9999);
145        assert_eq!(config.max_concurrent_instances, 32);
146    }
147
148    #[test]
149    fn test_config_from_env_with_custom_max_instances() {
150        let _lock = ENV_MUTEX.lock().unwrap();
151        let mut guard = EnvGuard::new();
152
153        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
154        guard.remove("RUNTARA_HTTP_PORT");
155        guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "100");
156
157        let config = Config::from_env().unwrap();
158
159        assert_eq!(config.max_concurrent_instances, 100);
160    }
161
162    #[test]
163    fn test_config_from_env_all_custom() {
164        let _lock = ENV_MUTEX.lock().unwrap();
165        let mut guard = EnvGuard::new();
166
167        guard.set("RUNTARA_DATABASE_URL", "postgres://user:pass@db:5432/prod");
168        guard.set("RUNTARA_HTTP_PORT", "8080");
169        guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "256");
170
171        let config = Config::from_env().unwrap();
172
173        assert_eq!(config.database_url, "postgres://user:pass@db:5432/prod");
174        assert_eq!(config.http_addr.port(), 8080);
175        assert_eq!(config.max_concurrent_instances, 256);
176    }
177
178    #[test]
179    fn test_config_missing_database_url() {
180        let _lock = ENV_MUTEX.lock().unwrap();
181        let mut guard = EnvGuard::new();
182
183        guard.remove("RUNTARA_DATABASE_URL");
184
185        let result = Config::from_env();
186        assert!(result.is_err());
187
188        let err = result.unwrap_err();
189        assert!(matches!(err, ConfigError::Missing("RUNTARA_DATABASE_URL")));
190        assert!(err.to_string().contains("RUNTARA_DATABASE_URL"));
191    }
192
193    #[test]
194    fn test_config_invalid_http_port() {
195        let _lock = ENV_MUTEX.lock().unwrap();
196        let mut guard = EnvGuard::new();
197
198        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
199        guard.set("RUNTARA_HTTP_PORT", "not_a_number");
200
201        let result = Config::from_env();
202        assert!(result.is_err());
203
204        let err = result.unwrap_err();
205        assert!(matches!(err, ConfigError::Invalid("RUNTARA_HTTP_PORT", _)));
206    }
207
208    #[test]
209    fn test_config_invalid_http_port_out_of_range() {
210        let _lock = ENV_MUTEX.lock().unwrap();
211        let mut guard = EnvGuard::new();
212
213        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
214        guard.set("RUNTARA_HTTP_PORT", "99999"); // > 65535
215
216        let result = Config::from_env();
217        assert!(result.is_err());
218
219        let err = result.unwrap_err();
220        assert!(matches!(err, ConfigError::Invalid("RUNTARA_HTTP_PORT", _)));
221    }
222
223    #[test]
224    fn test_config_invalid_max_concurrent_instances() {
225        let _lock = ENV_MUTEX.lock().unwrap();
226        let mut guard = EnvGuard::new();
227
228        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
229        guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "abc");
230
231        let result = Config::from_env();
232        assert!(result.is_err());
233
234        let err = result.unwrap_err();
235        assert!(matches!(
236            err,
237            ConfigError::Invalid("RUNTARA_MAX_CONCURRENT_INSTANCES", _)
238        ));
239    }
240
241    #[test]
242    fn test_config_negative_max_concurrent_instances() {
243        let _lock = ENV_MUTEX.lock().unwrap();
244        let mut guard = EnvGuard::new();
245
246        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
247        guard.set("RUNTARA_MAX_CONCURRENT_INSTANCES", "-5");
248
249        let result = Config::from_env();
250        assert!(result.is_err());
251    }
252
253    #[test]
254    fn test_config_error_display() {
255        let missing = ConfigError::Missing("MY_VAR");
256        assert_eq!(
257            missing.to_string(),
258            "missing required environment variable: MY_VAR"
259        );
260
261        let invalid = ConfigError::Invalid("MY_VAR", "must be a number");
262        assert_eq!(
263            invalid.to_string(),
264            "invalid value for MY_VAR: must be a number"
265        );
266    }
267
268    #[test]
269    fn test_config_debug() {
270        let _lock = ENV_MUTEX.lock().unwrap();
271        let mut guard = EnvGuard::new();
272
273        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
274        guard.remove("RUNTARA_HTTP_PORT");
275        guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
276
277        let config = Config::from_env().unwrap();
278        let debug_str = format!("{:?}", config);
279
280        assert!(debug_str.contains("Config"));
281        assert!(debug_str.contains("database_url"));
282        assert!(debug_str.contains("http_addr"));
283    }
284
285    #[test]
286    fn test_config_clone() {
287        let _lock = ENV_MUTEX.lock().unwrap();
288        let mut guard = EnvGuard::new();
289
290        guard.set("RUNTARA_DATABASE_URL", "postgres://localhost/test");
291        guard.remove("RUNTARA_HTTP_PORT");
292        guard.remove("RUNTARA_MAX_CONCURRENT_INSTANCES");
293
294        let config = Config::from_env().unwrap();
295        let cloned = config.clone();
296
297        assert_eq!(config.database_url, cloned.database_url);
298        assert_eq!(config.http_addr, cloned.http_addr);
299        assert_eq!(
300            config.max_concurrent_instances,
301            cloned.max_concurrent_instances
302        );
303    }
304}