use crate::deployer::{
types::{DeployError, DeployResult},
DCD_ENV_FILE,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;
pub struct EnvFileManager {
consumed_env: HashMap<String, String>,
dcd_env_path: PathBuf,
}
impl EnvFileManager {
pub fn new(consumed_env: HashMap<String, String>, project_dir: impl AsRef<Path>) -> Self {
Self {
consumed_env,
dcd_env_path: project_dir.as_ref().join(DCD_ENV_FILE),
}
}
pub async fn generate_dcd_env(&self) -> DeployResult<()> {
if self.consumed_env.is_empty() {
return Ok(());
}
let mut content = String::new();
content.push_str("# Generated by DCD - DO NOT EDIT MANUALLY\n");
content.push_str("# This file contains environment variables required by your docker-compose services\n\n");
let mut keys: Vec<_> = self.consumed_env.keys().collect();
keys.sort();
for key in keys {
if let Some(value) = self.consumed_env.get(key) {
content.push_str(&format!("{}={}\n", key, escape_env_value(value)));
}
}
fs::write(&self.dcd_env_path, content).await.map_err(|e| {
DeployError::Environment(format!("Failed to write {}: {}", DCD_ENV_FILE, e))
})?;
Ok(())
}
pub async fn compare_env_files(
&self,
local_files: &[PathBuf],
remote_files: &[PathBuf],
executor: &mut (dyn crate::executor::CommandExecutor + Send),
) -> DeployResult<bool> {
if !self.consumed_env.is_empty() {
return Ok(true);
}
for (local, remote) in local_files.iter().zip(remote_files.iter()) {
if !self
.compare_single_env_file(local, remote, executor)
.await?
{
return Ok(true);
}
}
Ok(false)
}
async fn compare_single_env_file(
&self,
local_path: &Path,
remote_path: &Path,
executor: &mut (dyn crate::executor::CommandExecutor + Send),
) -> DeployResult<bool> {
let local_content = fs::read_to_string(local_path).await.map_err(|e| {
DeployError::Environment(format!(
"Failed to read local env file {}: {}",
local_path.display(),
e
))
})?;
let cmd = format!("cat {}", remote_path.display());
let result = executor
.execute_command(&cmd)
.await
.map_err(|e| DeployError::Environment(e.to_string()))?;
if !result.is_success() {
return Ok(false); }
let remote_content = result
.output
.to_stdout_string()
.map_err(|e| DeployError::Environment(e.to_string()))?;
Ok(normalize_env_content(&local_content) == normalize_env_content(&remote_content))
}
pub fn get_dcd_env_path(&self) -> &Path {
&self.dcd_env_path
}
pub fn has_env_vars(&self) -> bool {
!self.consumed_env.is_empty()
}
}
fn normalize_env_content(content: &str) -> String {
content
.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.is_empty() && !trimmed.starts_with('#')
})
.map(|line| {
let trimmed = line.trim();
if let Some((key, value)) = trimmed.split_once('=') {
format!("{}={}", key.trim(), value.trim())
} else {
trimmed.to_string()
}
})
.collect::<Vec<_>>()
.join("\n")
}
fn escape_env_value(value: &str) -> String {
if value.contains(char::is_whitespace) || value.contains('\"') || value.contains('\'') {
format!("\"{}\"", value.replace('\"', "\\\""))
} else {
value.to_string()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use tempfile::TempDir;
#[tokio::test]
async fn test_generate_dcd_env() {
let temp_dir = TempDir::new().unwrap();
let mut env_vars = HashMap::new();
env_vars.insert("DB_HOST".to_string(), "localhost".to_string());
env_vars.insert("DB_PORT".to_string(), "5432".to_string());
env_vars.insert(
"API_KEY".to_string(),
"secret value with spaces".to_string(),
);
let manager = EnvFileManager::new(env_vars, temp_dir.path());
manager.generate_dcd_env().await.unwrap();
let content = fs::read_to_string(temp_dir.path().join(DCD_ENV_FILE))
.await
.unwrap();
assert!(content.contains("DB_HOST=localhost"));
assert!(content.contains("DB_PORT=5432"));
assert!(content.contains("API_KEY=\"secret value with spaces\""));
}
#[tokio::test]
async fn test_normalize_env_content() {
let content = r#"
# Comment
KEY1=value1
KEY2 = value2
KEY3="value3"
# Another comment
KEY4 = "value 4"
"#;
let normalized = normalize_env_content(content);
assert_eq!(
normalized,
"KEY1=value1\nKEY2=value2\nKEY3=\"value3\"\nKEY4=\"value 4\""
);
}
#[tokio::test]
async fn test_escape_env_value() {
assert_eq!(escape_env_value("simple"), "simple");
assert_eq!(escape_env_value("with space"), "\"with space\"");
assert_eq!(escape_env_value("with\"quote"), "\"with\\\"quote\"");
}
}