1use regex::Regex;
2use std::collections::HashMap;
3use std::fs;
4use std::path::Path;
5use std::sync::LazyLock;
6
7static KEY_RE: LazyLock<Regex> =
8 LazyLock::new(|| Regex::new(r"^[A-Za-z_][A-Za-z0-9_]*$").expect("valid regex"));
9
10pub fn load(path: &Path) -> Result<HashMap<String, String>, std::io::Error> {
11 let text = fs::read_to_string(path)?;
12 parse(&text).map_err(|err| std::io::Error::new(std::io::ErrorKind::InvalidData, err))
13}
14
15pub fn parse(text: &str) -> Result<HashMap<String, String>, String> {
16 let mut out = HashMap::new();
17
18 for (index, raw) in text.lines().enumerate() {
19 let mut line = raw.trim_end_matches('\r').trim().to_string();
20
21 if line.is_empty() || line.starts_with('#') {
22 continue;
23 }
24
25 if let Some(stripped) = line.strip_prefix("export ") {
26 line = stripped.trim().to_string();
27 }
28
29 let Some(cut) = line.find('=') else {
30 return Err(format!("line {}: expected KEY=VALUE", index + 1));
31 };
32
33 if cut == 0 {
34 return Err(format!("line {}: expected KEY=VALUE", index + 1));
35 }
36
37 let key = line[..cut].trim();
38 if !KEY_RE.is_match(key) {
39 return Err(format!("line {}: invalid key {key:?}", index + 1));
40 }
41
42 let value = parse_value(line[cut + 1..].trim())
43 .map_err(|err| format!("line {}: {err}", index + 1))?;
44
45 out.insert(key.to_string(), value);
46 }
47
48 Ok(out)
49}
50
51fn parse_value(value: &str) -> Result<String, String> {
52 if value.is_empty() {
53 return Ok(String::new());
54 }
55
56 if value.starts_with('"') {
57 if !value.ends_with('"') || value.len() == 1 {
58 return Err("unterminated double-quoted value".to_string());
59 }
60
61 let quoted = serde_json::from_str::<String>(value)
62 .map_err(|_| "invalid double-quoted value".to_string())?;
63 return Ok(quoted);
64 }
65
66 if value.starts_with('\'') {
67 if !value.ends_with('\'') || value.len() == 1 {
68 return Err("unterminated single-quoted value".to_string());
69 }
70 return Ok(value[1..value.len() - 1].to_string());
71 }
72
73 let trimmed = if let Some(idx) = value.find(" #") {
74 value[..idx].trim().to_string()
75 } else {
76 value.to_string()
77 };
78
79 Ok(trimmed)
80}
81
82#[cfg(test)]
83mod tests {
84 use super::*;
85 use tempfile::tempdir;
86
87 #[test]
88 fn parse_envfile() {
89 let text = r#"
90# comment
91FOO=bar
92EMPTY=
93export NAME=otto
94SINGLE='hello world'
95DOUBLE="a\\nb"
96RAW=hello # trailing comment
97"#;
98
99 let out = parse(text).expect("parse dotenv");
100 assert_eq!(out.get("FOO"), Some(&"bar".to_string()));
101 assert_eq!(out.get("EMPTY"), Some(&"".to_string()));
102 assert_eq!(out.get("NAME"), Some(&"otto".to_string()));
103 assert_eq!(out.get("SINGLE"), Some(&"hello world".to_string()));
104 assert_eq!(out.get("DOUBLE"), Some(&"a\\nb".to_string()));
105 assert_eq!(out.get("RAW"), Some(&"hello".to_string()));
106 }
107
108 #[test]
109 fn parse_rejects_invalid_line() {
110 assert!(parse("not-valid").is_err());
111 }
112
113 #[test]
114 fn load_missing_file() {
115 let dir = tempdir().expect("tempdir");
116 let path = dir.path().join("missing.env");
117 let err = load(&path).expect_err("expected missing");
118 assert_eq!(err.kind(), std::io::ErrorKind::NotFound);
119 }
120}