Skip to main content

command_stream/zx/
dotenv.rs

1//! `.env` parsing and serialization (the `dotenv` helpers zx re-exports).
2
3use std::collections::BTreeMap;
4use std::collections::HashMap;
5use std::path::Path;
6
7use super::error::ZxError;
8
9const QUOTES: [&str; 3] = ["\"", "'", "`"];
10const TRIPLE_QUOTES: [&str; 3] = ["\"\"\"", "'''", "```"];
11
12/// First unescaped `quote` in `text` (backslash escapes except inside `'`).
13fn find_closing_quote(text: &str, quote: char) -> Option<usize> {
14    let mut chars = text.char_indices();
15    while let Some((i, c)) = chars.next() {
16        if c == '\\' && quote != '\'' {
17            chars.next();
18        } else if c == quote {
19            return Some(i);
20        }
21    }
22    None
23}
24
25fn strip_inline_comment(value: &str) -> &str {
26    let cut = value
27        .match_indices('#')
28        .map(|(i, _)| i)
29        .find(|&i| i == 0 || value[..i].ends_with(char::is_whitespace));
30    // `(^|\s)#`: the whitespace before `#` is removed by the trim below.
31    match cut {
32        Some(i) => value[..i].trim(),
33        None => value.trim(),
34    }
35}
36
37/// Read a value that may span lines; `closer` finds the closing delimiter.
38fn read_quoted(
39    lines: &[&str],
40    start: usize,
41    first: &str,
42    closer: impl Fn(&str) -> Option<usize>,
43) -> (String, usize) {
44    if let Some(end) = closer(first) {
45        return (first[..end].to_string(), start + 1);
46    }
47    let mut parts: Vec<&str> = if first.is_empty() {
48        vec![]
49    } else {
50        vec![first]
51    };
52    for (i, line) in lines.iter().enumerate().skip(start + 1) {
53        if let Some(end) = closer(line) {
54            let tail = &line[..end];
55            if !tail.trim().is_empty() || parts.is_empty() {
56                parts.push(tail);
57            }
58            return (parts.join("\n"), i + 1);
59        }
60        parts.push(line);
61    }
62    (first.to_string(), start + 1)
63}
64
65fn read_value(lines: &[&str], index: usize, raw: &str) -> (String, usize) {
66    if let Some(triple) = TRIPLE_QUOTES.iter().find(|q| raw.starts_with(*q)) {
67        return read_quoted(lines, index, &raw[3..], |line| line.find(triple));
68    }
69    if let Some(quote) = QUOTES.iter().find(|q| raw.starts_with(*q)) {
70        let q = quote.chars().next().expect("non-empty quote");
71        return read_quoted(lines, index, &raw[1..], |line| find_closing_quote(line, q));
72    }
73    (strip_inline_comment(raw).to_string(), index + 1)
74}
75
76/// Parse dotenv text into key/value pairs (later keys overwrite earlier ones).
77pub fn parse(content: &str) -> BTreeMap<String, String> {
78    let mut result = BTreeMap::new();
79    let lines: Vec<&str> = content
80        .split('\n')
81        .map(|l| l.strip_suffix('\r').unwrap_or(l))
82        .collect();
83    let mut index = 0;
84    while index < lines.len() {
85        let trimmed = lines[index].trim();
86        let line = strip_export(trimmed);
87        let key = match line.find('=') {
88            Some(eq) if eq > 0 => line[..eq].trim(),
89            _ => "",
90        };
91        if line.starts_with('#') || key.is_empty() || key.contains(char::is_whitespace) {
92            index += 1;
93            continue;
94        }
95        let eq = line.find('=').expect("key implies '='");
96        let (value, next) = read_value(&lines, index, line[eq + 1..].trim());
97        result.insert(key.to_string(), value);
98        index = next;
99    }
100    result
101}
102
103fn strip_export(line: &str) -> &str {
104    if let Some(rest) = line.strip_prefix("export") {
105        if rest.starts_with(char::is_whitespace) {
106            return rest.trim_start();
107        }
108    }
109    line
110}
111
112fn quote_value(value: &str) -> String {
113    let needs_quotes = value
114        .chars()
115        .any(|c| c.is_whitespace() || matches!(c, '#' | '\'' | '"' | '`'));
116    if !needs_quotes {
117        return value.to_string();
118    }
119    if !value.contains(['\r', '\n']) {
120        if !value.contains('\'') {
121            return format!("'{value}'");
122        }
123        if !value.contains('\\') {
124            if let Some(q) = QUOTES.iter().find(|q| !value.contains(*q)) {
125                return format!("{q}{value}{q}");
126            }
127        }
128    }
129    let triple = TRIPLE_QUOTES
130        .iter()
131        .find(|q| !value.contains(*q))
132        .unwrap_or(&TRIPLE_QUOTES[0]);
133    format!("{triple}\n{value}\n{triple}")
134}
135
136/// Serialize pairs as dotenv text, one `KEY=value` per line, quoting values
137/// so that [`parse`] reads them back unchanged.
138pub fn stringify<'a, I, K, V>(env: I) -> String
139where
140    I: IntoIterator<Item = (&'a K, &'a V)>,
141    K: AsRef<str> + ?Sized + 'a,
142    V: AsRef<str> + ?Sized + 'a,
143{
144    env.into_iter()
145        .map(|(k, v)| format!("{}={}", k.as_ref(), quote_value(v.as_ref())))
146        .collect::<Vec<_>>()
147        .join("\n")
148}
149
150fn read_file(file: &Path) -> Result<BTreeMap<String, String>, ZxError> {
151    let text = std::fs::read_to_string(file)?;
152    Ok(parse(&text))
153}
154
155/// Read and merge env files; earlier files take precedence.
156pub fn load<P: AsRef<Path>>(files: &[P]) -> Result<BTreeMap<String, String>, ZxError> {
157    let mut env = BTreeMap::new();
158    for file in files {
159        for (k, v) in read_file(file.as_ref())? {
160            env.entry(k).or_insert(v);
161        }
162    }
163    Ok(env)
164}
165
166/// Like [`load`], but skips files that cannot be read.
167pub fn load_safe<P: AsRef<Path>>(files: &[P]) -> BTreeMap<String, String> {
168    let mut env = BTreeMap::new();
169    for file in files {
170        if let Ok(parsed) = read_file(file.as_ref()) {
171            for (k, v) in parsed {
172                env.entry(k).or_insert(v);
173            }
174        }
175    }
176    env
177}
178
179/// Load env files into a map suitable for [`Shell::env`](super::Shell::env):
180/// the current process environment overlaid (without overriding) by the file
181/// values. Unlike zx this never mutates the process environment.
182pub fn config<P: AsRef<Path>>(files: &[P]) -> HashMap<String, String> {
183    let mut env: HashMap<String, String> = std::env::vars().collect();
184    for (k, v) in load_safe(files) {
185        // Windows env names are case-insensitive: a file `PATH` is `Path`.
186        let taken = if cfg!(windows) {
187            env.keys().any(|e| e.eq_ignore_ascii_case(&k))
188        } else {
189            env.contains_key(&k)
190        };
191        if !taken {
192            env.insert(k, v);
193        }
194    }
195    env
196}