Skip to main content

atrg_core/
env_override.rs

1//! Environment variable overrides for `atrg.toml` configuration.
2//!
3//! Any field in [`Config`] can be overridden at runtime
4//! via an environment variable. The naming convention uses a double-underscore
5//! (`__`) to separate the section from the field:
6//!
7//! | Config field            | Env var                    |
8//! |-------------------------|----------------------------|
9//! | `app.name`              | `ATRG_APP__NAME`           |
10//! | `app.host`              | `ATRG_APP__HOST`           |
11//! | `app.port`              | `ATRG_APP__PORT`           |
12//! | `app.secret_key`        | `ATRG_APP__SECRET_KEY`     |
13//! | `app.environment`       | `ATRG_APP__ENVIRONMENT`    |
14//! | `app.cors_origins`      | `ATRG_APP__CORS_ORIGINS`   |
15//! | `auth.client_id`        | `ATRG_AUTH__CLIENT_ID`     |
16//! | `auth.redirect_uri`     | `ATRG_AUTH__REDIRECT_URI`  |
17//! | `auth.scope`            | `ATRG_AUTH__SCOPE`         |
18//! | `database.url`          | `ATRG_DATABASE__URL`       |
19//!
20//! For `app.cors_origins`, provide a comma-separated list of origins, e.g.
21//! `ATRG_APP__CORS_ORIGINS="http://localhost:5173,https://example.com"`.
22//!
23//! Invalid values (e.g. a non-numeric `ATRG_APP__PORT`) are logged as warnings
24//! and silently ignored — the original config value is preserved.
25
26use crate::config::Config;
27
28/// Apply environment variable overrides to a mutable [`Config`].
29///
30/// Call this after loading `atrg.toml` but before validation, so that env vars
31/// can fix up values for the current deployment environment.
32pub fn apply_env_overrides(config: &mut Config) {
33    // -- [app] ----------------------------------------------------------
34    if let Ok(val) = std::env::var("ATRG_APP__NAME") {
35        tracing::debug!(key = "ATRG_APP__NAME", "applying env override");
36        config.app.name = val;
37    }
38
39    if let Ok(val) = std::env::var("ATRG_APP__HOST") {
40        tracing::debug!(key = "ATRG_APP__HOST", "applying env override");
41        config.app.host = val;
42    }
43
44    if let Ok(val) = std::env::var("ATRG_APP__PORT") {
45        match val.parse::<u16>() {
46            Ok(port) => {
47                tracing::debug!(key = "ATRG_APP__PORT", port, "applying env override");
48                config.app.port = port;
49            }
50            Err(e) => {
51                tracing::warn!(
52                    key = "ATRG_APP__PORT",
53                    value = %val,
54                    error = %e,
55                    "ignoring invalid ATRG_APP__PORT value"
56                );
57            }
58        }
59    }
60
61    if let Ok(val) = std::env::var("ATRG_APP__SECRET_KEY") {
62        tracing::debug!(key = "ATRG_APP__SECRET_KEY", "applying env override");
63        config.app.secret_key = val;
64    }
65
66    if let Ok(val) = std::env::var("ATRG_APP__ENVIRONMENT") {
67        tracing::debug!(key = "ATRG_APP__ENVIRONMENT", "applying env override");
68        config.app.environment = val;
69    }
70
71    if let Ok(val) = std::env::var("ATRG_APP__CORS_ORIGINS") {
72        tracing::debug!(key = "ATRG_APP__CORS_ORIGINS", "applying env override");
73        config.app.cors_origins = val
74            .split(',')
75            .map(|s| s.trim().to_string())
76            .filter(|s| !s.is_empty())
77            .collect();
78    }
79
80    // -- [auth] ---------------------------------------------------------
81    if let Ok(val) = std::env::var("ATRG_AUTH__CLIENT_ID") {
82        tracing::debug!(key = "ATRG_AUTH__CLIENT_ID", "applying env override");
83        config.auth.client_id = val;
84    }
85
86    if let Ok(val) = std::env::var("ATRG_AUTH__REDIRECT_URI") {
87        tracing::debug!(key = "ATRG_AUTH__REDIRECT_URI", "applying env override");
88        config.auth.redirect_uri = val;
89    }
90
91    if let Ok(val) = std::env::var("ATRG_AUTH__SCOPE") {
92        tracing::debug!(key = "ATRG_AUTH__SCOPE", "applying env override");
93        config.auth.scope = val;
94    }
95
96    // -- [database] -----------------------------------------------------
97    if let Ok(val) = std::env::var("ATRG_DATABASE__URL") {
98        tracing::debug!(key = "ATRG_DATABASE__URL", "applying env override");
99        config.database.url = val;
100    }
101
102    // -- [firehose] -----------------------------------------------------
103    if let Ok(val) = std::env::var("ATRG_FIREHOSE__RELAY") {
104        if let Some(ref mut fh) = config.firehose {
105            tracing::debug!(key = "ATRG_FIREHOSE__RELAY", "applying env override");
106            fh.relay = val;
107        }
108    }
109
110    // -- [feed_generator] -----------------------------------------------
111    if let Ok(val) = std::env::var("ATRG_FEED_GENERATOR__DID") {
112        if let Some(ref mut fg) = config.feed_generator {
113            tracing::debug!(key = "ATRG_FEED_GENERATOR__DID", "applying env override");
114            fg.did = val;
115        }
116    }
117
118    // -- [labeler] ------------------------------------------------------
119    if let Ok(val) = std::env::var("ATRG_LABELER__DID") {
120        if let Some(ref mut lb) = config.labeler {
121            tracing::debug!(key = "ATRG_LABELER__DID", "applying env override");
122            lb.did = val;
123        }
124    }
125    if let Ok(val) = std::env::var("ATRG_LABELER__SIGNING_KEY_PATH") {
126        if let Some(ref mut lb) = config.labeler {
127            tracing::debug!(
128                key = "ATRG_LABELER__SIGNING_KEY_PATH",
129                "applying env override"
130            );
131            lb.signing_key_path = Some(val);
132        }
133    }
134    if let Ok(val) = std::env::var("ATRG_LABELER__SIGNING_KEY_BASE64") {
135        if let Some(ref mut lb) = config.labeler {
136            tracing::debug!(
137                key = "ATRG_LABELER__SIGNING_KEY_BASE64",
138                "applying env override"
139            );
140            lb.signing_key_base64 = Some(val);
141        }
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Tests
147// ---------------------------------------------------------------------------
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use crate::config::{AppConfig, AuthConfig, Config, DatabaseConfig};
153
154    /// Build a baseline config for testing. No file I/O needed.
155    fn base_config() -> Config {
156        Config {
157            app: AppConfig {
158                name: "test-app".into(),
159                host: "127.0.0.1".into(),
160                port: 3000,
161                secret_key: "abcdefghijklmnopqrstuvwxyz123456".into(),
162                cors_origins: vec![],
163                environment: "development".into(),
164            },
165            auth: AuthConfig {
166                client_id: "http://localhost:3000/client-metadata.json".into(),
167                redirect_uri: "http://localhost:3000/auth/callback".into(),
168                scope: "atproto transition:generic".into(),
169            },
170            database: DatabaseConfig {
171                url: "sqlite://atrg.db".into(),
172            },
173            jetstream: None,
174            firehose: None,
175            feed_generator: None,
176            labeler: None,
177            rate_limit: None,
178        }
179    }
180
181    /// Helper: set an env var, run a closure, then remove the var.
182    /// Ensures cleanup even on panic.
183    fn with_env_var<F: FnOnce()>(key: &str, value: &str, f: F) {
184        std::env::set_var(key, value);
185        let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(f));
186        std::env::remove_var(key);
187        if let Err(e) = result {
188            std::panic::resume_unwind(e);
189        }
190    }
191
192    #[test]
193    fn override_port() {
194        let mut cfg = base_config();
195        with_env_var("ATRG_APP__PORT", "9090", || {
196            apply_env_overrides(&mut cfg);
197        });
198        assert_eq!(cfg.app.port, 9090);
199    }
200
201    #[test]
202    fn override_secret_key() {
203        let mut cfg = base_config();
204        with_env_var(
205            "ATRG_APP__SECRET_KEY",
206            "new-super-secret-key-for-prod!!",
207            || {
208                apply_env_overrides(&mut cfg);
209            },
210        );
211        assert_eq!(cfg.app.secret_key, "new-super-secret-key-for-prod!!");
212    }
213
214    #[test]
215    fn override_database_url() {
216        let mut cfg = base_config();
217        with_env_var("ATRG_DATABASE__URL", "sqlite://prod.db", || {
218            apply_env_overrides(&mut cfg);
219        });
220        assert_eq!(cfg.database.url, "sqlite://prod.db");
221    }
222
223    #[test]
224    fn override_cors_origins_comma_separated() {
225        let mut cfg = base_config();
226        with_env_var(
227            "ATRG_APP__CORS_ORIGINS",
228            "http://localhost:5173, https://example.com , https://app.example.com",
229            || {
230                apply_env_overrides(&mut cfg);
231            },
232        );
233        assert_eq!(
234            cfg.app.cors_origins,
235            vec![
236                "http://localhost:5173",
237                "https://example.com",
238                "https://app.example.com",
239            ]
240        );
241    }
242
243    #[test]
244    fn invalid_port_is_ignored() {
245        let mut cfg = base_config();
246        let original_port = cfg.app.port;
247        with_env_var("ATRG_APP__PORT", "not_a_number", || {
248            apply_env_overrides(&mut cfg);
249        });
250        assert_eq!(cfg.app.port, original_port);
251    }
252
253    #[test]
254    fn override_app_name() {
255        let mut cfg = base_config();
256        with_env_var("ATRG_APP__NAME", "overridden-app", || {
257            apply_env_overrides(&mut cfg);
258        });
259        assert_eq!(cfg.app.name, "overridden-app");
260    }
261
262    #[test]
263    fn override_auth_client_id() {
264        let mut cfg = base_config();
265        with_env_var(
266            "ATRG_AUTH__CLIENT_ID",
267            "https://prod.example.com/client-metadata.json",
268            || {
269                apply_env_overrides(&mut cfg);
270            },
271        );
272        assert_eq!(
273            cfg.auth.client_id,
274            "https://prod.example.com/client-metadata.json"
275        );
276    }
277
278    #[test]
279    fn empty_cors_origins_value_produces_empty_vec() {
280        let mut cfg = base_config();
281        cfg.app.cors_origins = vec!["http://existing.example.com".into()];
282        with_env_var("ATRG_APP__CORS_ORIGINS", "", || {
283            apply_env_overrides(&mut cfg);
284        });
285        assert!(cfg.app.cors_origins.is_empty());
286    }
287
288    #[test]
289    fn absent_env_vars_leave_config_unchanged() {
290        // Make sure none of the ATRG_ vars are set.
291        for key in &[
292            "ATRG_APP__NAME",
293            "ATRG_APP__HOST",
294            "ATRG_APP__PORT",
295            "ATRG_APP__SECRET_KEY",
296            "ATRG_APP__ENVIRONMENT",
297            "ATRG_APP__CORS_ORIGINS",
298            "ATRG_AUTH__CLIENT_ID",
299            "ATRG_AUTH__REDIRECT_URI",
300            "ATRG_AUTH__SCOPE",
301            "ATRG_DATABASE__URL",
302        ] {
303            std::env::remove_var(key);
304        }
305
306        let mut cfg = base_config();
307        let before = format!("{:?}", cfg);
308        apply_env_overrides(&mut cfg);
309        let after = format!("{:?}", cfg);
310        assert_eq!(before, after);
311    }
312}