use crate::error::Result;
#[cfg(unix)]
use std::io::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Env {
entries: Vec<(String, String)>,
path: PathBuf,
}
impl Env {
pub fn load(path: impl AsRef<Path>) -> Result<Self> {
let path = path.as_ref();
let contents = std::fs::read_to_string(path)?;
let mut entries = Vec::new();
for line in contents.lines() {
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 = parse_env_value(value.trim());
entries.push((key, value));
}
}
Ok(Self {
entries,
path: path.to_path_buf(),
})
}
pub fn from_pairs(pairs: Vec<(String, String)>, path: PathBuf) -> Self {
Self {
entries: pairs,
path,
}
}
pub fn save(&self) -> Result<()> {
let content = self.to_env_string();
#[cfg(unix)]
{
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
let mut file = std::fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.mode(0o600)
.open(&self.path)?;
file.write_all(content.as_bytes())?;
file.flush()?;
std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600))?;
}
#[cfg(not(unix))]
{
std::fs::write(&self.path, content)?;
}
Ok(())
}
pub fn get(&self, key: &str) -> Option<&str> {
self.entries
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.as_str())
}
pub fn entries(&self) -> &[(String, String)] {
&self.entries
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn path(&self) -> &Path {
&self.path
}
fn to_env_string(&self) -> String {
let mut output = String::new();
for (key, value) in &self.entries {
if needs_quotes(value) {
output.push_str(&format!("{}=\"{}\"\n", key, escape_env_value(value)));
} else {
output.push_str(&format!("{}={}\n", key, value));
}
}
output
}
}
fn parse_env_value(raw: &str) -> String {
if raw.len() >= 2 && raw.starts_with('"') && raw.ends_with('"') {
return unescape_double_quoted(&raw[1..raw.len() - 1]);
}
if raw.len() >= 2 && raw.starts_with('\'') && raw.ends_with('\'') {
return raw[1..raw.len() - 1].to_string();
}
raw.to_string()
}
fn unescape_double_quoted(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(ch) = chars.next() {
if ch != '\\' {
out.push(ch);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('r') => out.push('\r'),
Some('"') => out.push('"'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
fn needs_quotes(value: &str) -> bool {
value.is_empty()
|| value.chars().any(|ch| ch.is_whitespace())
|| value.contains('#')
|| value.contains('=')
|| value.contains('"')
|| value.contains('\'')
|| value.contains('\\')
}
fn escape_env_value(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for ch in value.chars() {
match ch {
'\\' => escaped.push_str("\\\\"),
'"' => escaped.push_str("\\\""),
'\n' => escaped.push_str("\\n"),
'\r' => escaped.push_str("\\r"),
_ => escaped.push(ch),
}
}
escaped
}
impl std::fmt::Display for Env {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.to_env_string())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_env_load_and_entries() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let content = "API_KEY=secret123\nDB_URL=postgres://localhost/db\n";
fs::write(&path, content).unwrap();
let env = Env::load(&path).unwrap();
assert_eq!(env.len(), 2);
assert!(!env.is_empty());
assert_eq!(env.entries().len(), 2);
assert_eq!(env.path(), path.as_path());
}
#[test]
fn test_env_from_pairs() {
let pairs = vec![
("KEY1".to_string(), "value1".to_string()),
("KEY2".to_string(), "value2".to_string()),
];
let path = PathBuf::from(".env");
let env = Env::from_pairs(pairs, path.clone());
assert_eq!(env.len(), 2);
assert_eq!(env.path(), path.as_path());
}
#[test]
fn test_env_get() {
let pairs = vec![
("API_KEY".to_string(), "secret123".to_string()),
("DB_URL".to_string(), "postgres://".to_string()),
];
let env = Env::from_pairs(pairs, PathBuf::from(".env"));
assert_eq!(env.get("API_KEY"), Some("secret123"));
assert_eq!(env.get("DB_URL"), Some("postgres://"));
assert_eq!(env.get("NONEXISTENT"), None);
}
#[test]
fn test_env_display() {
let pairs = vec![
("SIMPLE".to_string(), "value".to_string()),
("WITH_SPACE".to_string(), "value with spaces".to_string()),
];
let env = Env::from_pairs(pairs, PathBuf::from(".env"));
let output = format!("{}", env);
assert!(output.contains("SIMPLE=value\n"));
assert!(output.contains("WITH_SPACE=\"value with spaces\"\n"));
}
#[test]
fn test_env_handles_comments() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let content =
"# This is a comment\nAPI_KEY=secret\n# Another comment\nDB_URL=postgres://\n";
fs::write(&path, content).unwrap();
let env = Env::load(&path).unwrap();
assert_eq!(env.len(), 2);
assert_eq!(env.get("API_KEY"), Some("secret"));
assert_eq!(env.get("DB_URL"), Some("postgres://"));
}
#[test]
fn test_env_handles_quotes() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let content = "QUOTED=\"value in quotes\"\nSINGLE='single quotes'\nNONE=no quotes\n";
fs::write(&path, content).unwrap();
let env = Env::load(&path).unwrap();
assert_eq!(env.get("QUOTED"), Some("value in quotes"));
assert_eq!(env.get("SINGLE"), Some("single quotes"));
assert_eq!(env.get("NONE"), Some("no quotes"));
}
#[test]
fn test_env_unescapes_double_quoted_values() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let content = "ESCAPED=\"line1\\nline2\\\"quoted\\\"\\\\tail\"\n";
fs::write(&path, content).unwrap();
let env = Env::load(&path).unwrap();
assert_eq!(env.get("ESCAPED"), Some("line1\nline2\"quoted\"\\tail"));
}
#[test]
fn test_env_save_roundtrip() {
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let pairs = vec![
("KEY1".to_string(), "value1".to_string()),
("KEY2".to_string(), "value with space".to_string()),
];
let env = Env::from_pairs(pairs, path.clone());
env.save().unwrap();
let loaded = Env::load(&path).unwrap();
assert_eq!(loaded.len(), 2);
assert_eq!(loaded.get("KEY1"), Some("value1"));
assert_eq!(loaded.get("KEY2"), Some("value with space"));
}
#[test]
fn test_env_display_escapes_special_chars() {
let pairs = vec![(
"SPECIAL".to_string(),
"line1\nline2 \"quoted\" \\ tail".to_string(),
)];
let env = Env::from_pairs(pairs, PathBuf::from(".env"));
let output = format!("{}", env);
assert!(output.contains("SPECIAL=\"line1\\nline2 \\\"quoted\\\" \\\\ tail\""));
}
#[cfg(unix)]
#[test]
fn test_env_save_sets_secure_permissions() {
use std::os::unix::fs::PermissionsExt;
let tmp = TempDir::new().unwrap();
let path = tmp.path().join(".env");
let env = Env::from_pairs(vec![("KEY".to_string(), "value".to_string())], path.clone());
env.save().unwrap();
let mode = fs::metadata(path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn test_env_empty() {
let env = Env::from_pairs(vec![], PathBuf::from(".env"));
assert!(env.is_empty());
assert_eq!(env.len(), 0);
assert_eq!(env.entries().len(), 0);
}
}