use std::collections::HashMap;
use std::path::Path;
use rainier_support::{Error, Result};
#[derive(Debug, Clone, Default)]
pub struct Env {
vars: HashMap<String, String>,
isolated: bool,
}
impl Env {
pub fn new() -> Self {
Self::default()
}
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let contents = std::fs::read_to_string(path.as_ref()).map_err(|e| {
Error::internal(format!("could not read {}: {e}", path.as_ref().display()))
})?;
Ok(Self::parse(&contents))
}
pub fn load_or_default(path: impl AsRef<Path>) -> Self {
Self::load(path).unwrap_or_default()
}
pub fn parse(contents: &str) -> Self {
let mut vars = HashMap::new();
for line in contents.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = line.strip_prefix("export ").map(str::trim_start).unwrap_or(line);
let Some((key, raw)) = line.split_once('=') else {
continue; };
let key = key.trim();
if key.is_empty() {
continue;
}
let value = interpolate(&unquote(raw.trim()), &vars);
vars.insert(key.to_string(), value);
}
Self { vars, isolated: false }
}
pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.vars.insert(key.into(), value.into());
}
pub fn get(&self, key: &str) -> Option<String> {
if self.isolated {
return self.vars.get(key).cloned();
}
match std::env::var(key) {
Ok(value) => Some(value),
Err(_) => self.vars.get(key).cloned(),
}
}
#[must_use = "this returns an isolated Env rather than isolating in place"]
pub fn isolated(mut self) -> Self {
self.isolated = true;
self
}
pub fn from_map<K, V, I>(pairs: I) -> Self
where
I: IntoIterator<Item = (K, V)>,
K: Into<String>,
V: Into<String>,
{
let mut env = Self::default();
for (key, value) in pairs {
env.set(key, value);
}
env.isolated()
}
pub fn is_isolated(&self) -> bool {
self.isolated
}
pub fn string(&self, key: &str, default: impl Into<String>) -> String {
self.get(key).unwrap_or_else(|| default.into())
}
pub fn require(&self, key: &str) -> Result<String> {
self.get(key).ok_or_else(|| {
Error::internal(format!("the `{key}` environment variable is required but not set"))
})
}
pub fn bool(&self, key: &str, default: bool) -> bool {
match self.get(key) {
Some(raw) => match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => true,
"false" | "0" | "no" | "off" | "" => false,
_ => default,
},
None => default,
}
}
pub fn int(&self, key: &str, default: i64) -> i64 {
self.get(key).and_then(|v| v.trim().parse().ok()).unwrap_or(default)
}
pub fn float(&self, key: &str, default: f64) -> f64 {
self.get(key)
.and_then(|v| v.trim().parse::<f64>().ok())
.filter(|value| value.is_finite())
.unwrap_or(default)
}
pub fn setting<T: rainier_support::Setting + Default>(&self, key: &str) -> Result<T> {
match self.get(key) {
Some(raw) => {
T::parse(&raw).map_err(|e| Error::internal(format!("`{key}`: {}", e.message())))
}
None => Ok(T::default()),
}
}
pub fn setting_or<T: rainier_support::Setting>(&self, key: &str, default: T) -> Result<T> {
match self.get(key) {
Some(raw) => {
T::parse(&raw).map_err(|e| Error::internal(format!("`{key}`: {}", e.message())))
}
None => Ok(default),
}
}
pub unsafe fn export_to_process(&self) {
for (key, value) in &self.vars {
if std::env::var(key).is_err() {
unsafe { std::env::set_var(key, value) };
}
}
}
pub fn file_vars(&self) -> &HashMap<String, String> {
&self.vars
}
}
fn unquote(raw: &str) -> String {
let bytes = raw.as_bytes();
if bytes.len() >= 2 && bytes[0] == b'\'' && bytes[bytes.len() - 1] == b'\'' {
return raw[1..raw.len() - 1].to_string();
}
if bytes.len() >= 2 && bytes[0] == b'"' && bytes[bytes.len() - 1] == b'"' {
let inner = &raw[1..raw.len() - 1];
let mut out = String::with_capacity(inner.len());
let mut chars = inner.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('t') => out.push('\t'),
Some(other) => out.push(other),
None => out.push('\\'),
}
}
return out;
}
match raw.split_once(" #") {
Some((value, _)) => value.trim_end().to_string(),
None => raw.trim_end().to_string(),
}
}
fn interpolate(value: &str, known: &HashMap<String, String>) -> String {
if !value.contains("${") {
return value.to_string();
}
let mut out = String::with_capacity(value.len());
let mut rest = value;
while let Some(start) = rest.find("${") {
out.push_str(&rest[..start]);
let after = &rest[start + 2..];
match after.find('}') {
Some(end) => {
let name = &after[..end];
let resolved = known
.get(name)
.cloned()
.or_else(|| std::env::var(name).ok())
.unwrap_or_default();
out.push_str(&resolved);
rest = &after[end + 1..];
}
None => {
out.push_str("${");
rest = after;
}
}
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_plain_assignments() {
let env = Env::parse("APP_NAME=Rainier\nAPP_DEBUG=true\n");
assert_eq!(env.get("APP_NAME").as_deref(), Some("Rainier"));
assert!(env.bool("APP_DEBUG", false));
}
#[test]
fn skips_comments_and_blank_lines() {
let env = Env::parse("# a comment\n\nKEY=value\n # indented comment\n");
assert_eq!(env.file_vars().len(), 1);
assert_eq!(env.get("KEY").as_deref(), Some("value"));
}
#[test]
fn honours_the_export_prefix() {
let env = Env::parse("export DB_HOST=localhost");
assert_eq!(env.get("DB_HOST").as_deref(), Some("localhost"));
}
#[test]
fn strips_quotes_and_handles_escapes() {
let env = Env::parse("SINGLE='raw $value'\nDOUBLE=\"line\\nbreak\"\nBARE=plain value\n");
assert_eq!(env.get("SINGLE").as_deref(), Some("raw $value"));
assert_eq!(env.get("DOUBLE").as_deref(), Some("line\nbreak"));
assert_eq!(env.get("BARE").as_deref(), Some("plain value"));
}
#[test]
fn drops_trailing_comments_on_unquoted_values() {
let env = Env::parse("PORT=8080 # the http port");
assert_eq!(env.get("PORT").as_deref(), Some("8080"));
assert_eq!(env.int("PORT", 0), 8080);
}
#[test]
fn a_hash_inside_quotes_is_not_a_comment() {
let env = Env::parse("KEY=\"a # b\"");
assert_eq!(env.get("KEY").as_deref(), Some("a # b"));
}
#[test]
fn interpolates_earlier_variables() {
let env = Env::parse("HOST=db.internal\nURL=mysql://${HOST}:3306/app\n");
assert_eq!(env.get("URL").as_deref(), Some("mysql://db.internal:3306/app"));
}
#[test]
fn unknown_interpolations_become_empty() {
let env = Env::parse("URL=http://${NOPE_NOT_SET_ANYWHERE}/x");
assert_eq!(env.get("URL").as_deref(), Some("http:///x"));
}
#[test]
fn an_unterminated_interpolation_stays_literal() {
let env = Env::parse("KEY=${broken");
assert_eq!(env.get("KEY").as_deref(), Some("${broken"));
}
#[test]
fn the_process_environment_wins_over_the_file() {
let key = "RAINIER_TEST_ENV_PRECEDENCE";
let mut env = Env::new();
env.set(key, "from-file");
assert_eq!(env.get(key).as_deref(), Some("from-file"));
unsafe { std::env::set_var(key, "from-process") };
assert_eq!(env.get(key).as_deref(), Some("from-process"));
unsafe { std::env::remove_var(key) };
}
#[test]
fn typed_readers_fall_back_to_defaults() {
let env = Env::parse("N=notanumber");
assert_eq!(env.int("N", 7), 7);
assert_eq!(env.int("MISSING", 7), 7);
assert!(env.bool("MISSING", true));
assert_eq!(env.string("MISSING", "fallback"), "fallback");
}
#[test]
fn require_names_the_missing_key() {
let env = Env::new();
let err = env.require("RAINIER_TEST_DEFINITELY_MISSING").unwrap_err();
assert!(err.message().contains("RAINIER_TEST_DEFINITELY_MISSING"));
}
#[test]
fn a_missing_file_is_not_fatal_for_load_or_default() {
let env = Env::load_or_default("./definitely-not-here.env");
assert!(env.file_vars().is_empty());
}
#[test]
fn a_float_parses_and_falls_back() {
let env = Env::parse(
"RATIO=0.1
NONSENSE=banana",
);
assert_eq!(env.float("RATIO", 1.0), 0.1);
assert_eq!(env.float("NONSENSE", 1.0), 1.0, "unparsable falls back");
assert_eq!(env.float("ABSENT", 0.5), 0.5);
}
#[test]
fn a_float_that_is_not_a_number_falls_back_rather_than_poisoning_a_comparison() {
let env = Env::parse(
"RATIO=NaN
HUGE=inf",
);
assert_eq!(env.float("RATIO", 1.0), 1.0);
assert_eq!(env.float("HUGE", 1.0), 1.0);
}
#[test]
fn the_process_wins_over_the_map_unless_isolated() {
let env = Env::parse("PATH=from-the-file");
assert_ne!(env.get("PATH").as_deref(), Some("from-the-file"));
let isolated = Env::parse("PATH=from-the-file").isolated();
assert_eq!(isolated.get("PATH").as_deref(), Some("from-the-file"));
}
#[test]
fn an_isolated_env_sees_nothing_it_was_not_given() {
let env = Env::from_map([("APP_ENV", "testing")]);
assert_eq!(env.get("APP_ENV").as_deref(), Some("testing"));
assert_eq!(env.get("PATH"), None);
assert!(env.is_isolated());
}
#[test]
fn isolation_survives_the_typed_readers() {
let env = Env::from_map([("FLAG", "true"), ("COUNT", "7"), ("RATIO", "0.5")]);
assert!(env.bool("FLAG", false));
assert_eq!(env.int("COUNT", 0), 7);
assert_eq!(env.float("RATIO", 0.0), 0.5);
assert_eq!(env.string("ABSENT", "fallback"), "fallback");
}
}