use anyhow::Result;
use std::path::Path;
use tracing::debug;
pub fn load_dotenv(project_dir: &Path) -> Result<Vec<(String, String)>> {
let mut loaded = Vec::new();
let rch_env_path = project_dir.join(".rch.env");
if rch_env_path.exists() {
debug!("Parsing .rch.env from {:?}", rch_env_path);
let vars = parse_env_file(&rch_env_path)?;
for (key, value) in vars {
if std::env::var(&key).is_err() {
debug!(" {} = {} (from .rch.env)", key, value);
loaded.push((key, value));
}
}
}
let dotenv_path = project_dir.join(".env");
if dotenv_path.exists() {
debug!("Parsing .env from {:?}", dotenv_path);
for (key, value) in parse_env_file(&dotenv_path)? {
if key.starts_with("RCH_") && std::env::var(&key).is_err() {
debug!(" {} = {} (from .env)", key, value);
loaded.push((key, value));
}
}
}
Ok(loaded)
}
fn parse_env_file(path: &Path) -> Result<Vec<(String, String)>> {
let content = std::fs::read_to_string(path)?;
let mut vars = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, value)) = line.split_once('=') {
let key = key.trim().to_string();
let value = value.trim();
let value = if value.len() >= 2
&& ((value.starts_with('"') && value.ends_with('"'))
|| (value.starts_with('\'') && value.ends_with('\'')))
{
value[1..value.len() - 1].to_string()
} else {
value.split('#').next().unwrap_or("").trim().to_string()
};
if key.is_empty() {
debug!("Skipping empty key at line {}", line_num + 1);
continue;
}
vars.push((key, value));
}
}
Ok(vars)
}
pub fn has_dotenv_files(project_dir: &Path) -> bool {
project_dir.join(".rch.env").exists() || project_dir.join(".env").exists()
}
#[cfg(test)]
#[allow(unsafe_code)]
mod tests {
use super::*;
use crate::config::env_test_lock;
use std::fs;
use tempfile::TempDir;
fn env_guard() -> std::sync::MutexGuard<'static, ()> {
env_test_lock()
}
fn set_env(key: &str, value: &str) {
unsafe { std::env::set_var(key, value) };
}
fn remove_env(key: &str) {
unsafe { std::env::remove_var(key) };
}
#[test]
fn test_parse_env_file_basic() {
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(&env_file, "KEY=value\nANOTHER=123").unwrap();
let vars = parse_env_file(&env_file).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars[0], ("KEY".to_string(), "value".to_string()));
assert_eq!(vars[1], ("ANOTHER".to_string(), "123".to_string()));
}
#[test]
fn test_parse_env_file_comments() {
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(
&env_file,
"# This is a comment\nKEY=value\n# Another comment\n",
)
.unwrap();
let vars = parse_env_file(&env_file).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0], ("KEY".to_string(), "value".to_string()));
}
#[test]
fn test_parse_env_file_quoted() {
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(&env_file, "DOUBLE=\"hello world\"\nSINGLE='single quoted'").unwrap();
let vars = parse_env_file(&env_file).unwrap();
assert_eq!(vars.len(), 2);
assert_eq!(vars[0], ("DOUBLE".to_string(), "hello world".to_string()));
assert_eq!(vars[1], ("SINGLE".to_string(), "single quoted".to_string()));
}
#[test]
fn test_parse_env_file_lone_quote_does_not_panic() {
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(&env_file, "LONE_DOUBLE=\"\nLONE_SINGLE='\nEMPTY=").unwrap();
let vars = parse_env_file(&env_file).unwrap();
assert_eq!(vars.len(), 3);
assert_eq!(vars[0], ("LONE_DOUBLE".to_string(), "\"".to_string()));
assert_eq!(vars[1], ("LONE_SINGLE".to_string(), "'".to_string()));
assert_eq!(vars[2], ("EMPTY".to_string(), String::new()));
}
#[test]
fn test_parse_env_file_inline_comments() {
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(&env_file, "KEY=value # this is a comment").unwrap();
let vars = parse_env_file(&env_file).unwrap();
assert_eq!(vars.len(), 1);
assert_eq!(vars[0], ("KEY".to_string(), "value".to_string()));
}
#[test]
fn test_load_dotenv_only_rch_vars() {
let _guard = env_guard();
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".env");
fs::write(
&env_file,
"RCH_LOG_LEVEL=debug\nOTHER_VAR=ignored\nRCH_ENABLED=true",
)
.unwrap();
remove_env("RCH_LOG_LEVEL");
remove_env("RCH_ENABLED");
let loaded = load_dotenv(tmp.path()).unwrap();
let keys: Vec<_> = loaded.iter().map(|(k, _)| k.as_str()).collect();
assert!(keys.contains(&"RCH_LOG_LEVEL"));
assert!(keys.contains(&"RCH_ENABLED"));
assert!(!keys.contains(&"OTHER_VAR"));
remove_env("RCH_LOG_LEVEL");
remove_env("RCH_ENABLED");
}
#[test]
fn test_load_dotenv_no_override() {
let _guard = env_guard();
let tmp = TempDir::new().unwrap();
let env_file = tmp.path().join(".rch.env");
fs::write(&env_file, "RCH_PRESET=fromfile").unwrap();
set_env("RCH_PRESET", "original");
let loaded = load_dotenv(tmp.path()).unwrap();
assert_eq!(std::env::var("RCH_PRESET").unwrap(), "original");
assert!(!loaded.iter().any(|(k, _)| k == "RCH_PRESET"));
remove_env("RCH_PRESET");
}
}