jarvy 0.0.5

Jarvy is a fast, cross-platform CLI that installs and manages developer tools across macOS and Linux.
Documentation
//! .env file generation
//!
//! Generates .env files from jarvy.toml configuration with:
//! - Variable expansion
//! - Proper quoting for values with spaces
//! - Header comment with timestamp
//! - Backup before overwrite
//! - Optional .gitignore update

use std::collections::HashMap;
use std::fs;
use std::io::Write;
use std::path::Path;
use thiserror::Error;

use super::expand::{EnvContext, expand_value};
use crate::telemetry;

/// Errors that can occur during .env file generation
#[derive(Error, Debug)]
pub enum DotenvError {
    #[error("Failed to write .env file: {0}")]
    WriteError(#[from] std::io::Error),
    #[error("Existing .env file not created by Jarvy. Use --force to overwrite.")]
    ExistingNonJarvyFile,
    #[error("Failed to backup existing .env file: {0}")]
    BackupError(String),
}

/// Jarvy marker comment for .env files
const JARVY_MARKER: &str = "# Generated by Jarvy";

/// Configuration for .env generation
#[derive(Debug, Clone)]
pub struct DotenvConfig {
    /// Whether to backup existing .env files
    pub backup: bool,
    /// Whether to force overwrite non-Jarvy .env files
    pub force: bool,
    /// Whether to add .env to .gitignore
    pub add_to_gitignore: bool,
}

impl Default for DotenvConfig {
    fn default() -> Self {
        Self {
            backup: true,
            force: false,
            add_to_gitignore: false,
        }
    }
}

/// Generate a .env file from environment variables
///
/// # Arguments
/// * `path` - Path where the .env file should be created
/// * `vars` - HashMap of variable names to their (unexpanded) values
/// * `ctx` - Context for variable expansion
/// * `config` - Generation configuration
///
/// # Returns
/// Ok(()) on success, or an error
pub fn generate_dotenv(
    path: &Path,
    vars: &HashMap<String, String>,
    ctx: &EnvContext,
    config: &DotenvConfig,
) -> Result<(), DotenvError> {
    // Check if file exists and if it's a Jarvy-managed file
    if path.exists() {
        let content = fs::read_to_string(path)?;
        let is_jarvy_file = content.contains(JARVY_MARKER);

        if !is_jarvy_file && !config.force {
            return Err(DotenvError::ExistingNonJarvyFile);
        }

        // Backup existing file
        if config.backup {
            // Create backup path by appending .backup to the filename
            let backup_path = path.parent().unwrap_or(Path::new(".")).join(format!(
                "{}.backup",
                path.file_name().unwrap_or_default().to_string_lossy()
            ));
            fs::copy(path, &backup_path).map_err(|e| {
                DotenvError::BackupError(format!(
                    "Could not backup {} to {}: {}",
                    path.display(),
                    backup_path.display(),
                    e
                ))
            })?;
        }
    }

    // Generate content
    let content = generate_dotenv_content(vars, ctx);

    // Write the file
    let mut file = fs::File::create(path)?;
    file.write_all(content.as_bytes())?;

    // Optionally add to .gitignore
    if config.add_to_gitignore {
        add_to_gitignore(path)?;
    }

    // Emit telemetry (count secrets as 0 since we don't track them separately here)
    telemetry::env_dotenv_generated(vars.len(), 0);

    Ok(())
}

/// Generate the content for a .env file
pub fn generate_dotenv_content(vars: &HashMap<String, String>, ctx: &EnvContext) -> String {
    let mut lines = Vec::new();

    // Header
    lines.push(JARVY_MARKER.to_string());
    lines.push(format!(
        "# Generated at: {}",
        std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .map(|d| d.as_secs())
            .unwrap_or(0)
    ));
    lines.push("# Do not edit manually - changes will be overwritten".to_string());
    lines.push(String::new());

    // Sort keys for deterministic output
    let mut keys: Vec<_> = vars.keys().collect();
    keys.sort();

    for key in keys {
        let raw_value = &vars[key];
        let expanded_value = expand_value(raw_value, ctx);
        let formatted_value = format_value(&expanded_value);
        lines.push(format!("{}={}", key, formatted_value));
    }

    lines.join("\n") + "\n"
}

/// Format a value for .env file (quote if necessary)
fn format_value(value: &str) -> String {
    // Quote if contains spaces, special chars, or is empty
    let needs_quotes = value.is_empty()
        || value.contains(' ')
        || value.contains('\t')
        || value.contains('#')
        || value.contains('$')
        || value.contains('`')
        || value.contains('"')
        || value.contains('\'')
        || value.contains('\n');

    if needs_quotes {
        // Escape double quotes and backslashes
        let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
        format!("\"{}\"", escaped)
    } else {
        value.to_string()
    }
}

/// Add the .env file to .gitignore if not already present
fn add_to_gitignore(dotenv_path: &Path) -> Result<(), DotenvError> {
    let gitignore_path = dotenv_path
        .parent()
        .unwrap_or(Path::new("."))
        .join(".gitignore");

    let filename = dotenv_path
        .file_name()
        .map(|s| s.to_string_lossy().to_string())
        .unwrap_or_else(|| ".env".to_string());

    let mut content = if gitignore_path.exists() {
        fs::read_to_string(&gitignore_path)?
    } else {
        String::new()
    };

    // Check if already in .gitignore
    let patterns_to_check = [
        filename.clone(),
        format!("/{}", filename),
        ".env".to_string(),
        "/.env".to_string(),
        ".env*".to_string(),
    ];

    for pattern in &patterns_to_check {
        if content.lines().any(|line| line.trim() == pattern) {
            return Ok(()); // Already ignored
        }
    }

    // Add to .gitignore
    if !content.is_empty() && !content.ends_with('\n') {
        content.push('\n');
    }
    content.push_str(&format!("\n# Added by Jarvy\n{}\n", filename));

    fs::write(&gitignore_path, content)?;
    Ok(())
}

/// Preview what would be written to the .env file (for dry-run)
pub fn preview_dotenv(vars: &HashMap<String, String>, ctx: &EnvContext) -> String {
    generate_dotenv_content(vars, ctx)
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_format_value_simple() {
        assert_eq!(format_value("simple"), "simple");
    }

    #[test]
    fn test_format_value_with_spaces() {
        assert_eq!(format_value("has spaces"), "\"has spaces\"");
    }

    #[test]
    fn test_format_value_with_special_chars() {
        assert_eq!(format_value("has$var"), "\"has$var\"");
        assert_eq!(format_value("has#comment"), "\"has#comment\"");
    }

    #[test]
    fn test_format_value_empty() {
        assert_eq!(format_value(""), "\"\"");
    }

    #[test]
    fn test_format_value_with_quotes() {
        assert_eq!(format_value("has\"quote"), "\"has\\\"quote\"");
    }

    #[test]
    fn test_generate_content() {
        let mut vars = HashMap::new();
        vars.insert("VAR_A".to_string(), "value_a".to_string());
        vars.insert("VAR_B".to_string(), "value_b".to_string());

        let ctx = EnvContext::new();
        let content = generate_dotenv_content(&vars, &ctx);

        assert!(content.contains(JARVY_MARKER));
        assert!(content.contains("VAR_A=value_a"));
        assert!(content.contains("VAR_B=value_b"));
    }

    #[test]
    fn test_generate_dotenv_new_file() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".env");

        let mut vars = HashMap::new();
        vars.insert("TEST_VAR".to_string(), "test_value".to_string());

        let ctx = EnvContext::new();
        let config = DotenvConfig::default();

        let result = generate_dotenv(&path, &vars, &ctx, &config);
        assert!(result.is_ok());

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains(JARVY_MARKER));
        assert!(content.contains("TEST_VAR=test_value"));
    }

    #[test]
    fn test_generate_dotenv_backup() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".env");

        // Create existing Jarvy .env file
        fs::write(&path, format!("{}\nOLD_VAR=old", JARVY_MARKER)).unwrap();

        let mut vars = HashMap::new();
        vars.insert("NEW_VAR".to_string(), "new_value".to_string());

        let ctx = EnvContext::new();
        let config = DotenvConfig {
            backup: true,
            ..Default::default()
        };

        let result = generate_dotenv(&path, &vars, &ctx, &config);
        assert!(result.is_ok());

        // Check backup exists
        let backup_path = dir.path().join(".env.backup");
        assert!(backup_path.exists());
        let backup_content = fs::read_to_string(&backup_path).unwrap();
        assert!(backup_content.contains("OLD_VAR=old"));
    }

    #[test]
    fn test_generate_dotenv_non_jarvy_file() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".env");

        // Create existing non-Jarvy .env file
        fs::write(&path, "# My custom env file\nCUSTOM_VAR=value").unwrap();

        let mut vars = HashMap::new();
        vars.insert("NEW_VAR".to_string(), "new_value".to_string());

        let ctx = EnvContext::new();
        let config = DotenvConfig::default();

        let result = generate_dotenv(&path, &vars, &ctx, &config);
        assert!(matches!(result, Err(DotenvError::ExistingNonJarvyFile)));
    }

    #[test]
    fn test_generate_dotenv_force_overwrite() {
        let dir = tempdir().unwrap();
        let path = dir.path().join(".env");

        // Create existing non-Jarvy .env file
        fs::write(&path, "# My custom env file\nCUSTOM_VAR=value").unwrap();

        let mut vars = HashMap::new();
        vars.insert("NEW_VAR".to_string(), "new_value".to_string());

        let ctx = EnvContext::new();
        let config = DotenvConfig {
            force: true,
            backup: true,
            ..Default::default()
        };

        let result = generate_dotenv(&path, &vars, &ctx, &config);
        assert!(result.is_ok());

        let content = fs::read_to_string(&path).unwrap();
        assert!(content.contains(JARVY_MARKER));
        assert!(content.contains("NEW_VAR=new_value"));
    }

    #[test]
    fn test_variable_expansion_in_dotenv() {
        let ctx = EnvContext::new().with_var("PROJECT", "myapp");

        let mut vars = HashMap::new();
        vars.insert("APP_NAME".to_string(), "${PROJECT}".to_string());

        let content = generate_dotenv_content(&vars, &ctx);
        assert!(content.contains("APP_NAME=myapp"));
    }
}