Skip to main content

cordis_cli/
dotenv.rs

1//! Minimal `.env` loading — `KEY=VALUE` lines, no interpolation.
2
3use std::path::Path;
4
5/// Load `.env` and then `.env.local` from `dir`, without overriding
6/// variables that are already set in the environment.
7pub fn load(dir: &Path) {
8    for name in [".env", ".env.local"] {
9        let path = dir.join(name);
10        let Ok(text) = std::fs::read_to_string(&path) else {
11            continue;
12        };
13        for (key, value) in parse(&text) {
14            if std::env::var_os(&key).is_none() {
15                set_if_absent(&key, &value);
16            }
17        }
18    }
19}
20
21/// SAFETY: the CLI sets the environment exactly once during single-threaded
22/// startup, before any worker threads exist, so no other thread can be
23/// reading `std::env` concurrently.
24#[allow(unsafe_code)]
25fn set_if_absent(key: &str, value: &str) {
26    unsafe { std::env::set_var(key, value) };
27}
28
29/// Parse dotenv text into key/value pairs. Supports comments, blank lines,
30/// an optional `export ` prefix, and single- or double-quoted values.
31/// Malformed lines are skipped.
32pub fn parse(text: &str) -> Vec<(String, String)> {
33    let mut pairs = Vec::new();
34    for line in text.lines() {
35        let line = line.trim();
36        if line.is_empty() || line.starts_with('#') {
37            continue;
38        }
39        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
40        let Some((key, value)) = line.split_once('=') else {
41            continue;
42        };
43        let key = key.trim();
44        if key.is_empty() || key.contains(char::is_whitespace) {
45            continue;
46        }
47        pairs.push((key.to_owned(), unquote(value.trim())));
48    }
49    pairs
50}
51
52/// Strip one layer of matching quotes; `#` starts a comment in unquoted
53/// values.
54fn unquote(value: &str) -> String {
55    let value = if value.len() >= 2
56        && ((value.starts_with('"') && value.ends_with('"'))
57            || (value.starts_with('\'') && value.ends_with('\'')))
58    {
59        &value[1..value.len() - 1]
60    } else {
61        value
62            .split_once('#')
63            .map_or(value, |(head, _)| head.trim_end())
64    };
65    value.to_owned()
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn parses_comments_quotes_and_exports() {
74        let text = r#"
75# comment
76PLAIN=value
77export EXPORTED=1
78QUOTED="a # b"
79SINGLE='c'
80TRAILING=x   # trailing comment
81EMPTY=
82malformed-line
83"#;
84        assert_eq!(
85            parse(text),
86            vec![
87                ("PLAIN".to_owned(), "value".to_owned()),
88                ("EXPORTED".to_owned(), "1".to_owned()),
89                ("QUOTED".to_owned(), "a # b".to_owned()),
90                ("SINGLE".to_owned(), "c".to_owned()),
91                ("TRAILING".to_owned(), "x".to_owned()),
92                ("EMPTY".to_owned(), String::new()),
93            ]
94        );
95    }
96}