1#[derive(Debug, thiserror::Error)]
5#[error("env file parse error on line {line}: {msg}")]
6pub struct EnvFileError {
7 pub line: usize,
8 pub msg: String,
9}
10
11#[derive(Debug, Clone, Default)]
14pub struct EnvMap {
15 entries: Vec<(String, String)>,
16}
17
18impl EnvMap {
19 pub fn new() -> Self {
20 Self::default()
21 }
22
23 pub fn get(&self, key: &str) -> Option<&str> {
24 self.entries
25 .iter()
26 .find(|(k, _)| k == key)
27 .map(|(_, v)| v.as_str())
28 }
29
30 pub fn set(&mut self, key: impl Into<String>, value: impl Into<String>) {
31 let key = key.into();
32 if let Some(slot) = self.entries.iter_mut().find(|(k, _)| *k == key) {
33 slot.1 = value.into();
34 } else {
35 self.entries.push((key, value.into()));
36 }
37 }
38
39 pub fn contains(&self, key: &str) -> bool {
40 self.entries.iter().any(|(k, _)| k == key)
41 }
42
43 pub fn iter(&self) -> impl Iterator<Item = (&str, &str)> {
44 self.entries.iter().map(|(k, v)| (k.as_str(), v.as_str()))
45 }
46
47 pub fn keys(&self) -> impl Iterator<Item = &str> {
48 self.entries.iter().map(|(k, _)| k.as_str())
49 }
50
51 pub fn len(&self) -> usize {
52 self.entries.len()
53 }
54
55 pub fn is_empty(&self) -> bool {
56 self.entries.is_empty()
57 }
58}
59
60pub fn parse(text: &str) -> Result<EnvMap, EnvFileError> {
62 let mut map = EnvMap::new();
63 for (i, raw) in text.lines().enumerate() {
64 let line_no = i + 1;
65 let line = raw.trim_start();
66 if line.is_empty() || line.starts_with('#') {
67 continue;
68 }
69 let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
70 let eq = line.find('=').ok_or_else(|| EnvFileError {
71 line: line_no,
72 msg: format!("missing `=` in `{}`", raw.trim()),
73 })?;
74 let key = line[..eq].trim();
75 if !valid_key(key) {
76 return Err(EnvFileError {
77 line: line_no,
78 msg: format!("invalid key `{key}`"),
79 });
80 }
81 let value = parse_value(&line[eq + 1..]);
82 map.set(key.to_string(), value);
83 }
84 Ok(map)
85}
86
87pub fn to_dotenv(map: &EnvMap) -> String {
89 let mut out = String::new();
90 for (k, v) in map.iter() {
91 out.push_str(k);
92 out.push('=');
93 out.push_str("e_if_needed(v));
94 out.push('\n');
95 }
96 out
97}
98
99fn parse_value(raw: &str) -> String {
100 let s = raw.trim_start();
101 if let Some(rest) = s.strip_prefix('"') {
102 let (inner, _) = take_quoted(rest, '"');
103 unescape_double(&inner)
104 } else if let Some(rest) = s.strip_prefix('\'') {
105 let (inner, _) = take_quoted(rest, '\'');
106 inner
107 } else {
108 strip_unquoted_comment(s).trim_end().to_string()
109 }
110}
111
112fn take_quoted(s: &str, q: char) -> (String, usize) {
116 let mut out = String::new();
117 let mut chars = s.char_indices();
118 while let Some((i, c)) = chars.next() {
119 if c == '\\' && q == '"' {
120 if let Some((_, n)) = chars.next() {
121 out.push('\\');
122 out.push(n);
123 continue;
124 } else {
125 out.push('\\');
126 return (out, s.len());
127 }
128 }
129 if c == q {
130 return (out, i + c.len_utf8());
131 }
132 out.push(c);
133 }
134 (out, s.len())
135}
136
137fn strip_unquoted_comment(s: &str) -> &str {
138 let bytes = s.as_bytes();
139 let mut i = 0;
140 while i < bytes.len() {
141 if bytes[i] == b'#' && (i == 0 || bytes[i - 1].is_ascii_whitespace()) {
142 return &s[..i];
143 }
144 i += 1;
145 }
146 s
147}
148
149fn quote_if_needed(v: &str) -> String {
150 let needs = v.is_empty()
151 || v.chars()
152 .any(|c| c.is_whitespace() || matches!(c, '#' | '"' | '\'' | '\\' | '$' | '`'));
153 if needs {
154 let escaped = v
155 .replace('\\', "\\\\")
156 .replace('"', "\\\"")
157 .replace('\n', "\\n")
158 .replace('\t', "\\t")
159 .replace('\r', "\\r");
160 format!("\"{escaped}\"")
161 } else {
162 v.to_string()
163 }
164}
165
166pub fn unquote(s: &str) -> String {
169 let s = s.trim();
170 let b = s.as_bytes();
171 if b.len() >= 2 {
172 if b[0] == b'"' && b[b.len() - 1] == b'"' {
173 return unescape_double(&s[1..s.len() - 1]);
174 }
175 if b[0] == b'\'' && b[b.len() - 1] == b'\'' {
176 return s[1..s.len() - 1].to_string();
177 }
178 }
179 s.to_string()
180}
181
182pub fn unescape_double(s: &str) -> String {
184 let mut out = String::with_capacity(s.len());
185 let mut chars = s.chars();
186 while let Some(c) = chars.next() {
187 if c == '\\' {
188 match chars.next() {
189 Some('n') => out.push('\n'),
190 Some('t') => out.push('\t'),
191 Some('r') => out.push('\r'),
192 Some('0') => out.push('\0'),
193 Some('\\') => out.push('\\'),
194 Some('"') => out.push('"'),
195 Some('\'') => out.push('\''),
196 Some(other) => {
197 out.push('\\');
198 out.push(other);
199 }
200 None => out.push('\\'),
201 }
202 } else {
203 out.push(c);
204 }
205 }
206 out
207}
208
209pub fn valid_key(s: &str) -> bool {
212 let mut chars = s.chars();
213 match chars.next() {
214 Some(c) if c.is_ascii_alphabetic() || c == '_' => {}
215 _ => return false,
216 }
217 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
218}
219
220#[cfg(test)]
221mod tests {
222 use super::*;
223
224 #[test]
225 fn parses_basic() {
226 let m = parse("FOO=bar\nBAZ=qux\n").unwrap();
227 assert_eq!(m.get("FOO"), Some("bar"));
228 assert_eq!(m.get("BAZ"), Some("qux"));
229 assert_eq!(m.len(), 2);
230 }
231
232 #[test]
233 fn handles_export_and_comments() {
234 let m = parse("# header\nexport PORT=3000 # inline\n\nNAME=hi\n").unwrap();
235 assert_eq!(m.get("PORT"), Some("3000"));
236 assert_eq!(m.get("NAME"), Some("hi"));
237 }
238
239 #[test]
240 fn quoted_values() {
241 let m = parse("A=\"hello world\"\nB='raw # not comment'\nC=\"line\\nbreak\"\n").unwrap();
242 assert_eq!(m.get("A"), Some("hello world"));
243 assert_eq!(m.get("B"), Some("raw # not comment"));
244 assert_eq!(m.get("C"), Some("line\nbreak"));
245 }
246
247 #[test]
248 fn later_key_overwrites_in_place() {
249 let mut m = EnvMap::new();
250 m.set("X", "1");
251 m.set("Y", "2");
252 m.set("X", "3");
253 let keys: Vec<_> = m.keys().collect();
254 assert_eq!(keys, vec!["X", "Y"]);
255 assert_eq!(m.get("X"), Some("3"));
256 }
257
258 #[test]
259 fn roundtrip_quoting() {
260 let mut m = EnvMap::new();
261 m.set("PLAIN", "value");
262 m.set("SPACED", "a b");
263 let text = to_dotenv(&m);
264 let back = parse(&text).unwrap();
265 assert_eq!(back.get("PLAIN"), Some("value"));
266 assert_eq!(back.get("SPACED"), Some("a b"));
267 }
268
269 #[test]
270 fn rejects_bad_key() {
271 assert!(parse("1BAD=x\n").is_err());
272 assert!(parse("noequals\n").is_err());
273 }
274}