1use std::collections::HashMap;
13use std::path::Path;
14
15use crate::{Error, Result};
16
17#[derive(Debug, Clone, Default)]
19pub struct DotenvConfig {
20 pub enabled: bool,
22 pub path: String,
24 pub override_env: bool,
26 pub extra_files: Vec<String>,
28}
29
30impl DotenvConfig {
31 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 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
68pub 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 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 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 let env_vars = interpolate_variables(env_vars);
98
99 Ok(env_vars)
100}
101
102pub 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
108pub 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 if line.is_empty() || line.starts_with('#') {
118 continue;
119 }
120
121 let line = line.strip_prefix("export ").unwrap_or(line);
123
124 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 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 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
152fn 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(); if let Some(end_pos) = find_unescaped_quote(&value, '"') {
161 return unescape_double_quoted(&value[..end_pos]);
162 }
163
164 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 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 unescape_double_quoted(&value)
179}
180
181fn 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(); if let Some(end_pos) = value.find('\'') {
190 return value[..end_pos].to_string();
191 }
192
193 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
207fn 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
229fn 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
257fn interpolate_variables(mut vars: HashMap<String, String>) -> HashMap<String, String> {
259 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
285fn 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 if chars.peek() == Some(&'{') {
294 chars.next(); let var_name: String = chars.by_ref().take_while(|&c| c != '}').collect();
296
297 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 } else {
305 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}