Skip to main content

envo/
envfile.rs

1//! Parsing and writing of dotenv-style `KEY=VALUE` files, plus the small
2//! shared helpers (quoting, identifier checks) used across the crate.
3
4#[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/// An insertion-ordered string map. Later writes to an existing key overwrite
12/// in place (preserving position); new keys are appended.
13#[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
60/// Parse a dotenv document into an ordered map.
61pub 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
87/// Serialize a map back to dotenv text (values quoted only when necessary).
88pub 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(&quote_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
112/// Read the contents of a quoted region up to the closing quote `q`.
113/// For double quotes, a backslash escapes the next character (kept verbatim
114/// here and resolved later by [`unescape_double`]).
115fn 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
166/// Remove one layer of surrounding quotes (double or single) if present,
167/// unescaping double-quoted content.
168pub 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
182/// Resolve backslash escapes inside double-quoted content.
183pub 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
209/// A valid shell/env identifier: starts with a letter or `_`, then letters,
210/// digits or `_`.
211pub 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}