use anyhow::{Context, Result};
use log::{debug, info};
use regex::Regex;
use std::collections::HashMap;
use std::fs;
use std::io::Cursor;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub enum LineType {
Variable(Variable),
Other(String), }
#[derive(Debug, Clone, PartialEq)]
pub enum QuoteType {
None,
Single,
Double,
}
#[derive(Debug, Clone)]
pub struct Variable {
pub key: String,
pub value: String,
pub quote_type: QuoteType,
pub has_comment: bool,
pub line_index: usize,
}
pub struct EnvManager {
file_path: Option<PathBuf>,
lines: Vec<LineType>,
variables: HashMap<String, Variable>,
}
impl EnvManager {
pub fn new() -> Self {
EnvManager {
file_path: None,
lines: Vec::new(),
variables: HashMap::new(),
}
}
pub fn load<P: AsRef<Path>>(&mut self, path: P) -> Result<()> {
let path = path.as_ref();
self.file_path = Some(path.to_path_buf());
let content = fs::read_to_string(path)
.with_context(|| format!("Failed to read .env file: {}", path.display()))?;
self.parse_content(&content)?;
Ok(())
}
fn parse_content(&mut self, content: &str) -> Result<()> {
self.lines.clear();
self.variables.clear();
let re = Regex::new(r"^\s*(?:export\s+)?([\w.]+)\s*=\s*(.*?)?\s*$").unwrap();
for (i, line_str) in content.lines().enumerate() {
if let Some(captures) = re.captures(line_str) {
let key = captures.get(1).unwrap().as_str().to_string();
let raw_value_part = captures.get(2).map_or("", |m| m.as_str());
let (raw_value, has_comment) =
if let Some(comment_start) = raw_value_part.find(" #") {
(&raw_value_part[..comment_start], true)
} else {
(raw_value_part, false)
};
let (value, quote_type) = self.parse_value(raw_value, line_str)?;
let var = Variable {
key: key.clone(),
value,
quote_type,
has_comment,
line_index: i,
};
self.lines.push(LineType::Variable(var.clone()));
self.variables.insert(key, var);
} else {
self.lines.push(LineType::Other(line_str.to_string()));
}
}
Ok(())
}
fn parse_value(&self, raw_value: &str, _original_line: &str) -> Result<(String, QuoteType)> {
let trimmed_value = raw_value.trim();
let quote_type = if trimmed_value.starts_with('\'') && trimmed_value.ends_with('\'') {
QuoteType::Single
} else if trimmed_value.starts_with('"') && trimmed_value.ends_with('"') {
QuoteType::Double
} else {
QuoteType::None
};
if quote_type == QuoteType::Double {
let fake_line_for_parser = format!("_DUMMY_KEY_={trimmed_value}");
let mut iter = dotenvy::Iter::new(Cursor::new(fake_line_for_parser));
if let Some(item) = iter.next() {
let (_key, value) = item?;
return Ok((value, quote_type));
}
}
let value = match quote_type {
QuoteType::None => trimmed_value.to_string(),
QuoteType::Single => trimmed_value
.strip_prefix('\'')
.unwrap()
.strip_suffix('\'')
.unwrap()
.to_string(),
QuoteType::Double => unreachable!(), };
Ok((value, quote_type))
}
pub fn save(&self) -> Result<()> {
let path = self
.file_path
.as_ref()
.context("File path is not set; cannot save .env changes")?;
let mut output = String::new();
for (i, line_type) in self.lines.iter().enumerate() {
if i > 0 {
output.push('\n');
}
match line_type {
LineType::Variable(var_template) => {
if let Some(current_var) = self.variables.get(&var_template.key) {
let value_str = match ¤t_var.quote_type {
QuoteType::None => current_var.value.clone(),
QuoteType::Single => format!("'{}'", current_var.value),
QuoteType::Double => format!("\"{}\"", current_var.value),
};
let original_line_str = self.get_original_line_str(current_var.line_index);
let line_ending = if current_var.has_comment {
if let Some(comment_start) = original_line_str.find(" #") {
&original_line_str[comment_start..]
} else {
"" }
} else {
""
};
output
.push_str(&format!("{}={}{}", current_var.key, value_str, line_ending));
}
}
LineType::Other(s) => output.push_str(s),
}
}
fs::write(path, output)
.with_context(|| format!("Failed to write .env file: {}", path.display()))
}
fn get_original_line_str(&self, index: usize) -> &str {
match &self.lines.get(index) {
Some(LineType::Variable(_var)) => {
""
}
Some(LineType::Other(s)) => s,
None => "",
}
}
#[allow(dead_code)]
pub fn get_variable(&self, key: &str) -> Option<&Variable> {
self.variables.get(key)
}
pub fn set_variable(&mut self, key: &str, value: &str) -> Result<()> {
if let Some(var) = self.variables.get_mut(key) {
debug!("Setting variable: {key} = {value}");
var.value = value.to_string();
} else {
anyhow::bail!("Variable '{}' does not exist", key);
}
Ok(())
}
#[allow(dead_code)]
pub fn get_all_variables(&self) -> &HashMap<String, Variable> {
&self.variables
}
}
pub fn update_frontend_port(env_path: &Path, new_port: u16) -> Result<()> {
info!("env_path: {}, new_port: {}", env_path.display(), new_port);
let mut env_manager = EnvManager::new();
env_manager.load(env_path)?;
let port_str = new_port.to_string();
if env_manager
.set_variable("FRONTEND_HOST_PORT", &port_str)
.is_ok()
{
env_manager.save()?;
info!("Successfully updated FRONTEND_HOST_PORT in .env to {new_port}");
} else {
info!("FRONTEND_HOST_PORT not found in .env, no update needed.");
}
Ok(())
}
#[allow(dead_code)]
pub fn load_env_variables(env_path: &Path) -> Result<HashMap<String, String>> {
let mut env_manager = EnvManager::new();
env_manager.load(env_path)?;
let mut result = HashMap::new();
for (key, var) in env_manager.get_all_variables() {
if !var.value.is_empty() {
result.insert(key.clone(), var.value.clone());
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::NamedTempFile;
#[test]
fn test_env_parsing() {
let content = r#"
# This is a comment
FRONTEND_HOST_PORT=80
BACKEND_PORT="3000"
DB_HOST='localhost'
API_URL=http://localhost:3000 # inline comment
EMPTY_VAR=
ESCAPED_VAR="hello\nworld"
"#;
let mut manager = EnvManager::new();
manager.parse_content(content).unwrap();
assert_eq!(manager.variables.len(), 6);
assert_eq!(
manager.get_variable("FRONTEND_HOST_PORT").unwrap().value,
"80"
);
assert_eq!(manager.get_variable("BACKEND_PORT").unwrap().value, "3000");
assert_eq!(
manager.get_variable("BACKEND_PORT").unwrap().quote_type,
QuoteType::Double
);
assert_eq!(
manager.get_variable("DB_HOST").unwrap().quote_type,
QuoteType::Single
);
assert!(manager.get_variable("API_URL").unwrap().has_comment);
assert_eq!(
manager.get_variable("ESCAPED_VAR").unwrap().value,
"hello\nworld"
);
}
#[test]
fn test_save_and_load() {
let temp_file = NamedTempFile::new().unwrap();
let initial_content = r#"
KEY1=VALUE1
# A comment
KEY2="old_value"
KEY3='single_quoted'
"#;
fs::write(temp_file.path(), initial_content).unwrap();
let mut manager = EnvManager::new();
manager.load(temp_file.path()).unwrap();
manager.set_variable("KEY2", "new_value").unwrap();
manager.save().unwrap();
let final_content = fs::read_to_string(temp_file.path()).unwrap();
let expected_content = r#"
KEY1=VALUE1
# A comment
KEY2="new_value"
KEY3='single_quoted'"#;
assert_eq!(final_content.trim(), expected_content.trim());
let mut final_manager = EnvManager::new();
final_manager.parse_content(&final_content).unwrap();
assert_eq!(
final_manager.get_variable("KEY2").unwrap().value,
"new_value"
);
assert_eq!(
final_manager.get_variable("KEY2").unwrap().quote_type,
QuoteType::Double
);
}
}