Skip to main content

pro_core/
dotenv.rs

1//! Dotenv support for loading environment variables from .env files
2//!
3//! Supports standard .env file format:
4//! - KEY=value
5//! - KEY="quoted value"
6//! - KEY='single quoted'
7//! - # comments
8//! - export KEY=value (optional export prefix)
9//! - Multiline values with quotes
10//! - Variable interpolation: ${VAR} or $VAR
11
12use std::collections::HashMap;
13use std::path::Path;
14
15use crate::{Error, Result};
16
17/// Configuration for dotenv loading
18#[derive(Debug, Clone, Default)]
19pub struct DotenvConfig {
20    /// Whether dotenv is enabled (default: true)
21    pub enabled: bool,
22    /// Path to .env file relative to project root (default: ".env")
23    pub path: String,
24    /// Whether to override existing environment variables (default: false)
25    pub override_env: bool,
26    /// Additional .env files to load (e.g., ".env.local", ".env.development")
27    pub extra_files: Vec<String>,
28}
29
30impl DotenvConfig {
31    /// Create default configuration
32    pub fn new() -> Self {
33        Self {
34            enabled: true,
35            path: ".env".to_string(),
36            override_env: false,
37            extra_files: vec![],
38        }
39    }
40
41    /// Parse configuration from TOML table
42    pub fn from_toml(table: &toml::Table) -> Self {
43        let mut config = Self::new();
44
45        if let Some(enabled) = table.get("enabled").and_then(|v| v.as_bool()) {
46            config.enabled = enabled;
47        }
48
49        if let Some(path) = table.get("path").and_then(|v| v.as_str()) {
50            config.path = path.to_string();
51        }
52
53        if let Some(override_env) = table.get("override").and_then(|v| v.as_bool()) {
54            config.override_env = override_env;
55        }
56
57        if let Some(extra) = table.get("extra_files").and_then(|v| v.as_array()) {
58            config.extra_files = extra
59                .iter()
60                .filter_map(|v| v.as_str().map(String::from))
61                .collect();
62        }
63
64        config
65    }
66}
67
68/// Load environment variables from a .env file
69pub fn load_dotenv(project_dir: &Path, config: &DotenvConfig) -> Result<HashMap<String, String>> {
70    let mut env_vars = HashMap::new();
71
72    if !config.enabled {
73        return Ok(env_vars);
74    }
75
76    // Load main .env file
77    let main_env_path = project_dir.join(&config.path);
78    if main_env_path.exists() {
79        let vars = parse_dotenv_file(&main_env_path)?;
80        for (key, value) in vars {
81            env_vars.insert(key, value);
82        }
83    }
84
85    // Load extra files in order (later files override earlier ones)
86    for extra_file in &config.extra_files {
87        let extra_path = project_dir.join(extra_file);
88        if extra_path.exists() {
89            let vars = parse_dotenv_file(&extra_path)?;
90            for (key, value) in vars {
91                env_vars.insert(key, value);
92            }
93        }
94    }
95
96    // Perform variable interpolation
97    let env_vars = interpolate_variables(env_vars);
98
99    Ok(env_vars)
100}
101
102/// Parse a .env file and return key-value pairs
103pub fn parse_dotenv_file(path: &Path) -> Result<HashMap<String, String>> {
104    let content = std::fs::read_to_string(path).map_err(Error::Io)?;
105    parse_dotenv(&content)
106}
107
108/// Parse dotenv content string
109pub fn parse_dotenv(content: &str) -> Result<HashMap<String, String>> {
110    let mut vars = HashMap::new();
111    let mut lines = content.lines().peekable();
112
113    while let Some(line) = lines.next() {
114        let line = line.trim();
115
116        // Skip empty lines and comments
117        if line.is_empty() || line.starts_with('#') {
118            continue;
119        }
120
121        // Handle export prefix
122        let line = line.strip_prefix("export ").unwrap_or(line);
123
124        // Find the = sign
125        let Some(eq_pos) = line.find('=') else {
126            continue;
127        };
128
129        let key = line[..eq_pos].trim().to_string();
130        let mut value = line[eq_pos + 1..].trim().to_string();
131
132        // Handle quoted values
133        if value.starts_with('"') {
134            value = parse_double_quoted(&value, &mut lines);
135        } else if value.starts_with('\'') {
136            value = parse_single_quoted(&value, &mut lines);
137        } else {
138            // Unquoted: remove inline comments
139            if let Some(comment_pos) = value.find(" #") {
140                value = value[..comment_pos].trim().to_string();
141            }
142        }
143
144        if !key.is_empty() {
145            vars.insert(key, value);
146        }
147    }
148
149    Ok(vars)
150}
151
152/// Parse a double-quoted value (supports escape sequences and multiline)
153fn parse_double_quoted(
154    first_line: &str,
155    lines: &mut std::iter::Peekable<std::str::Lines>,
156) -> String {
157    let mut value = first_line[1..].to_string(); // Remove opening quote
158
159    // Check if closed on same line
160    if let Some(end_pos) = find_unescaped_quote(&value, '"') {
161        return unescape_double_quoted(&value[..end_pos]);
162    }
163
164    // Multiline value
165    for line in lines.by_ref() {
166        value.push('\n');
167        value.push_str(line);
168
169        if let Some(end_pos) = find_unescaped_quote(line, '"') {
170            // Found closing quote
171            let total_len = value.len();
172            let trimmed = &value[..total_len - line.len() + end_pos];
173            return unescape_double_quoted(trimmed);
174        }
175    }
176
177    // No closing quote found, return as-is
178    unescape_double_quoted(&value)
179}
180
181/// Parse a single-quoted value (literal, no escape sequences)
182fn parse_single_quoted(
183    first_line: &str,
184    lines: &mut std::iter::Peekable<std::str::Lines>,
185) -> String {
186    let mut value = first_line[1..].to_string(); // Remove opening quote
187
188    // Check if closed on same line
189    if let Some(end_pos) = value.find('\'') {
190        return value[..end_pos].to_string();
191    }
192
193    // Multiline value
194    for line in lines.by_ref() {
195        value.push('\n');
196        value.push_str(line);
197
198        if let Some(end_pos) = line.find('\'') {
199            let total_len = value.len();
200            return value[..total_len - line.len() + end_pos].to_string();
201        }
202    }
203
204    value
205}
206
207/// Find an unescaped quote character
208fn find_unescaped_quote(s: &str, quote: char) -> Option<usize> {
209    let chars = s.chars().enumerate();
210    let mut escaped = false;
211
212    for (i, c) in chars {
213        if escaped {
214            escaped = false;
215            continue;
216        }
217        if c == '\\' {
218            escaped = true;
219            continue;
220        }
221        if c == quote {
222            return Some(i);
223        }
224    }
225
226    None
227}
228
229/// Unescape double-quoted string
230fn unescape_double_quoted(s: &str) -> String {
231    let mut result = String::with_capacity(s.len());
232    let mut chars = s.chars();
233
234    while let Some(c) = chars.next() {
235        if c == '\\' {
236            match chars.next() {
237                Some('n') => result.push('\n'),
238                Some('r') => result.push('\r'),
239                Some('t') => result.push('\t'),
240                Some('\\') => result.push('\\'),
241                Some('"') => result.push('"'),
242                Some('$') => result.push('$'),
243                Some(other) => {
244                    result.push('\\');
245                    result.push(other);
246                }
247                None => result.push('\\'),
248            }
249        } else {
250            result.push(c);
251        }
252    }
253
254    result
255}
256
257/// Interpolate ${VAR} and $VAR references
258fn interpolate_variables(mut vars: HashMap<String, String>) -> HashMap<String, String> {
259    // We need to handle the case where variables reference each other
260    // Do multiple passes until no more interpolation is possible
261    let max_passes = 10;
262
263    for _ in 0..max_passes {
264        let mut changed = false;
265
266        let keys: Vec<String> = vars.keys().cloned().collect();
267        for key in keys {
268            let value = vars.get(&key).cloned().unwrap_or_default();
269            let new_value = interpolate_value(&value, &vars);
270
271            if new_value != value {
272                vars.insert(key, new_value);
273                changed = true;
274            }
275        }
276
277        if !changed {
278            break;
279        }
280    }
281
282    vars
283}
284
285/// Interpolate a single value
286fn interpolate_value(value: &str, vars: &HashMap<String, String>) -> String {
287    let mut result = String::with_capacity(value.len());
288    let mut chars = value.chars().peekable();
289
290    while let Some(c) = chars.next() {
291        if c == '$' {
292            // Check for ${VAR} or $VAR
293            if chars.peek() == Some(&'{') {
294                chars.next(); // consume '{'
295                let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
296
297                // Look up in our vars first, then system env
298                if let Some(var_value) = vars.get(&var_name) {
299                    result.push_str(var_value);
300                } else if let Ok(env_value) = std::env::var(&var_name) {
301                    result.push_str(&env_value);
302                }
303                // If not found, just omit it (empty string)
304            } else {
305                // $VAR format - collect alphanumeric and underscore using peek
306                let mut var_name = String::new();
307                while let Some(&next_c) = chars.peek() {
308                    if next_c.is_alphanumeric() || next_c == '_' {
309                        var_name.push(next_c);
310                        chars.next();
311                    } else {
312                        break;
313                    }
314                }
315
316                if !var_name.is_empty() {
317                    if let Some(var_value) = vars.get(&var_name) {
318                        result.push_str(var_value);
319                    } else if let Ok(env_value) = std::env::var(&var_name) {
320                        result.push_str(&env_value);
321                    }
322                } else {
323                    result.push('$');
324                }
325            }
326        } else {
327            result.push(c);
328        }
329    }
330
331    result
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    #[test]
339    fn test_parse_simple() {
340        let content = r#"
341KEY=value
342ANOTHER=test
343"#;
344        let vars = parse_dotenv(content).unwrap();
345        assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
346        assert_eq!(vars.get("ANOTHER"), Some(&"test".to_string()));
347    }
348
349    #[test]
350    fn test_parse_with_comments() {
351        let content = r#"
352# This is a comment
353KEY=value # inline comment
354ANOTHER=test
355"#;
356        let vars = parse_dotenv(content).unwrap();
357        assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
358        assert_eq!(vars.get("ANOTHER"), Some(&"test".to_string()));
359    }
360
361    #[test]
362    fn test_parse_quoted() {
363        let content = r#"
364DOUBLE="hello world"
365SINGLE='hello world'
366WITH_HASH="value # not a comment"
367"#;
368        let vars = parse_dotenv(content).unwrap();
369        assert_eq!(vars.get("DOUBLE"), Some(&"hello world".to_string()));
370        assert_eq!(vars.get("SINGLE"), Some(&"hello world".to_string()));
371        assert_eq!(
372            vars.get("WITH_HASH"),
373            Some(&"value # not a comment".to_string())
374        );
375    }
376
377    #[test]
378    fn test_parse_export_prefix() {
379        let content = r#"
380export KEY=value
381export QUOTED="hello"
382"#;
383        let vars = parse_dotenv(content).unwrap();
384        assert_eq!(vars.get("KEY"), Some(&"value".to_string()));
385        assert_eq!(vars.get("QUOTED"), Some(&"hello".to_string()));
386    }
387
388    #[test]
389    fn test_parse_escape_sequences() {
390        let content = r#"
391NEWLINE="hello\nworld"
392TAB="hello\tworld"
393ESCAPED="hello\"world"
394"#;
395        let vars = parse_dotenv(content).unwrap();
396        assert_eq!(vars.get("NEWLINE"), Some(&"hello\nworld".to_string()));
397        assert_eq!(vars.get("TAB"), Some(&"hello\tworld".to_string()));
398        assert_eq!(vars.get("ESCAPED"), Some(&"hello\"world".to_string()));
399    }
400
401    #[test]
402    fn test_interpolation() {
403        let content = r#"
404BASE=/usr/local
405PATH_VAR=${BASE}/bin
406SIMPLE=$BASE/lib
407"#;
408        let vars = parse_dotenv(content).unwrap();
409        let vars = interpolate_variables(vars);
410        assert_eq!(vars.get("PATH_VAR"), Some(&"/usr/local/bin".to_string()));
411        assert_eq!(vars.get("SIMPLE"), Some(&"/usr/local/lib".to_string()));
412    }
413
414    #[test]
415    fn test_multiline_double_quoted() {
416        let content = r#"MULTI="line1
417line2
418line3""#;
419        let vars = parse_dotenv(content).unwrap();
420        assert_eq!(vars.get("MULTI"), Some(&"line1\nline2\nline3".to_string()));
421    }
422
423    #[test]
424    fn test_empty_value() {
425        let content = r#"
426EMPTY=
427QUOTED_EMPTY=""
428"#;
429        let vars = parse_dotenv(content).unwrap();
430        assert_eq!(vars.get("EMPTY"), Some(&"".to_string()));
431        assert_eq!(vars.get("QUOTED_EMPTY"), Some(&"".to_string()));
432    }
433
434    #[test]
435    fn test_dotenv_config_from_toml() {
436        let toml_str = r#"
437enabled = true
438path = ".env.local"
439override = true
440extra_files = [".env.development", ".env.local"]
441"#;
442        let table: toml::Table = toml::from_str(toml_str).unwrap();
443        let config = DotenvConfig::from_toml(&table);
444
445        assert!(config.enabled);
446        assert_eq!(config.path, ".env.local");
447        assert!(config.override_env);
448        assert_eq!(config.extra_files, vec![".env.development", ".env.local"]);
449    }
450}