Skip to main content

tt_config/
lib.rs

1//! Layered configuration loader.
2//!
3//! Minimal env-var loader for the Gateway. Adds layered file (yaml/toml) and
4//! CLI-flag merging in later iterations when those callers exist.
5//!
6//! # Reading
7//!
8//! ```no_run
9//! use tt_config::Config;
10//! let cfg = Config::from_env().expect("env vars valid");
11//! ```
12//!
13//! # Conventions
14//!
15//! * Required vars return [`ConfigError::Missing`] when absent.
16//! * Optional vars return `None` when absent and `Some(_)` when present.
17//! * Parse failures (e.g. `PORT="abc"`) return [`ConfigError::Parse`].
18
19use std::num::ParseIntError;
20
21use thiserror::Error;
22
23/// Top-level errors from config loading.
24#[derive(Debug, Error)]
25pub enum ConfigError {
26    /// A required env var was not set.
27    #[error("required environment variable not set: {0}")]
28    Missing(&'static str),
29    /// A value was set but could not be parsed into the expected type.
30    #[error("invalid value for {var}: {reason}")]
31    Parse {
32        /// Name of the offending env var.
33        var: &'static str,
34        /// Human-readable reason.
35        reason: String,
36    },
37}
38
39impl ConfigError {
40    fn parse_int(var: &'static str, e: ParseIntError) -> Self {
41        Self::Parse {
42            var,
43            reason: e.to_string(),
44        }
45    }
46}
47
48/// Gateway runtime configuration.
49///
50/// All fields are populated by [`Config::from_env`]. Fields are intentionally
51/// public so handlers can read them directly; adding a setter would just be
52/// noise.
53#[derive(Debug, Clone)]
54pub struct Config {
55    /// TCP port the gateway binds. Defaults to 8080.
56    pub port: u16,
57    /// IP address the gateway binds (`TT_BIND_ADDR`). `None` when unset —
58    /// the gateway then picks a default based on whether a key store is
59    /// configured (0.0.0.0 with one, loopback without).
60    pub bind_addr: Option<std::net::IpAddr>,
61    /// Postgres connection string. `None` disables persistence at boot.
62    pub database_url: Option<String>,
63    /// Redis URL for the L1 exact-match cache. `None` disables L1.
64    pub redis_url: Option<String>,
65    /// Sentry DSN for error reporting. `None` disables Sentry init.
66    pub sentry_dsn: Option<String>,
67    /// Hex-encoded XChaCha20-Poly1305 root key for provider credentials at
68    /// rest. Read by the auth/cred layer when it lands; surfaced here so the
69    /// gateway fails fast at boot if production has it misconfigured.
70    pub master_key: Option<String>,
71}
72
73impl Config {
74    /// Load from env. Defaults `port` to 8080 when `PORT` is unset.
75    ///
76    /// # Errors
77    ///
78    /// [`ConfigError::Parse`] when `PORT` is set to a non-integer.
79    pub fn from_env() -> Result<Self, ConfigError> {
80        let port = match std::env::var("PORT") {
81            Ok(v) => v
82                .parse::<u16>()
83                .map_err(|e| ConfigError::parse_int("PORT", e))?,
84            Err(_) => 8080,
85        };
86
87        let bind_addr = match opt("TT_BIND_ADDR") {
88            Some(v) => Some(
89                v.parse::<std::net::IpAddr>()
90                    .map_err(|e| ConfigError::Parse {
91                        var: "TT_BIND_ADDR",
92                        reason: e.to_string(),
93                    })?,
94            ),
95            None => None,
96        };
97
98        Ok(Self {
99            port,
100            bind_addr,
101            database_url: opt("DATABASE_URL"),
102            redis_url: opt("REDIS_URL"),
103            sentry_dsn: opt("SENTRY_DSN"),
104            master_key: opt("TT_MASTER_KEY"),
105        })
106    }
107}
108
109fn opt(var: &str) -> Option<String> {
110    std::env::var(var).ok().filter(|s| !s.is_empty())
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::sync::Mutex;
117
118    /// Serializes the tests below that mutate the process-global `PORT` env
119    /// var. Rust runs a crate's tests in one process in parallel, so without
120    /// this they race — one clears `PORT` while the other sets it to a bad
121    /// value, making `defaults_when_only_required_missing` see the bad value.
122    static ENV_LOCK: Mutex<()> = Mutex::new(());
123
124    /// `from_env` is allowed to be called in tests that don't set anything;
125    /// must return defaults rather than error.
126    #[test]
127    fn defaults_when_only_required_missing() {
128        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
129        // PORT may already be set by the test runner; clear it inside this
130        // test only.
131        let prev_port = std::env::var("PORT").ok();
132        std::env::remove_var("PORT");
133
134        let cfg = Config::from_env().expect("defaults must load");
135        assert_eq!(cfg.port, 8080);
136
137        if let Some(v) = prev_port {
138            std::env::set_var("PORT", v);
139        }
140    }
141
142    #[test]
143    fn invalid_port_returns_parse_error() {
144        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
145        std::env::set_var("PORT", "not-a-number");
146        let err = Config::from_env().expect_err("invalid port must fail");
147        match err {
148            ConfigError::Parse { var, .. } => assert_eq!(var, "PORT"),
149            other => panic!("expected Parse, got {other:?}"),
150        }
151        std::env::remove_var("PORT");
152    }
153
154    #[test]
155    fn bind_addr_unset_is_none() {
156        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
157        std::env::remove_var("TT_BIND_ADDR");
158        let cfg = Config::from_env().expect("defaults must load");
159        assert!(cfg.bind_addr.is_none());
160    }
161
162    #[test]
163    fn bind_addr_parses_v4_and_v6() {
164        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
165        std::env::set_var("TT_BIND_ADDR", "127.0.0.1");
166        let cfg = Config::from_env().expect("valid v4 must load");
167        assert_eq!(
168            cfg.bind_addr,
169            Some(std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST))
170        );
171        std::env::set_var("TT_BIND_ADDR", "::1");
172        let cfg = Config::from_env().expect("valid v6 must load");
173        assert_eq!(
174            cfg.bind_addr,
175            Some(std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST))
176        );
177        std::env::remove_var("TT_BIND_ADDR");
178    }
179
180    #[test]
181    fn invalid_bind_addr_returns_parse_error() {
182        let _guard = ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner());
183        std::env::set_var("TT_BIND_ADDR", "not-an-ip");
184        let err = Config::from_env().expect_err("invalid bind addr must fail");
185        match err {
186            ConfigError::Parse { var, .. } => assert_eq!(var, "TT_BIND_ADDR"),
187            other => panic!("expected Parse, got {other:?}"),
188        }
189        std::env::remove_var("TT_BIND_ADDR");
190    }
191}