use std::{env, time::Duration};
pub fn resolve_env_value(value: &str) -> Result<String, EnvError> {
if value.starts_with("${") && value.ends_with('}') {
let var_name = &value[2..value.len() - 1];
if let Some((name, default)) = var_name.split_once(":-") {
return env::var(name).or_else(|_| Ok(default.to_string()));
}
if let Some((name, message)) = var_name.split_once(":?") {
return env::var(name).map_err(|_| EnvError::MissingVarWithMessage {
name: name.to_string(),
message: message.to_string(),
});
}
env::var(var_name).map_err(|_| EnvError::MissingVar {
name: var_name.to_string(),
})
} else {
Ok(value.to_string())
}
}
pub fn get_env_value(env_var_name: &str) -> Result<String, EnvError> {
env::var(env_var_name).map_err(|_| EnvError::MissingVar {
name: env_var_name.to_string(),
})
}
pub fn parse_size(s: &str) -> Result<usize, ParseError> {
let s = s.trim();
let s_upper = s.to_uppercase();
let (num_str, multiplier) = if s_upper.ends_with("GB") {
(&s[..s.len() - 2], 1024 * 1024 * 1024)
} else if s_upper.ends_with("MB") {
(&s[..s.len() - 2], 1024 * 1024)
} else if s_upper.ends_with("KB") {
(&s[..s.len() - 2], 1024)
} else if s_upper.ends_with('B') {
(&s[..s.len() - 1], 1)
} else {
(s, 1)
};
let num: usize = num_str.trim().parse().map_err(|_| ParseError::InvalidSize {
value: s.to_string(),
reason: "Invalid number".to_string(),
})?;
num.checked_mul(multiplier).ok_or_else(|| ParseError::InvalidSize {
value: s.to_string(),
reason: "Value too large".to_string(),
})
}
pub fn parse_duration(s: &str) -> Result<Duration, ParseError> {
let s = s.trim().to_lowercase();
let (num_str, multiplier_ms) = if s.ends_with("ms") {
(&s[..s.len() - 2], 1u64)
} else if s.ends_with('s') {
(&s[..s.len() - 1], 1000)
} else if s.ends_with('m') {
(&s[..s.len() - 1], 60 * 1000)
} else if s.ends_with('h') {
(&s[..s.len() - 1], 60 * 60 * 1000)
} else if s.ends_with('d') {
(&s[..s.len() - 1], 24 * 60 * 60 * 1000)
} else {
return Err(ParseError::InvalidDuration {
value: s,
reason: "Missing unit (ms, s, m, h, d)".to_string(),
});
};
let num: u64 = num_str.trim().parse().map_err(|_| ParseError::InvalidDuration {
value: s.clone(),
reason: "Invalid number".to_string(),
})?;
Ok(Duration::from_millis(num * multiplier_ms))
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum EnvError {
#[error("Missing environment variable: {name}")]
MissingVar {
name: String,
},
#[error("Missing environment variable {name}: {message}")]
MissingVarWithMessage {
name: String,
message: String,
},
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum ParseError {
#[error("Invalid size value '{value}': {reason}")]
InvalidSize {
value: String,
reason: String,
},
#[error("Invalid duration value '{value}': {reason}")]
InvalidDuration {
value: String,
reason: String,
},
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn literal_value_returned_unchanged() {
assert_eq!(resolve_env_value("hello").unwrap(), "hello");
}
#[test]
fn env_var_reference_resolves() {
temp_env::with_var("FRAISEQL_TEST_ENV_RS", Some("resolved"), || {
assert_eq!(resolve_env_value("${FRAISEQL_TEST_ENV_RS}").unwrap(), "resolved");
});
}
#[test]
fn missing_env_var_returns_error() {
temp_env::with_var("FRAISEQL_MISSING_VAR", None::<&str>, || {
let err = resolve_env_value("${FRAISEQL_MISSING_VAR}").unwrap_err();
assert!(
matches!(err, EnvError::MissingVar { ref name } if name == "FRAISEQL_MISSING_VAR"),
"expected MissingVar, got: {err:?}"
);
});
}
#[test]
fn default_syntax_uses_fallback_when_absent() {
temp_env::with_var("FRAISEQL_ABSENT", None::<&str>, || {
assert_eq!(resolve_env_value("${FRAISEQL_ABSENT:-fallback}").unwrap(), "fallback");
});
}
#[test]
fn default_syntax_uses_real_value_when_present() {
temp_env::with_var("FRAISEQL_PRESENT", Some("real"), || {
assert_eq!(resolve_env_value("${FRAISEQL_PRESENT:-fallback}").unwrap(), "real");
});
}
#[test]
fn required_with_message_syntax_errors_with_message() {
temp_env::with_var("FRAISEQL_REQUIRED", None::<&str>, || {
let err = resolve_env_value("${FRAISEQL_REQUIRED:?must be set}").unwrap_err();
assert!(
matches!(
err,
EnvError::MissingVarWithMessage { ref name, ref message }
if name == "FRAISEQL_REQUIRED" && message == "must be set"
),
"expected MissingVarWithMessage, got: {err:?}"
);
});
}
#[test]
fn required_with_message_syntax_resolves_when_present() {
temp_env::with_var("FRAISEQL_REQUIRED_OK", Some("value"), || {
assert_eq!(resolve_env_value("${FRAISEQL_REQUIRED_OK:?must be set}").unwrap(), "value");
});
}
#[test]
fn get_env_value_returns_value_when_set() {
temp_env::with_var("FRAISEQL_GET_TEST", Some("got_it"), || {
assert_eq!(get_env_value("FRAISEQL_GET_TEST").unwrap(), "got_it");
});
}
#[test]
fn get_env_value_returns_error_when_missing() {
temp_env::with_var("FRAISEQL_GET_MISSING", None::<&str>, || {
assert!(get_env_value("FRAISEQL_GET_MISSING").is_err());
});
}
#[test]
fn parse_size_overflow_returns_error() {
let result = parse_size(&format!("{}GB", usize::MAX));
assert!(result.is_err(), "overflow must return Err");
}
#[test]
fn parse_size_whitespace_trimmed() {
assert_eq!(parse_size(" 10MB ").unwrap(), 10 * 1024 * 1024);
}
#[test]
fn parse_size_case_insensitive() {
assert_eq!(parse_size("10mb").unwrap(), 10 * 1024 * 1024);
assert_eq!(parse_size("10Mb").unwrap(), 10 * 1024 * 1024);
}
#[test]
fn parse_size_zero_is_valid() {
assert_eq!(parse_size("0MB").unwrap(), 0);
}
#[test]
fn parse_duration_zero_is_valid() {
assert_eq!(parse_duration("0s").unwrap(), Duration::from_secs(0));
}
#[test]
fn parse_duration_whitespace_trimmed() {
assert_eq!(parse_duration(" 30s ").unwrap(), Duration::from_secs(30));
}
#[test]
fn parse_duration_missing_unit_returns_error() {
let err = parse_duration("42").unwrap_err();
assert!(matches!(err, ParseError::InvalidDuration { .. }));
}
#[test]
fn parse_duration_non_numeric_returns_error() {
let err = parse_duration("xyzs").unwrap_err();
assert!(matches!(err, ParseError::InvalidDuration { .. }));
}
}