1use std::collections::{HashMap, HashSet};
22use std::fs;
23use std::path::Path;
24use std::str::FromStr;
25
26#[derive(Debug)]
28pub enum DotEnvError {
29 Io(std::io::Error),
31 Parse {
33 line: usize,
35 message: String,
37 },
38 MissingVars(Vec<String>),
40 TypeConversion {
42 key: String,
44 expected: &'static str,
46 value: String,
48 },
49 InterpolationError {
51 key: String,
53 references: String,
55 },
56}
57
58impl std::fmt::Display for DotEnvError {
59 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60 match self {
61 DotEnvError::Io(err) => write!(f, "I/O error: {err}"),
62 DotEnvError::Parse { line, message } => {
63 write!(f, "parse error at line {line}: {message}")
64 }
65 DotEnvError::MissingVars(vars) => {
66 write!(f, "missing required variables: {}", vars.join(", "))
67 }
68 DotEnvError::TypeConversion {
69 key,
70 expected,
71 value,
72 } => {
73 write!(
74 f,
75 "cannot convert {key}={value:?} to type {expected}"
76 )
77 }
78 DotEnvError::InterpolationError { key, references } => {
79 write!(
80 f,
81 "circular reference resolving {key}: references {references}"
82 )
83 }
84 }
85 }
86}
87
88impl std::error::Error for DotEnvError {
89 fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
90 match self {
91 DotEnvError::Io(err) => Some(err),
92 _ => None,
93 }
94 }
95}
96
97impl From<std::io::Error> for DotEnvError {
98 fn from(err: std::io::Error) -> Self {
99 DotEnvError::Io(err)
100 }
101}
102
103fn parse_env_content(content: &str) -> Result<Vec<(String, String)>, DotEnvError> {
114 let mut pairs = Vec::new();
115
116 for (line_idx, raw_line) in content.lines().enumerate() {
117 let line = raw_line.trim();
118
119 if line.is_empty() || line.starts_with('#') {
121 continue;
122 }
123
124 let line = if let Some(rest) = line.strip_prefix("export ") {
126 rest.trim_start()
127 } else {
128 line
129 };
130
131 let eq_pos = match line.find('=') {
133 Some(pos) => pos,
134 None => continue, };
136
137 let key = line[..eq_pos].trim().to_string();
138 if key.is_empty() {
139 continue;
140 }
141
142 let raw_value = &line[eq_pos + 1..];
143 let value = parse_value(raw_value, line_idx + 1)?;
144
145 pairs.push((key, value));
146 }
147
148 Ok(pairs)
149}
150
151fn parse_value(raw: &str, line_number: usize) -> Result<String, DotEnvError> {
153 let trimmed = raw.trim_start();
154
155 if trimmed.is_empty() {
156 return Ok(String::new());
157 }
158
159 if trimmed.starts_with('"') {
160 parse_double_quoted(trimmed, line_number)
162 } else if trimmed.starts_with('\'') {
163 parse_single_quoted(trimmed, line_number)
165 } else {
166 let value = if let Some(comment_pos) = find_inline_comment(trimmed) {
168 trimmed[..comment_pos].trim_end()
169 } else {
170 trimmed.trim_end()
171 };
172 Ok(value.to_string())
173 }
174}
175
176fn find_inline_comment(s: &str) -> Option<usize> {
178 for (i, c) in s.char_indices() {
179 if c == '#' && (i == 0 || s.as_bytes()[i - 1] == b' ') {
180 return Some(i);
181 }
182 }
183 None
184}
185
186fn parse_double_quoted(s: &str, line_number: usize) -> Result<String, DotEnvError> {
188 let inner = &s[1..]; let mut result = String::new();
190 let mut chars = inner.chars();
191
192 loop {
193 match chars.next() {
194 None => {
195 return Err(DotEnvError::Parse {
196 line: line_number,
197 message: "unterminated double-quoted string".to_string(),
198 });
199 }
200 Some('"') => {
201 return Ok(result);
203 }
204 Some('\\') => {
205 match chars.next() {
206 Some('n') => result.push('\n'),
207 Some('t') => result.push('\t'),
208 Some('\\') => result.push('\\'),
209 Some('"') => result.push('"'),
210 Some(c) => {
211 result.push('\\');
213 result.push(c);
214 }
215 None => {
216 return Err(DotEnvError::Parse {
217 line: line_number,
218 message: "unterminated escape sequence".to_string(),
219 });
220 }
221 }
222 }
223 Some(c) => result.push(c),
224 }
225 }
226}
227
228fn parse_single_quoted(s: &str, line_number: usize) -> Result<String, DotEnvError> {
230 let inner = &s[1..]; match inner.find('\'') {
232 Some(end) => Ok(inner[..end].to_string()),
233 None => Err(DotEnvError::Parse {
234 line: line_number,
235 message: "unterminated single-quoted string".to_string(),
236 }),
237 }
238}
239
240fn interpolate(
245 pairs: Vec<(String, String)>,
246) -> Result<Vec<(String, String)>, DotEnvError> {
247 let raw_map: HashMap<String, String> = pairs.iter().cloned().collect();
248 let keys: Vec<String> = pairs.iter().map(|(k, _)| k.clone()).collect();
249 let mut resolved: HashMap<String, String> = HashMap::new();
250
251 for key in &keys {
252 if !resolved.contains_key(key) {
253 resolve_key(key, &raw_map, &mut resolved, &mut HashSet::new())?;
254 }
255 }
256
257 let result: Vec<(String, String)> = pairs
259 .into_iter()
260 .map(|(k, _)| {
261 let v = resolved.get(&k).cloned().unwrap_or_default();
262 (k, v)
263 })
264 .collect();
265
266 Ok(result)
267}
268
269fn resolve_key(
271 key: &str,
272 raw_map: &HashMap<String, String>,
273 resolved: &mut HashMap<String, String>,
274 in_progress: &mut HashSet<String>,
275) -> Result<String, DotEnvError> {
276 if let Some(val) = resolved.get(key) {
277 return Ok(val.clone());
278 }
279
280 if in_progress.contains(key) {
281 return Err(DotEnvError::InterpolationError {
282 key: key.to_string(),
283 references: key.to_string(),
284 });
285 }
286
287 let raw_value = match raw_map.get(key) {
288 Some(v) => v.clone(),
289 None => {
290 return Ok(std::env::var(key).unwrap_or_default());
292 }
293 };
294
295 in_progress.insert(key.to_string());
296
297 let result = expand_references(&raw_value, key, raw_map, resolved, in_progress)?;
298
299 in_progress.remove(key);
300 resolved.insert(key.to_string(), result.clone());
301 Ok(result)
302}
303
304fn expand_references(
306 value: &str,
307 parent_key: &str,
308 raw_map: &HashMap<String, String>,
309 resolved: &mut HashMap<String, String>,
310 in_progress: &mut HashSet<String>,
311) -> Result<String, DotEnvError> {
312 let mut result = String::new();
313 let mut chars = value.chars().peekable();
314
315 while let Some(c) = chars.next() {
316 if c == '$' && chars.peek() == Some(&'{') {
317 chars.next(); let mut ref_name = String::new();
319 let mut found_close = false;
320 for c2 in chars.by_ref() {
321 if c2 == '}' {
322 found_close = true;
323 break;
324 }
325 ref_name.push(c2);
326 }
327 if !found_close {
328 result.push('$');
330 result.push('{');
331 result.push_str(&ref_name);
332 continue;
333 }
334 let resolved_val =
336 resolve_key(&ref_name, raw_map, resolved, in_progress).map_err(|_| {
337 DotEnvError::InterpolationError {
338 key: parent_key.to_string(),
339 references: ref_name.clone(),
340 }
341 })?;
342 result.push_str(&resolved_val);
343 } else {
344 result.push(c);
345 }
346 }
347
348 Ok(result)
349}
350
351pub struct DotEnv {
356 vars: HashMap<String, String>,
357}
358
359impl DotEnv {
360 pub fn load() -> Result<DotEnv, DotEnvError> {
366 DotEnv::load_from(".env")
367 }
368
369 pub fn load_from(path: impl AsRef<Path>) -> Result<DotEnv, DotEnvError> {
375 let content = fs::read_to_string(path.as_ref())?;
376 DotEnv::from_str(&content)
377 }
378
379 pub fn load_layered(paths: &[impl AsRef<Path>]) -> Result<DotEnv, DotEnvError> {
387 let mut all_pairs: Vec<(String, String)> = Vec::new();
388
389 for path in paths {
390 let path = path.as_ref();
391 if !path.exists() {
392 continue;
393 }
394 let content = fs::read_to_string(path)?;
395 let pairs = parse_env_content(&content)?;
396 all_pairs.extend(pairs);
397 }
398
399 let resolved = interpolate(all_pairs)?;
400 let vars: HashMap<String, String> = resolved.into_iter().collect();
401 Ok(DotEnv { vars })
402 }
403
404 fn from_str(content: &str) -> Result<DotEnv, DotEnvError> {
406 let pairs = parse_env_content(content)?;
407 let resolved = interpolate(pairs)?;
408 let vars: HashMap<String, String> = resolved.into_iter().collect();
409 Ok(DotEnv { vars })
410 }
411
412 pub fn get(&self, key: &str) -> Option<&str> {
414 self.vars.get(key).map(|s| s.as_str())
415 }
416
417 pub fn get_or(&self, key: &str, default: &str) -> String {
419 self.vars
420 .get(key)
421 .cloned()
422 .unwrap_or_else(|| default.to_string())
423 }
424
425 pub fn get_as<T: FromStr>(&self, key: &str) -> Result<T, DotEnvError> {
432 let value = self.vars.get(key).ok_or_else(|| {
433 DotEnvError::MissingVars(vec![key.to_string()])
434 })?;
435 value.parse::<T>().map_err(|_| DotEnvError::TypeConversion {
436 key: key.to_string(),
437 expected: std::any::type_name::<T>(),
438 value: value.clone(),
439 })
440 }
441
442 pub fn get_bool(&self, key: &str) -> Result<bool, DotEnvError> {
451 let value = self.vars.get(key).ok_or_else(|| {
452 DotEnvError::MissingVars(vec![key.to_string()])
453 })?;
454 match value.to_lowercase().as_str() {
455 "true" | "1" | "yes" => Ok(true),
456 "false" | "0" | "no" => Ok(false),
457 _ => Err(DotEnvError::TypeConversion {
458 key: key.to_string(),
459 expected: "bool",
460 value: value.clone(),
461 }),
462 }
463 }
464
465 pub fn get_list(&self, key: &str, separator: char) -> Vec<String> {
469 match self.vars.get(key) {
470 Some(value) => value
471 .split(separator)
472 .map(|s| s.trim().to_string())
473 .collect(),
474 None => Vec::new(),
475 }
476 }
477
478 pub fn require(&self, keys: &[&str]) -> Result<(), DotEnvError> {
484 let missing: Vec<String> = keys
485 .iter()
486 .filter(|k| !self.vars.contains_key(**k))
487 .map(|k| k.to_string())
488 .collect();
489
490 if missing.is_empty() {
491 Ok(())
492 } else {
493 Err(DotEnvError::MissingVars(missing))
494 }
495 }
496
497 pub fn apply(&self) {
499 for (key, value) in &self.vars {
500 unsafe {
504 std::env::set_var(key, value);
505 }
506 }
507 }
508
509 pub fn keys(&self) -> impl Iterator<Item = &str> {
511 self.vars.keys().map(|s| s.as_str())
512 }
513
514 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
516 self.vars.iter().map(|(k, v)| (k.as_str(), v.as_str()))
517 }
518}
519
520pub fn load() -> Result<DotEnv, DotEnvError> {
528 DotEnv::load()
529}
530
531pub fn load_and_apply() -> Result<(), DotEnvError> {
537 let env = DotEnv::load()?;
538 env.apply();
539 Ok(())
540}
541
542#[cfg(test)]
543mod tests {
544 use super::*;
545 use std::io::Write;
546
547 fn parse(content: &str) -> DotEnv {
548 DotEnv::from_str(content).expect("failed to parse")
549 }
550
551 #[test]
552 fn test_basic_key_value() {
553 let env = parse("FOO=bar\nBAZ=qux");
554 assert_eq!(env.get("FOO"), Some("bar"));
555 assert_eq!(env.get("BAZ"), Some("qux"));
556 }
557
558 #[test]
559 fn test_double_quoted_value() {
560 let env = parse(r#"GREETING="hello world""#);
561 assert_eq!(env.get("GREETING"), Some("hello world"));
562 }
563
564 #[test]
565 fn test_single_quoted_value() {
566 let env = parse("PATH_VAR='/usr/local/bin'");
567 assert_eq!(env.get("PATH_VAR"), Some("/usr/local/bin"));
568 }
569
570 #[test]
571 fn test_escape_sequences_in_double_quotes() {
572 let env = parse(r#"MSG="line1\nline2\ttab\\slash\"quote""#);
573 assert_eq!(env.get("MSG"), Some("line1\nline2\ttab\\slash\"quote"));
574 }
575
576 #[test]
577 fn test_single_quotes_no_escapes() {
578 let env = parse(r"LITERAL='hello\nworld'");
579 assert_eq!(env.get("LITERAL"), Some(r"hello\nworld"));
580 }
581
582 #[test]
583 fn test_full_line_comment() {
584 let env = parse("# this is a comment\nKEY=value");
585 assert_eq!(env.get("KEY"), Some("value"));
586 assert!(env.vars.len() == 1);
587 }
588
589 #[test]
590 fn test_inline_comment() {
591 let env = parse("KEY=value # this is a comment");
592 assert_eq!(env.get("KEY"), Some("value"));
593 }
594
595 #[test]
596 fn test_export_prefix() {
597 let env = parse("export SECRET=hunter2");
598 assert_eq!(env.get("SECRET"), Some("hunter2"));
599 }
600
601 #[test]
602 fn test_empty_value() {
603 let env = parse("EMPTY=\nALSO_EMPTY=");
604 assert_eq!(env.get("EMPTY"), Some(""));
605 assert_eq!(env.get("ALSO_EMPTY"), Some(""));
606 }
607
608 #[test]
609 fn test_lines_without_equals_skipped() {
610 let env = parse("VALID=yes\nINVALID_LINE\nALSO_VALID=true");
611 assert_eq!(env.vars.len(), 2);
612 assert_eq!(env.get("VALID"), Some("yes"));
613 assert_eq!(env.get("ALSO_VALID"), Some("true"));
614 }
615
616 #[test]
617 fn test_variable_interpolation_simple() {
618 let env = parse("HOST=localhost\nURL=http://${HOST}/api");
619 assert_eq!(env.get("URL"), Some("http://localhost/api"));
620 }
621
622 #[test]
623 fn test_variable_interpolation_nested() {
624 let env = parse("A=hello\nB=${A}_world\nC=${B}!");
625 assert_eq!(env.get("C"), Some("hello_world!"));
626 }
627
628 #[test]
629 fn test_variable_interpolation_fallback_to_env() {
630 unsafe { std::env::set_var("DOTENV_TEST_FALLBACK", "from_env"); }
632 let env = parse("REF=${DOTENV_TEST_FALLBACK}");
633 assert_eq!(env.get("REF"), Some("from_env"));
634 unsafe { std::env::remove_var("DOTENV_TEST_FALLBACK"); }
635 }
636
637 #[test]
638 fn test_dollar_without_braces_not_expanded() {
639 let env = parse("VAL=hello\nREF=$VAL");
640 assert_eq!(env.get("REF"), Some("$VAL"));
641 }
642
643 #[test]
644 fn test_circular_reference_detection() {
645 let result = DotEnv::from_str("A=${B}\nB=${A}");
646 assert!(result.is_err());
647 if let Err(DotEnvError::InterpolationError { .. }) = result {
648 } else {
650 panic!("expected InterpolationError");
651 }
652 }
653
654 #[test]
655 fn test_layered_loading() {
656 let dir = std::env::temp_dir().join("dotenv_test_layered");
657 let _ = fs::create_dir_all(&dir);
658
659 let base_path = dir.join("base.env");
660 let override_path = dir.join("override.env");
661
662 let mut f1 = fs::File::create(&base_path).unwrap();
663 writeln!(f1, "A=base_a\nB=base_b").unwrap();
664
665 let mut f2 = fs::File::create(&override_path).unwrap();
666 writeln!(f2, "B=override_b\nC=new_c").unwrap();
667
668 let env = DotEnv::load_layered(&[&base_path, &override_path]).unwrap();
669 assert_eq!(env.get("A"), Some("base_a"));
670 assert_eq!(env.get("B"), Some("override_b"));
671 assert_eq!(env.get("C"), Some("new_c"));
672
673 let _ = fs::remove_dir_all(&dir);
674 }
675
676 #[test]
677 fn test_get_as_u16() {
678 let env = parse("PORT=8080");
679 let port: u16 = env.get_as("PORT").unwrap();
680 assert_eq!(port, 8080);
681 }
682
683 #[test]
684 fn test_get_as_invalid_type() {
685 let env = parse("PORT=not_a_number");
686 let result: Result<u16, _> = env.get_as("PORT");
687 assert!(result.is_err());
688 }
689
690 #[test]
691 fn test_get_bool_variants() {
692 let env = parse("A=true\nB=false\nC=1\nD=0\nE=yes\nF=no\nG=TRUE\nH=Yes");
693 assert!(env.get_bool("A").unwrap());
694 assert!(!env.get_bool("B").unwrap());
695 assert!(env.get_bool("C").unwrap());
696 assert!(!env.get_bool("D").unwrap());
697 assert!(env.get_bool("E").unwrap());
698 assert!(!env.get_bool("F").unwrap());
699 assert!(env.get_bool("G").unwrap());
700 assert!(env.get_bool("H").unwrap());
701 }
702
703 #[test]
704 fn test_get_bool_invalid() {
705 let env = parse("VAL=maybe");
706 assert!(env.get_bool("VAL").is_err());
707 }
708
709 #[test]
710 fn test_require_all_present() {
711 let env = parse("A=1\nB=2\nC=3");
712 assert!(env.require(&["A", "B", "C"]).is_ok());
713 }
714
715 #[test]
716 fn test_require_missing() {
717 let env = parse("A=1");
718 let result = env.require(&["A", "B", "C"]);
719 match result {
720 Err(DotEnvError::MissingVars(vars)) => {
721 assert!(vars.contains(&"B".to_string()));
722 assert!(vars.contains(&"C".to_string()));
723 assert_eq!(vars.len(), 2);
724 }
725 _ => panic!("expected MissingVars error"),
726 }
727 }
728
729 #[test]
730 fn test_get_list() {
731 let env = parse("HOSTS=a,b,c");
732 let list = env.get_list("HOSTS", ',');
733 assert_eq!(list, vec!["a", "b", "c"]);
734 }
735
736 #[test]
737 fn test_get_list_with_spaces() {
738 let env = parse("ITEMS=one , two , three");
739 let list = env.get_list("ITEMS", ',');
740 assert_eq!(list, vec!["one", "two", "three"]);
741 }
742
743 #[test]
744 fn test_get_list_missing_key() {
745 let env = parse("OTHER=val");
746 let list = env.get_list("MISSING", ',');
747 assert!(list.is_empty());
748 }
749
750 #[test]
751 fn test_get_or_default() {
752 let env = parse("A=hello");
753 assert_eq!(env.get_or("A", "default"), "hello");
754 assert_eq!(env.get_or("MISSING", "fallback"), "fallback");
755 }
756
757 #[test]
758 fn test_apply_sets_env_vars() {
759 let env = parse("DOTENV_TEST_APPLY=applied_value");
760 env.apply();
761 assert_eq!(
762 std::env::var("DOTENV_TEST_APPLY").unwrap(),
763 "applied_value"
764 );
765 unsafe { std::env::remove_var("DOTENV_TEST_APPLY"); }
766 }
767
768 #[test]
769 fn test_keys_and_iter() {
770 let env = parse("X=1\nY=2");
771 let mut keys: Vec<&str> = env.keys().collect();
772 keys.sort();
773 assert_eq!(keys, vec!["X", "Y"]);
774
775 let mut pairs: Vec<(&str, &str)> = env.iter().collect();
776 pairs.sort();
777 assert_eq!(pairs, vec![("X", "1"), ("Y", "2")]);
778 }
779
780 #[test]
781 fn test_load_from_file() {
782 let dir = std::env::temp_dir().join("dotenv_test_load_from");
783 let _ = fs::create_dir_all(&dir);
784 let path = dir.join("test.env");
785
786 let mut f = fs::File::create(&path).unwrap();
787 writeln!(f, "LOADED=yes").unwrap();
788
789 let env = DotEnv::load_from(&path).unwrap();
790 assert_eq!(env.get("LOADED"), Some("yes"));
791
792 let _ = fs::remove_dir_all(&dir);
793 }
794
795 #[test]
796 fn test_whitespace_around_key() {
797 let env = parse(" KEY =value");
798 assert_eq!(env.get("KEY"), Some("value"));
799 }
800
801 #[test]
802 fn test_interpolation_multiple_refs() {
803 let env = parse("HOST=localhost\nPORT=5432\nURL=postgres://${HOST}:${PORT}/db");
804 assert_eq!(env.get("URL"), Some("postgres://localhost:5432/db"));
805 }
806}