use std::collections::HashMap;
use std::fs;
use std::path::Path;
pub fn normalize_env_value(raw: &str) -> String {
let mut value = raw.trim().to_string();
loop {
let trimmed = value.trim();
let bytes = trimmed.as_bytes();
if bytes.len() >= 2 {
let first = bytes[0] as char;
let last = bytes[bytes.len() - 1] as char;
if (first == '"' || first == '\'') && first == last {
value = trimmed[1..trimmed.len() - 1].trim().to_string();
continue;
}
}
let unescaped = trimmed.replace("\\\"", "\"").replace("\\'", "'");
if unescaped != trimmed {
value = unescaped;
continue;
}
return trimmed.to_string();
}
}
pub fn parse_env_content(content: &str) -> HashMap<String, String> {
let mut result = HashMap::new();
for line in content.lines() {
let mut trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if trimmed.starts_with("export ") {
trimmed = trimmed.trim_start_matches("export ").trim();
}
let Some((key, value)) = trimmed.split_once('=') else {
continue;
};
let key = key.trim();
if key.is_empty() {
continue;
}
result.insert(key.to_string(), normalize_env_value(value));
}
result
}
pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
let content = fs::read_to_string(path)
.map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
Ok(parse_env_content(&content))
}
pub fn to_env_references(vars: &HashMap<String, String>) -> HashMap<String, String> {
vars.keys()
.map(|key| (key.clone(), format!("${{{}}}", key)))
.collect()
}
pub fn resolve_env_placeholders(
project_root: &Path,
envs: &HashMap<String, String>,
) -> HashMap<String, String> {
let lookup = load_env_lookup(project_root);
envs.iter()
.map(|(key, value)| {
let resolved = env_reference_name(value)
.and_then(|name| lookup.get(name).cloned())
.unwrap_or_else(|| value.clone());
(key.clone(), resolved)
})
.collect()
}
fn load_env_lookup(project_root: &Path) -> HashMap<String, String> {
let mut lookup = HashMap::new();
for name in [".env", ".env.local", ".env.development", ".env.production"] {
let path = project_root.join(name);
if !path.exists() {
continue;
}
if let Ok(parsed) = parse_env_file(&path) {
lookup.extend(parsed);
}
}
lookup.extend(std::env::vars());
lookup
}
fn env_reference_name(value: &str) -> Option<&str> {
let trimmed = value.trim();
if let Some(name) = trimmed
.strip_prefix("${")
.and_then(|rest| rest.strip_suffix('}'))
{
return (!name.trim().is_empty()).then_some(name.trim());
}
trimmed
.strip_prefix('$')
.map(str::trim)
.filter(|name| !name.is_empty())
}
#[cfg(test)]
mod tests {
use super::{
normalize_env_value, parse_env_content, resolve_env_placeholders, to_env_references,
};
use std::collections::HashMap;
use std::fs;
use std::path::PathBuf;
fn make_temp_dir(label: &str) -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("system clock should be after epoch")
.as_nanos();
let dir = std::env::temp_dir().join(format!("xbp-env-files-{label}-{nanos}"));
fs::create_dir_all(&dir).expect("temp dir should be created");
dir
}
#[test]
fn normalize_env_value_strips_redundant_wrapping_quotes() {
assert_eq!(normalize_env_value(r#""hello""#), "hello");
assert_eq!(normalize_env_value("'hello'"), "hello");
assert_eq!(normalize_env_value(r#"'\"hello\"'"#), "hello");
assert_eq!(normalize_env_value("''hello''"), "hello");
assert_eq!(normalize_env_value("hello"), "hello");
}
#[test]
fn parse_env_content_normalizes_quotes_and_exports() {
let parsed = parse_env_content(
r#"
export FIRST='"hello"'
SECOND='world'
THIRD=plain
"#,
);
assert_eq!(parsed.get("FIRST"), Some(&"hello".to_string()));
assert_eq!(parsed.get("SECOND"), Some(&"world".to_string()));
assert_eq!(parsed.get("THIRD"), Some(&"plain".to_string()));
}
#[test]
fn to_env_references_maps_values_to_placeholders() {
let mut vars = HashMap::new();
vars.insert("DATABASE_URL".to_string(), "postgres://demo".to_string());
let refs = to_env_references(&vars);
assert_eq!(
refs.get("DATABASE_URL"),
Some(&"${DATABASE_URL}".to_string())
);
}
#[test]
fn resolve_env_placeholders_reads_local_env_files() {
let project_root = make_temp_dir("resolve-placeholders");
fs::write(
project_root.join(".env.local"),
"DATABASE_URL='postgres://demo'\nAPI_KEY='\"secret\"'\n",
)
.expect("env file should be written");
let mut envs = HashMap::new();
envs.insert("DATABASE_URL".to_string(), "${DATABASE_URL}".to_string());
envs.insert("API_KEY".to_string(), "${API_KEY}".to_string());
envs.insert("NODE_ENV".to_string(), "production".to_string());
let resolved = resolve_env_placeholders(&project_root, &envs);
assert_eq!(
resolved.get("DATABASE_URL"),
Some(&"postgres://demo".to_string())
);
assert_eq!(resolved.get("API_KEY"), Some(&"secret".to_string()));
assert_eq!(resolved.get("NODE_ENV"), Some(&"production".to_string()));
let _ = fs::remove_dir_all(project_root);
}
}