use color_eyre::eyre;
use indexmap::IndexMap;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EnvMap {
inner: IndexMap<String, String>,
}
pub fn interpolate_str(input: &str, env: &HashMap<String, String>) -> String {
interpolate(input, env)
}
impl Default for EnvMap {
fn default() -> Self {
Self::new()
}
}
impl EnvMap {
pub fn new() -> Self {
Self {
inner: IndexMap::new(),
}
}
pub fn insert(&mut self, key: impl Into<String>, value: impl Into<String>) {
self.inner.insert(key.into(), value.into());
}
pub fn iter(&self) -> impl Iterator<Item = (&String, &String)> {
self.inner.iter()
}
pub fn extend(&mut self, other: EnvMap) {
self.inner.extend(other.inner);
}
}
fn strip_export_prefix(line: &str) -> &str {
let trimmed = line.trim_start();
if let Some(rest) = trimmed
.strip_prefix("export")
.and_then(|s| s.strip_prefix(char::is_whitespace))
{
rest.trim_start()
} else {
trimmed
}
}
fn strip_inline_comment_outside_quotes(line: &str) -> String {
let mut in_single = false;
let mut in_double = false;
let mut last_was_ws = false;
let mut cleaned = String::with_capacity(line.len());
for ch in line.chars() {
if ch == '\'' && !in_double {
in_single = !in_single;
cleaned.push(ch);
last_was_ws = false;
continue;
}
if ch == '"' && !in_single {
in_double = !in_double;
cleaned.push(ch);
last_was_ws = false;
continue;
}
if ch == '#' && !in_single && !in_double && last_was_ws {
while cleaned.ends_with(char::is_whitespace) {
cleaned.pop();
}
break;
}
last_was_ws = ch.is_whitespace();
cleaned.push(ch);
}
cleaned
}
fn unescape_double_quoted_value(inner: &str) -> String {
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('"') => out.push('"'),
Some('\\') | None => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
}
}
out
}
fn parse_value(raw_value: &str) -> String {
let value = raw_value.trim().to_string();
if value.len() < 2 {
return value;
}
if let Some(inner) = value.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
return unescape_double_quoted_value(inner);
}
if let Some(inner) = value.strip_prefix('\'').and_then(|s| s.strip_suffix('\'')) {
return inner.to_string();
}
value
}
pub fn parse_dotenv(contents: &str) -> eyre::Result<EnvMap> {
let mut env = EnvMap::new();
for (idx, raw_line) in contents.lines().enumerate() {
let line_no = idx + 1;
let line = raw_line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let line = strip_export_prefix(line);
let cleaned = strip_inline_comment_outside_quotes(line);
let line = cleaned.trim();
let (key, value) = line
.split_once('=')
.ok_or_else(|| eyre::eyre!("invalid env file line {line_no}: missing '='"))?;
let key = key.trim();
if key.is_empty() {
return Err(eyre::eyre!("invalid env file line {line_no}: empty key"));
}
let value = parse_value(value);
env.insert(key.to_string(), value);
}
Ok(env)
}
pub fn load_env_files_sync(paths: &[PathBuf]) -> eyre::Result<EnvMap> {
let mut env = EnvMap::new();
for path in paths {
let content = std::fs::read_to_string(path)
.map_err(|err| eyre::eyre!("failed to read env file {}: {err}", path.display()))?;
let parsed = parse_dotenv(&content)
.map_err(|err| eyre::eyre!("failed to parse env file {}: {err}", path.display()))?;
env.extend(parsed);
}
Ok(env)
}
pub fn expand_env_values(env: &EnvMap, base: &HashMap<String, String>) -> EnvMap {
let mut current: HashMap<String, String> = base.clone();
let mut out = EnvMap::new();
for (k, v) in env.iter() {
let expanded = interpolate(v, ¤t);
out.insert(k.clone(), expanded.clone());
current.insert(k.clone(), expanded);
}
out
}
pub fn resolve_path(config_dir: &Path, raw: &str) -> eyre::Result<PathBuf> {
let expanded = shellexpand::full(raw)
.map_err(|err| eyre::eyre!("failed to expand path `{raw}`: {err}"))?
.to_string();
let path = PathBuf::from(expanded);
if path.is_absolute() {
Ok(path)
} else {
Ok(config_dir.join(path))
}
}
fn interpolate(input: &str, env: &HashMap<String, String>) -> String {
let mut out = String::with_capacity(input.len());
let mut chars = input.chars().peekable();
while let Some(ch) = chars.next() {
if ch != '$' {
out.push(ch);
continue;
}
let Some(next) = chars.peek().copied() else {
out.push('$');
break;
};
if next == '$' {
let _ = chars.next();
out.push('$');
continue;
}
if next == '{' {
let _ = chars.next();
let mut key = String::new();
for c in chars.by_ref() {
if c == '}' {
break;
}
key.push(c);
}
if let Some(value) = env.get(&key) {
out.push_str(value);
}
continue;
}
if is_var_start(next) {
let mut key = String::new();
let Some(first) = chars.next() else {
out.push('$');
continue;
};
key.push(first);
while let Some(c) = chars.peek().copied() {
if !is_var_continue(c) {
break;
}
let Some(next) = chars.next() else {
break;
};
key.push(next);
}
if let Some(value) = env.get(&key) {
out.push_str(value);
}
continue;
}
out.push('$');
}
out
}
fn is_var_start(c: char) -> bool {
c == '_' || c.is_ascii_alphabetic()
}
fn is_var_continue(c: char) -> bool {
is_var_start(c) || c.is_ascii_digit()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dotenv_parse_basic() -> eyre::Result<()> {
let env = parse_dotenv("FOO=bar\n# comment\nexport BAZ=qux\n")?;
assert_eq!(env.inner.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(env.inner.get("BAZ").map(String::as_str), Some("qux"));
Ok(())
}
#[test]
fn interpolate_vars() {
let mut m = HashMap::new();
m.insert("A".to_string(), "x".to_string());
m.insert("B".to_string(), "y".to_string());
assert_eq!(interpolate("$A-$B", &m), "x-y");
assert_eq!(interpolate("${A}${B}", &m), "xy");
assert_eq!(interpolate("$$A", &m), "$A");
}
#[test]
fn expand_env_values_is_single_pass_and_ordered() {
let mut base = HashMap::new();
base.insert("X".to_string(), "base".to_string());
let mut env = EnvMap::new();
env.insert("A", "${X}-a");
env.insert("B", "${A}-b");
let out = expand_env_values(&env, &base);
assert_eq!(out.inner.get("A").map(String::as_str), Some("base-a"));
assert_eq!(out.inner.get("B").map(String::as_str), Some("base-a-b"));
}
#[test]
fn expand_env_values_does_not_expand_forward_references() {
let base = HashMap::new();
let mut env = EnvMap::new();
env.insert("B", "${A}-b");
env.insert("A", "a");
let out = expand_env_values(&env, &base);
assert_eq!(out.inner.get("B").map(String::as_str), Some("-b"));
assert_eq!(out.inner.get("A").map(String::as_str), Some("a"));
}
#[test]
fn dotenv_allows_export_with_extra_whitespace() -> eyre::Result<()> {
let env = parse_dotenv("export FOO=bar\nexport\tBAZ=qux\n")?;
assert_eq!(env.inner.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(env.inner.get("BAZ").map(String::as_str), Some("qux"));
Ok(())
}
#[test]
fn dotenv_strips_inline_comments_outside_quotes() -> eyre::Result<()> {
let env = parse_dotenv("FOO=bar # comment\nBAR=\"x # y\" # z\n")?;
assert_eq!(env.inner.get("FOO").map(String::as_str), Some("bar"));
assert_eq!(env.inner.get("BAR").map(String::as_str), Some("x # y"));
Ok(())
}
#[test]
fn dotenv_double_quote_unescapes_common_sequences() -> eyre::Result<()> {
let env = parse_dotenv("A=\"x\\n\\\"y\\\"\\\\z\"\n")?;
assert_eq!(env.inner.get("A").map(String::as_str), Some("x\n\"y\"\\z"));
Ok(())
}
}