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;
#[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),
}
const JARVY_MARKER: &str = "# Generated by Jarvy";
#[derive(Debug, Clone)]
pub struct DotenvConfig {
pub backup: bool,
pub force: bool,
pub add_to_gitignore: bool,
}
impl Default for DotenvConfig {
fn default() -> Self {
Self {
backup: true,
force: false,
add_to_gitignore: false,
}
}
}
pub fn generate_dotenv(
path: &Path,
vars: &HashMap<String, String>,
ctx: &EnvContext,
config: &DotenvConfig,
) -> Result<(), DotenvError> {
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);
}
if config.backup {
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
))
})?;
}
}
let content = generate_dotenv_content(vars, ctx);
let mut file = fs::File::create(path)?;
file.write_all(content.as_bytes())?;
if config.add_to_gitignore {
add_to_gitignore(path)?;
}
telemetry::env_dotenv_generated(vars.len(), 0);
Ok(())
}
pub fn generate_dotenv_content(vars: &HashMap<String, String>, ctx: &EnvContext) -> String {
let mut lines = Vec::new();
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());
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"
}
fn format_value(value: &str) -> String {
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 {
let escaped = value.replace('\\', "\\\\").replace('"', "\\\"");
format!("\"{}\"", escaped)
} else {
value.to_string()
}
}
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()
};
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(()); }
}
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(())
}
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");
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());
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");
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");
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"));
}
}