1use std::{env, time::Duration};
8
9pub 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 if let Some((name, default)) = var_name.split_once(":-") {
21 return env::var(name).or_else(|_| Ok(default.to_string()));
22 }
23
24 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
40pub 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
51pub 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 (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
84pub 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#[derive(Debug, thiserror::Error)]
120#[non_exhaustive]
121pub enum EnvError {
122 #[error("Missing environment variable: {name}")]
124 MissingVar {
125 name: String,
127 },
128
129 #[error("Missing environment variable {name}: {message}")]
131 MissingVarWithMessage {
132 name: String,
134 message: String,
136 },
137}
138
139#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum ParseError {
143 #[error("Invalid size value '{value}': {reason}")]
145 InvalidSize {
146 value: String,
148 reason: String,
150 },
151
152 #[error("Invalid duration value '{value}': {reason}")]
154 InvalidDuration {
155 value: String,
157 reason: String,
159 },
160}
161
162#[cfg(test)]
163mod tests {
164 #![allow(clippy::unwrap_used)] use super::*;
167
168 #[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 #[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 #[test]
248 fn parse_size_overflow_returns_error() {
249 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 #[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}