Skip to main content

fraiseql_server/config/
env.rs

1//! Helpers for resolving configuration values from environment variables.
2//!
3//! Provides [`resolve_env_value`] which transparently dereferences values
4//! that start with `$` as environment variable names, and parse utilities
5//! for human-friendly size strings (`"10MB"`) and duration strings (`"30s"`).
6
7use std::{env, time::Duration};
8
9/// Resolve a value that may be an environment variable reference
10///
11/// # Errors
12///
13/// Returns `EnvError::MissingVar` if the referenced environment variable is not set.
14/// Returns `EnvError::MissingVarWithMessage` if the variable uses the `:?` syntax and is not set.
15pub fn resolve_env_value(value: &str) -> Result<String, EnvError> {
16    if value.starts_with("${") && value.ends_with('}') {
17        let var_name = &value[2..value.len() - 1];
18
19        // Support default values: ${VAR:-default}
20        if let Some((name, default)) = var_name.split_once(":-") {
21            return env::var(name).or_else(|_| Ok(default.to_string()));
22        }
23
24        // Support required with message: ${VAR:?message}
25        if let Some((name, message)) = var_name.split_once(":?") {
26            return env::var(name).map_err(|_| EnvError::MissingVarWithMessage {
27                name:    name.to_string(),
28                message: message.to_string(),
29            });
30        }
31
32        env::var(var_name).map_err(|_| EnvError::MissingVar {
33            name: var_name.to_string(),
34        })
35    } else {
36        Ok(value.to_string())
37    }
38}
39
40/// Get value from environment variable name stored in config
41///
42/// # Errors
43///
44/// Returns `EnvError::MissingVar` if the named environment variable is not set.
45pub fn get_env_value(env_var_name: &str) -> Result<String, EnvError> {
46    env::var(env_var_name).map_err(|_| EnvError::MissingVar {
47        name: env_var_name.to_string(),
48    })
49}
50
51/// Parse size strings like "10MB", "1GB"
52///
53/// # Errors
54///
55/// Returns `ParseError::InvalidSize` if the string is not a valid size or the number overflows.
56pub fn parse_size(s: &str) -> Result<usize, ParseError> {
57    let s = s.trim();
58    let s_upper = s.to_uppercase();
59
60    let (num_str, multiplier) = if s_upper.ends_with("GB") {
61        (&s[..s.len() - 2], 1024 * 1024 * 1024)
62    } else if s_upper.ends_with("MB") {
63        (&s[..s.len() - 2], 1024 * 1024)
64    } else if s_upper.ends_with("KB") {
65        (&s[..s.len() - 2], 1024)
66    } else if s_upper.ends_with('B') {
67        (&s[..s.len() - 1], 1)
68    } else {
69        // Assume bytes if no unit
70        (s, 1)
71    };
72
73    let num: usize = num_str.trim().parse().map_err(|_| ParseError::InvalidSize {
74        value:  s.to_string(),
75        reason: "Invalid number".to_string(),
76    })?;
77
78    num.checked_mul(multiplier).ok_or_else(|| ParseError::InvalidSize {
79        value:  s.to_string(),
80        reason: "Value too large".to_string(),
81    })
82}
83
84/// Parse duration strings like "30s", "5m", "1h"
85///
86/// # Errors
87///
88/// Returns `ParseError::InvalidDuration` if the string is missing a unit suffix or the number is
89/// invalid.
90pub fn parse_duration(s: &str) -> Result<Duration, ParseError> {
91    let s = s.trim().to_lowercase();
92
93    let (num_str, multiplier_ms) = if s.ends_with("ms") {
94        (&s[..s.len() - 2], 1u64)
95    } else if s.ends_with('s') {
96        (&s[..s.len() - 1], 1000)
97    } else if s.ends_with('m') {
98        (&s[..s.len() - 1], 60 * 1000)
99    } else if s.ends_with('h') {
100        (&s[..s.len() - 1], 60 * 60 * 1000)
101    } else if s.ends_with('d') {
102        (&s[..s.len() - 1], 24 * 60 * 60 * 1000)
103    } else {
104        return Err(ParseError::InvalidDuration {
105            value:  s,
106            reason: "Missing unit (ms, s, m, h, d)".to_string(),
107        });
108    };
109
110    let num: u64 = num_str.trim().parse().map_err(|_| ParseError::InvalidDuration {
111        value:  s.clone(),
112        reason: "Invalid number".to_string(),
113    })?;
114
115    Ok(Duration::from_millis(num * multiplier_ms))
116}
117
118/// Errors produced when a required environment variable is absent.
119#[derive(Debug, thiserror::Error)]
120#[non_exhaustive]
121pub enum EnvError {
122    /// A required environment variable was not set.
123    #[error("Missing environment variable: {name}")]
124    MissingVar {
125        /// Name of the missing variable.
126        name: String,
127    },
128
129    /// A required environment variable was not set; carries an extra explanation.
130    #[error("Missing environment variable {name}: {message}")]
131    MissingVarWithMessage {
132        /// Name of the missing variable.
133        name:    String,
134        /// Human-readable explanation of why the variable is required.
135        message: String,
136    },
137}
138
139/// Errors produced when a configuration string cannot be parsed.
140#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum ParseError {
143    /// A size string (e.g. `"10MB"`) could not be interpreted.
144    #[error("Invalid size value '{value}': {reason}")]
145    InvalidSize {
146        /// The raw string that failed parsing.
147        value:  String,
148        /// Explanation of why parsing failed.
149        reason: String,
150    },
151
152    /// A duration string (e.g. `"30s"`) could not be interpreted.
153    #[error("Invalid duration value '{value}': {reason}")]
154    InvalidDuration {
155        /// The raw string that failed parsing.
156        value:  String,
157        /// Explanation of why parsing failed.
158        reason: String,
159    },
160}
161
162#[cfg(test)]
163mod tests {
164    #![allow(clippy::unwrap_used)] // Reason: test code, panics acceptable
165
166    use super::*;
167
168    // ─── resolve_env_value ──────────────────────────────────────────────────
169
170    #[test]
171    fn literal_value_returned_unchanged() {
172        assert_eq!(resolve_env_value("hello").unwrap(), "hello");
173    }
174
175    #[test]
176    fn env_var_reference_resolves() {
177        temp_env::with_var("FRAISEQL_TEST_ENV_RS", Some("resolved"), || {
178            assert_eq!(resolve_env_value("${FRAISEQL_TEST_ENV_RS}").unwrap(), "resolved");
179        });
180    }
181
182    #[test]
183    fn missing_env_var_returns_error() {
184        temp_env::with_var("FRAISEQL_MISSING_VAR", None::<&str>, || {
185            let err = resolve_env_value("${FRAISEQL_MISSING_VAR}").unwrap_err();
186            assert!(
187                matches!(err, EnvError::MissingVar { ref name } if name == "FRAISEQL_MISSING_VAR"),
188                "expected MissingVar, got: {err:?}"
189            );
190        });
191    }
192
193    #[test]
194    fn default_syntax_uses_fallback_when_absent() {
195        temp_env::with_var("FRAISEQL_ABSENT", None::<&str>, || {
196            assert_eq!(resolve_env_value("${FRAISEQL_ABSENT:-fallback}").unwrap(), "fallback");
197        });
198    }
199
200    #[test]
201    fn default_syntax_uses_real_value_when_present() {
202        temp_env::with_var("FRAISEQL_PRESENT", Some("real"), || {
203            assert_eq!(resolve_env_value("${FRAISEQL_PRESENT:-fallback}").unwrap(), "real");
204        });
205    }
206
207    #[test]
208    fn required_with_message_syntax_errors_with_message() {
209        temp_env::with_var("FRAISEQL_REQUIRED", None::<&str>, || {
210            let err = resolve_env_value("${FRAISEQL_REQUIRED:?must be set}").unwrap_err();
211            assert!(
212                matches!(
213                    err,
214                    EnvError::MissingVarWithMessage { ref name, ref message }
215                    if name == "FRAISEQL_REQUIRED" && message == "must be set"
216                ),
217                "expected MissingVarWithMessage, got: {err:?}"
218            );
219        });
220    }
221
222    #[test]
223    fn required_with_message_syntax_resolves_when_present() {
224        temp_env::with_var("FRAISEQL_REQUIRED_OK", Some("value"), || {
225            assert_eq!(resolve_env_value("${FRAISEQL_REQUIRED_OK:?must be set}").unwrap(), "value");
226        });
227    }
228
229    // ─── get_env_value ──────────────────────────────────────────────────────
230
231    #[test]
232    fn get_env_value_returns_value_when_set() {
233        temp_env::with_var("FRAISEQL_GET_TEST", Some("got_it"), || {
234            assert_eq!(get_env_value("FRAISEQL_GET_TEST").unwrap(), "got_it");
235        });
236    }
237
238    #[test]
239    fn get_env_value_returns_error_when_missing() {
240        temp_env::with_var("FRAISEQL_GET_MISSING", None::<&str>, || {
241            assert!(get_env_value("FRAISEQL_GET_MISSING").is_err());
242        });
243    }
244
245    // ─── parse_size edge cases ──────────────────────────────────────────────
246
247    #[test]
248    fn parse_size_overflow_returns_error() {
249        // usize::MAX GB would overflow
250        let result = parse_size(&format!("{}GB", usize::MAX));
251        assert!(result.is_err(), "overflow must return Err");
252    }
253
254    #[test]
255    fn parse_size_whitespace_trimmed() {
256        assert_eq!(parse_size("  10MB  ").unwrap(), 10 * 1024 * 1024);
257    }
258
259    #[test]
260    fn parse_size_case_insensitive() {
261        assert_eq!(parse_size("10mb").unwrap(), 10 * 1024 * 1024);
262        assert_eq!(parse_size("10Mb").unwrap(), 10 * 1024 * 1024);
263    }
264
265    #[test]
266    fn parse_size_zero_is_valid() {
267        assert_eq!(parse_size("0MB").unwrap(), 0);
268    }
269
270    // ─── parse_duration edge cases ──────────────────────────────────────────
271
272    #[test]
273    fn parse_duration_zero_is_valid() {
274        assert_eq!(parse_duration("0s").unwrap(), Duration::from_secs(0));
275    }
276
277    #[test]
278    fn parse_duration_whitespace_trimmed() {
279        assert_eq!(parse_duration("  30s  ").unwrap(), Duration::from_secs(30));
280    }
281
282    #[test]
283    fn parse_duration_missing_unit_returns_error() {
284        let err = parse_duration("42").unwrap_err();
285        assert!(matches!(err, ParseError::InvalidDuration { .. }));
286    }
287
288    #[test]
289    fn parse_duration_non_numeric_returns_error() {
290        let err = parse_duration("xyzs").unwrap_err();
291        assert!(matches!(err, ParseError::InvalidDuration { .. }));
292    }
293}