use std::collections::{HashMap, HashSet};
use std::fs;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum EnvError {
#[error("failed to read env file: {0}")]
Read(String),
#[error("circular variable reference: {0}")]
CircularRef(String),
}
#[derive(Debug, Clone)]
pub struct DuplicateKey {
pub key: String,
pub line: usize,
pub previous_line: usize,
}
#[derive(Debug)]
pub struct ParseResult {
pub values: HashMap<String, String>,
pub duplicates: Vec<DuplicateKey>,
pub line_numbers: HashMap<String, usize>,
}
pub fn parse_env_file(path: &str) -> Result<HashMap<String, String>, EnvError> {
let content = fs::read_to_string(path).map_err(|e| EnvError::Read(e.to_string()))?;
Ok(parse_env_str(&content))
}
pub fn parse_env_file_detailed(path: &str) -> Result<ParseResult, EnvError> {
let content = fs::read_to_string(path).map_err(|e| EnvError::Read(e.to_string()))?;
Ok(parse_env_str_detailed(&content))
}
#[derive(Debug, Clone, Copy, PartialEq)]
enum ParseState {
LineStart,
InKey,
AfterEquals,
InUnquotedValue,
InDoubleQuoted,
InDoubleQuotedEscape,
InSingleQuoted,
}
pub fn parse_env_str(content: &str) -> HashMap<String, String> {
parse_env_str_detailed(content).values
}
pub fn parse_env_str_detailed(content: &str) -> ParseResult {
let mut map = HashMap::new();
let mut key_lines: HashMap<String, usize> = HashMap::new();
let mut duplicates = Vec::new();
let mut state = ParseState::LineStart;
let mut current_key = String::new();
let mut current_value = String::new();
let mut chars = content.chars().peekable();
let mut line_number: usize = 1;
let mut key_start_line: usize = 1;
let insert_tracking = |map: &mut HashMap<String, String>,
key_lines: &mut HashMap<String, usize>,
duplicates: &mut Vec<DuplicateKey>,
key: String,
value: String,
line: usize| {
if let Some(&prev_line) = key_lines.get(&key) {
duplicates.push(DuplicateKey {
key: key.clone(),
line,
previous_line: prev_line,
});
}
key_lines.insert(key.clone(), line);
map.insert(key, value);
};
while let Some(ch) = chars.next() {
if ch == '\n' {
line_number += 1;
}
match state {
ParseState::LineStart => {
if ch == '#' {
while let Some(&c) = chars.peek() {
chars.next();
if c == '\n' {
line_number += 1;
break;
}
}
} else if ch == '\n' || ch == '\r' {
} else if ch.is_whitespace() {
} else if ch == 'e' && chars.peek() == Some(&'x') {
let rest: String = chars.clone().take(5).collect();
if rest == "xport" {
for _ in 0..5 { chars.next(); }
if chars.peek() == Some(&' ') {
chars.next();
}
state = ParseState::LineStart;
} else {
current_key.push(ch);
state = ParseState::InKey;
}
} else {
current_key.push(ch);
key_start_line = line_number;
state = ParseState::InKey;
}
}
ParseState::InKey => {
if ch == '=' {
state = ParseState::AfterEquals;
} else if ch == '\n' || ch == '\r' {
current_key.clear();
state = ParseState::LineStart;
} else if ch.is_whitespace() {
} else {
current_key.push(ch);
}
}
ParseState::AfterEquals => {
if ch == '"' {
state = ParseState::InDoubleQuoted;
} else if ch == '\'' {
state = ParseState::InSingleQuoted;
} else if ch == '\n' || ch == '\r' {
let key = current_key.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, String::new(), key_start_line);
}
current_key.clear();
state = ParseState::LineStart;
} else if ch.is_whitespace() {
} else {
current_value.push(ch);
state = ParseState::InUnquotedValue;
}
}
ParseState::InUnquotedValue => {
if ch == '\n' || ch == '\r' {
let key = current_key.trim().to_string();
let val = current_value.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, val, key_start_line);
}
current_key.clear();
current_value.clear();
state = ParseState::LineStart;
} else if ch == '#' {
let key = current_key.trim().to_string();
let val = current_value.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, val, key_start_line);
}
current_key.clear();
current_value.clear();
while let Some(&c) = chars.peek() {
chars.next();
if c == '\n' {
line_number += 1;
break;
}
}
state = ParseState::LineStart;
} else {
current_value.push(ch);
}
}
ParseState::InDoubleQuoted => {
if ch == '\\' {
state = ParseState::InDoubleQuotedEscape;
} else if ch == '"' {
let key = current_key.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, current_value.clone(), key_start_line);
}
current_key.clear();
current_value.clear();
while let Some(&c) = chars.peek() {
if c == '\n' || c == '\r' { break; }
chars.next();
}
state = ParseState::LineStart;
} else {
current_value.push(ch);
}
}
ParseState::InDoubleQuotedEscape => {
match ch {
'n' => current_value.push('\n'),
'r' => current_value.push('\r'),
't' => current_value.push('\t'),
'\\' => current_value.push('\\'),
'"' => current_value.push('"'),
'\n' | '\r' => {
if ch == '\r' && chars.peek() == Some(&'\n') {
chars.next();
}
}
_ => {
current_value.push('\\');
current_value.push(ch);
}
}
state = ParseState::InDoubleQuoted;
}
ParseState::InSingleQuoted => {
if ch == '\'' {
let key = current_key.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, current_value.clone(), key_start_line);
}
current_key.clear();
current_value.clear();
while let Some(&c) = chars.peek() {
if c == '\n' || c == '\r' { break; }
chars.next();
}
state = ParseState::LineStart;
} else {
current_value.push(ch);
}
}
}
}
match state {
ParseState::InUnquotedValue => {
let key = current_key.trim().to_string();
let val = current_value.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, val, key_start_line);
}
}
ParseState::InDoubleQuoted | ParseState::InSingleQuoted => {
let key = current_key.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, current_value, key_start_line);
}
}
ParseState::AfterEquals => {
let key = current_key.trim().to_string();
if !key.is_empty() {
insert_tracking(&mut map, &mut key_lines, &mut duplicates, key, String::new(), key_start_line);
}
}
_ => {}
}
ParseResult {
values: map,
duplicates,
line_numbers: key_lines,
}
}
pub fn parse_env_str_with_warnings(content: &str, warn_duplicates: bool) -> HashMap<String, String> {
let result = parse_env_str_detailed(content);
if warn_duplicates {
for dup in &result.duplicates {
eprintln!("warning: duplicate key '{}' at line {} (overwriting previous value)", dup.key, dup.line);
}
}
result.values
}
fn interpolate_value(value: &str, env_map: &HashMap<String, String>) -> String {
let mut result = String::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' {
if chars.peek() == Some(&'{') {
chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
if let Some(val) = env_map.get(&var_name) {
result.push_str(val);
} else {
result.push_str(&format!("${{{}}}", var_name));
}
} else if chars.peek().map(|c| c.is_alphabetic() || *c == '_').unwrap_or(false) {
let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '_' {
var_name.push(c);
chars.next();
} else {
break;
}
}
if let Some(val) = env_map.get(&var_name) {
result.push_str(val);
} else {
result.push('$');
result.push_str(&var_name);
}
} else {
result.push('$');
}
} else {
result.push(ch);
}
}
result
}
fn has_var_refs(value: &str) -> bool {
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' {
if chars.peek() == Some(&'{') {
return true;
}
if chars.peek().map(|c| c.is_alphabetic() || *c == '_').unwrap_or(false) {
return true;
}
}
}
false
}
fn extract_var_refs(value: &str) -> Vec<String> {
let mut refs = Vec::new();
let mut chars = value.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '$' {
if chars.peek() == Some(&'{') {
chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
if !var_name.is_empty() {
refs.push(var_name);
}
} else if chars.peek().map(|c| c.is_alphabetic() || *c == '_').unwrap_or(false) {
let mut var_name = String::new();
while let Some(&c) = chars.peek() {
if c.is_alphanumeric() || c == '_' {
var_name.push(c);
chars.next();
} else {
break;
}
}
if !var_name.is_empty() {
refs.push(var_name);
}
}
}
}
refs
}
fn has_resolvable_refs(value: &str, keys: &HashSet<String>) -> bool {
for var_name in extract_var_refs(value) {
if keys.contains(&var_name) {
return true;
}
}
false
}
pub fn interpolate_env(env_map: HashMap<String, String>) -> Result<HashMap<String, String>, EnvError> {
let mut result = env_map.clone();
let keys: HashSet<String> = env_map.keys().cloned().collect();
let max_iterations = env_map.len() + 1;
for _ in 0..max_iterations {
let mut changed = false;
let snapshot = result.clone();
for (_key, value) in result.iter_mut() {
if has_var_refs(value) {
let new_value = interpolate_value(value, &snapshot);
if new_value != *value {
*value = new_value;
changed = true;
}
}
}
if !changed {
for (key, value) in result.iter() {
if has_resolvable_refs(value, &keys) {
return Err(EnvError::CircularRef(key.clone()));
}
}
return Ok(result);
}
}
for (key, value) in result.iter() {
if has_resolvable_refs(value, &keys) {
return Err(EnvError::CircularRef(key.clone()));
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_key_value() {
let input = "FOO=bar";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_multiple_key_values() {
let input = "FOO=bar\nBAZ=qux";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
assert_eq!(result.get("BAZ"), Some(&"qux".to_string()));
}
#[test]
fn test_ignores_comments() {
let input = "# this is a comment\nFOO=bar\n# another comment";
let result = parse_env_str(input);
assert_eq!(result.len(), 1);
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_ignores_blank_lines() {
let input = "\n\nFOO=bar\n\n\nBAZ=qux\n";
let result = parse_env_str(input);
assert_eq!(result.len(), 2);
}
#[test]
fn test_export_prefix() {
let input = "export FOO=bar";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_strips_double_quotes() {
let input = "FOO=\"bar baz\"";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar baz".to_string()));
}
#[test]
fn test_strips_single_quotes() {
let input = "FOO='bar baz'";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar baz".to_string()));
}
#[test]
fn test_unclosed_double_quote() {
let input = "FOO=\"bar'";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar'".to_string()));
}
#[test]
fn test_empty_value() {
let input = "FOO=";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"".to_string()));
}
#[test]
fn test_value_with_equals() {
let input = "DATABASE_URL=postgres://user:pass@host/db?foo=bar";
let result = parse_env_str(input);
assert_eq!(result.get("DATABASE_URL"), Some(&"postgres://user:pass@host/db?foo=bar".to_string()));
}
#[test]
fn test_trims_whitespace() {
let input = " FOO = bar ";
let result = parse_env_str(input);
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
}
#[test]
fn test_empty_input() {
let input = "";
let result = parse_env_str(input);
assert!(result.is_empty());
}
#[test]
fn test_interpolate_brace_syntax() {
let input = "BASE=/home/user\nPATH=${BASE}/bin";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("PATH"), Some(&"/home/user/bin".to_string()));
}
#[test]
fn test_interpolate_bare_syntax() {
let input = "USER=alice\nGREETING=Hello $USER!";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("GREETING"), Some(&"Hello alice!".to_string()));
}
#[test]
fn test_interpolate_multiple_refs() {
let input = "HOST=localhost\nPORT=3000\nURL=http://${HOST}:${PORT}/api";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("URL"), Some(&"http://localhost:3000/api".to_string()));
}
#[test]
fn test_interpolate_chain() {
let input = "A=1\nB=${A}\nC=${B}";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("C"), Some(&"1".to_string()));
}
#[test]
fn test_interpolate_undefined_kept() {
let input = "PATH=${UNDEFINED}/bin";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("PATH"), Some(&"${UNDEFINED}/bin".to_string()));
}
#[test]
fn test_interpolate_circular_error() {
let mut env = HashMap::new();
env.insert("A".to_string(), "${B}".to_string());
env.insert("B".to_string(), "${A}".to_string());
let result = interpolate_env(env);
assert!(result.is_err());
}
#[test]
fn test_interpolate_no_refs() {
let input = "FOO=bar\nBAZ=qux";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("FOO"), Some(&"bar".to_string()));
assert_eq!(result.get("BAZ"), Some(&"qux".to_string()));
}
#[test]
fn test_interpolate_lone_dollar() {
let input = "PRICE=$50";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("PRICE"), Some(&"$50".to_string()));
}
#[test]
fn test_interpolate_underscore_var() {
let input = "MY_VAR=hello\nOTHER=${MY_VAR}_world";
let env = parse_env_str(input);
let result = interpolate_env(env).unwrap();
assert_eq!(result.get("OTHER"), Some(&"hello_world".to_string()));
}
#[test]
fn test_multiline_double_quoted() {
let input = "KEY=\"line1\nline2\nline3\"";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"line1\nline2\nline3".to_string()));
}
#[test]
fn test_multiline_single_quoted() {
let input = "KEY='line1\nline2'";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"line1\nline2".to_string()));
}
#[test]
fn test_multiline_preserves_internal_quotes() {
let input = "KEY=\"he said 'hello'\nand left\"";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"he said 'hello'\nand left".to_string()));
}
#[test]
fn test_escape_newline() {
let input = "MSG=\"line1\\nline2\"";
let result = parse_env_str(input);
assert_eq!(result.get("MSG"), Some(&"line1\nline2".to_string()));
}
#[test]
fn test_escape_tab() {
let input = "MSG=\"col1\\tcol2\"";
let result = parse_env_str(input);
assert_eq!(result.get("MSG"), Some(&"col1\tcol2".to_string()));
}
#[test]
fn test_escape_carriage_return() {
let input = "MSG=\"line1\\rline2\"";
let result = parse_env_str(input);
assert_eq!(result.get("MSG"), Some(&"line1\rline2".to_string()));
}
#[test]
fn test_escape_double_quote() {
let input = r#"MSG="say \"hello\"""#;
let result = parse_env_str(input);
assert_eq!(result.get("MSG"), Some(&"say \"hello\"".to_string()));
}
#[test]
fn test_escape_backslash() {
let input = r#"PATH="C:\\Users\\name""#;
let result = parse_env_str(input);
assert_eq!(result.get("PATH"), Some(&"C:\\Users\\name".to_string()));
}
#[test]
fn test_escape_unknown_kept() {
let input = "MSG=\"test\\xvalue\"";
let result = parse_env_str(input);
assert_eq!(result.get("MSG"), Some(&"test\\xvalue".to_string()));
}
#[test]
fn test_single_quote_no_escape() {
let input = r"KEY='back\\slash'";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"back\\\\slash".to_string()));
}
#[test]
fn test_line_continuation() {
let input = "KEY=\"part1\\\npart2\"";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"part1part2".to_string()));
}
#[test]
fn test_unquoted_no_escape() {
let input = "KEY=value\\nhere";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"value\\nhere".to_string()));
}
#[test]
fn test_inline_comment() {
let input = "KEY=value # this is a comment";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"value".to_string()));
}
#[test]
fn test_hash_in_quoted_value() {
let input = "KEY=\"value # not a comment\"";
let result = parse_env_str(input);
assert_eq!(result.get("KEY"), Some(&"value # not a comment".to_string()));
}
#[test]
fn test_multiple_vars_with_multiline() {
let input = "A=first\nB=\"multi\nline\"\nC=third";
let result = parse_env_str(input);
assert_eq!(result.get("A"), Some(&"first".to_string()));
assert_eq!(result.get("B"), Some(&"multi\nline".to_string()));
assert_eq!(result.get("C"), Some(&"third".to_string()));
}
#[test]
fn test_duplicate_key_last_value_wins() {
let input = "FOO=first\nFOO=second";
let result = parse_env_str_with_warnings(input, false);
assert_eq!(result.get("FOO"), Some(&"second".to_string()));
assert_eq!(result.len(), 1);
}
#[test]
fn test_duplicate_key_multiple_times() {
let input = "FOO=1\nFOO=2\nFOO=3";
let result = parse_env_str_with_warnings(input, false);
assert_eq!(result.get("FOO"), Some(&"3".to_string()));
}
#[test]
fn test_duplicate_with_different_types() {
let input = "KEY=unquoted\nKEY=\"quoted\"";
let result = parse_env_str_with_warnings(input, false);
assert_eq!(result.get("KEY"), Some(&"quoted".to_string()));
}
#[test]
fn test_detailed_parse_no_duplicates() {
let input = "FOO=bar\nBAZ=qux";
let result = parse_env_str_detailed(input);
assert_eq!(result.values.len(), 2);
assert!(result.duplicates.is_empty());
}
#[test]
fn test_detailed_parse_detects_duplicate() {
let input = "FOO=first\nFOO=second";
let result = parse_env_str_detailed(input);
assert_eq!(result.values.get("FOO"), Some(&"second".to_string()));
assert_eq!(result.duplicates.len(), 1);
assert_eq!(result.duplicates[0].key, "FOO");
assert_eq!(result.duplicates[0].previous_line, 1);
assert_eq!(result.duplicates[0].line, 2);
}
#[test]
fn test_detailed_parse_multiple_duplicates() {
let input = "A=1\nA=2\nA=3\nB=x\nB=y";
let result = parse_env_str_detailed(input);
assert_eq!(result.values.len(), 2);
assert_eq!(result.duplicates.len(), 3);
}
#[test]
fn test_detailed_parse_tracks_line_numbers() {
let input = "# comment\nFOO=first\n\nBAR=x\nFOO=second";
let result = parse_env_str_detailed(input);
assert_eq!(result.duplicates.len(), 1);
assert_eq!(result.duplicates[0].key, "FOO");
assert_eq!(result.duplicates[0].previous_line, 2);
assert_eq!(result.duplicates[0].line, 5);
}
#[test]
fn test_parse_env_file_not_found() {
let result = parse_env_file("nonexistent_file_12345.env");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, EnvError::Read(_)));
let err_msg = err.to_string();
assert!(err_msg.contains("failed to read"));
}
#[test]
fn test_parse_env_file_detailed_not_found() {
let result = parse_env_file_detailed("nonexistent_file_67890.env");
assert!(result.is_err());
let err = result.unwrap_err();
assert!(matches!(err, EnvError::Read(_)));
}
#[test]
fn test_parse_env_file_success() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_parse_success.env");
std::fs::write(&file_path, "FOO=bar\nBAZ=qux").unwrap();
let result = parse_env_file(file_path.to_str().unwrap());
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.get("FOO"), Some(&"bar".to_string()));
assert_eq!(map.get("BAZ"), Some(&"qux".to_string()));
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn test_parse_env_file_detailed_success() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_parse_detailed_success.env");
std::fs::write(&file_path, "FOO=bar\nFOO=baz").unwrap();
let result = parse_env_file_detailed(file_path.to_str().unwrap());
assert!(result.is_ok());
let parse_result = result.unwrap();
assert_eq!(parse_result.values.get("FOO"), Some(&"baz".to_string()));
assert_eq!(parse_result.duplicates.len(), 1);
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn test_parse_env_file_empty_file() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_parse_empty.env");
std::fs::write(&file_path, "").unwrap();
let result = parse_env_file(file_path.to_str().unwrap());
assert!(result.is_ok());
let map = result.unwrap();
assert!(map.is_empty());
let _ = std::fs::remove_file(&file_path);
}
#[test]
fn test_env_error_display() {
let read_err = EnvError::Read("file not found".to_string());
assert!(read_err.to_string().contains("failed to read"));
assert!(read_err.to_string().contains("file not found"));
let circular_err = EnvError::CircularRef("VAR_A".to_string());
assert!(circular_err.to_string().contains("circular"));
assert!(circular_err.to_string().contains("VAR_A"));
}
#[test]
fn test_parse_env_file_with_special_characters() {
let temp_dir = std::env::temp_dir();
let file_path = temp_dir.join("test_special_chars.env");
std::fs::write(&file_path, "URL=https://example.com?foo=bar&baz=qux\nKEY=\"value with spaces\"").unwrap();
let result = parse_env_file(file_path.to_str().unwrap());
assert!(result.is_ok());
let map = result.unwrap();
assert_eq!(map.get("URL"), Some(&"https://example.com?foo=bar&baz=qux".to_string()));
assert_eq!(map.get("KEY"), Some(&"value with spaces".to_string()));
let _ = std::fs::remove_file(&file_path);
}
}