dcd 0.1.9

Docker Compose Deployment tool for remote servers
Documentation
use crate::deployer::{
    types::{DeployError, DeployResult},
    DCD_ENV_FILE,
};
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use tokio::fs;

pub struct EnvFileManager {
    /// Environment variables from analysis
    consumed_env: HashMap<String, String>,
    /// Path to generated .env.dcd file
    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),
        }
    }

    /// Generate .env.dcd file with consumed environment variables
    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");

        // Sort keys for consistent output
        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(())
    }

    /// Compare environment variables between files
    pub async fn compare_env_files(
        &self,
        local_files: &[PathBuf],
        remote_files: &[PathBuf],
        executor: &mut (dyn crate::executor::CommandExecutor + Send),
    ) -> DeployResult<bool> {
        // If we have consumed environment variables, always consider as changed
        if !self.consumed_env.is_empty() {
            return Ok(true);
        }

        // Compare each file pair
        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)
    }

    /// Compare single environment file with its remote counterpart
    async fn compare_single_env_file(
        &self,
        local_path: &Path,
        remote_path: &Path,
        executor: &mut (dyn crate::executor::CommandExecutor + Send),
    ) -> DeployResult<bool> {
        // Read local file
        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
            ))
        })?;

        // Read remote file
        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); // Remote file doesn't exist or can't be read
        }

        let remote_content = result
            .output
            .to_stdout_string()
            .map_err(|e| DeployError::Environment(e.to_string()))?;

        // Compare normalized content (ignore whitespace and comments)
        Ok(normalize_env_content(&local_content) == normalize_env_content(&remote_content))
    }

    /// Get path to generated .env.dcd file
    pub fn get_dcd_env_path(&self) -> &Path {
        &self.dcd_env_path
    }

    /// Check if we have any environment variables to manage
    pub fn has_env_vars(&self) -> bool {
        !self.consumed_env.is_empty()
    }
}

/// Normalize environment file content for comparison
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")
}

/// Escape special characters in environment variable values
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\"");
    }
}