Skip to main content

rustlavel_core/
env.rs

1//! Our own `.env` loader.
2//!
3//! Supports `KEY=value`, `export KEY=value`, `#` comments, single/double quoted
4//! values (with escapes inside double quotes), and `${VAR}` interpolation
5//! against values already loaded or present in the process environment.
6
7use crate::error::{Error, Result};
8use std::collections::BTreeMap;
9use std::path::Path;
10
11/// Parse `.env` text into ordered key/value pairs.
12///
13/// Interpolation resolves against earlier entries in the same file first, then
14/// the process environment — the same precedence Laravel's loader uses.
15pub fn parse(source: &str, file: &str) -> Result<BTreeMap<String, String>> {
16    let mut values: BTreeMap<String, String> = BTreeMap::new();
17
18    for (index, raw_line) in source.lines().enumerate() {
19        let line_number = index + 1;
20        let line = raw_line.trim();
21        if line.is_empty() || line.starts_with('#') {
22            continue;
23        }
24
25        let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
26        let Some((key, value)) = line.split_once('=') else {
27            return Err(Error::Config {
28                file: file.to_string(),
29                line: line_number,
30                message: format!("expected `KEY=value`, found `{line}`"),
31            });
32        };
33
34        let key = key.trim();
35        if key.is_empty() || !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.') {
36            return Err(Error::Config {
37                file: file.to_string(),
38                line: line_number,
39                message: format!("`{key}` is not a valid variable name"),
40            });
41        }
42
43        let value = parse_value(value.trim(), &values, file, line_number)?;
44        values.insert(key.to_string(), value);
45    }
46
47    Ok(values)
48}
49
50fn parse_value(
51    raw: &str,
52    known: &BTreeMap<String, String>,
53    file: &str,
54    line: usize,
55) -> Result<String> {
56    let mut chars = raw.chars().peekable();
57    match chars.peek() {
58        // Single quotes are literal: no escapes, no interpolation.
59        Some('\'') => {
60            let body = raw
61                .strip_prefix('\'')
62                .and_then(|r| r.strip_suffix('\''))
63                .ok_or_else(|| Error::Config {
64                    file: file.to_string(),
65                    line,
66                    message: "unterminated single-quoted value".into(),
67                })?;
68            Ok(body.to_string())
69        }
70        Some('"') => {
71            let body = raw
72                .strip_prefix('"')
73                .and_then(|r| r.strip_suffix('"'))
74                .ok_or_else(|| Error::Config {
75                    file: file.to_string(),
76                    line,
77                    message: "unterminated double-quoted value".into(),
78                })?;
79            let unescaped = unescape(body);
80            Ok(interpolate(&unescaped, known))
81        }
82        _ => {
83            // Bare values end at an unquoted `#` comment.
84            let body = match raw.find(" #") {
85                Some(at) => &raw[..at],
86                None => raw,
87            };
88            Ok(interpolate(body.trim(), known))
89        }
90    }
91}
92
93fn unescape(body: &str) -> String {
94    let mut out = String::with_capacity(body.len());
95    let mut chars = body.chars();
96    while let Some(ch) = chars.next() {
97        if ch != '\\' {
98            out.push(ch);
99            continue;
100        }
101        match chars.next() {
102            Some('n') => out.push('\n'),
103            Some('r') => out.push('\r'),
104            Some('t') => out.push('\t'),
105            Some('"') => out.push('"'),
106            Some('\\') => out.push('\\'),
107            Some('$') => out.push('$'),
108            Some(other) => {
109                out.push('\\');
110                out.push(other);
111            }
112            None => out.push('\\'),
113        }
114    }
115    out
116}
117
118fn interpolate(value: &str, known: &BTreeMap<String, String>) -> String {
119    if !value.contains("${") {
120        return value.to_string();
121    }
122
123    let mut out = String::with_capacity(value.len());
124    let bytes = value.as_bytes();
125    let mut i = 0;
126    while i < bytes.len() {
127        if bytes[i] == b'$' && bytes.get(i + 1) == Some(&b'{')
128            && let Some(end) = value[i + 2..].find('}') {
129                let name = &value[i + 2..i + 2 + end];
130                let resolved = known
131                    .get(name)
132                    .cloned()
133                    .or_else(|| std::env::var(name).ok())
134                    .unwrap_or_default();
135                out.push_str(&resolved);
136                i += end + 3;
137                continue;
138            }
139        // Push whole UTF-8 characters, never partial bytes.
140        let ch = value[i..].chars().next().expect("index is on a char boundary");
141        out.push(ch);
142        i += ch.len_utf8();
143    }
144    out
145}
146
147/// Load a `.env` file and export every entry into the process environment.
148///
149/// Variables already set in the real environment win, so deployments can
150/// override the file without editing it. A missing file is not an error.
151pub fn load(path: impl AsRef<Path>) -> Result<BTreeMap<String, String>> {
152    let path = path.as_ref();
153    let source = match std::fs::read_to_string(path) {
154        Ok(source) => source,
155        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
156        Err(e) => return Err(Error::Io(e)),
157    };
158
159    let values = parse(&source, &path.display().to_string())?;
160    for (key, value) in &values {
161        if std::env::var_os(key).is_none() {
162            // SAFETY: called during single-threaded application boot, before
163            // any worker task that might read the environment concurrently.
164            unsafe { std::env::set_var(key, value) };
165        }
166    }
167    Ok(values)
168}
169
170/// Read an environment variable, falling back to a default.
171///
172/// This is the `env('APP_NAME', 'Rustlavel')` of the framework.
173pub fn env_or(key: &str, default: &str) -> String {
174    std::env::var(key).unwrap_or_else(|_| default.to_string())
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn parses_the_common_shapes() {
183        let source = r#"
184# a comment
185APP_NAME=Rustlavel
186export APP_ENV=local
187APP_DEBUG=true            # trailing comment
188QUOTED="hello world"
189LITERAL='raw ${APP_NAME}'
190ESCAPED="line\nbreak"
191EMPTY=
192"#;
193        let values = parse(source, ".env").unwrap();
194
195        assert_eq!(values["APP_NAME"], "Rustlavel");
196        assert_eq!(values["APP_ENV"], "local");
197        assert_eq!(values["APP_DEBUG"], "true");
198        assert_eq!(values["QUOTED"], "hello world");
199        assert_eq!(values["LITERAL"], "raw ${APP_NAME}");
200        assert_eq!(values["ESCAPED"], "line\nbreak");
201        assert_eq!(values["EMPTY"], "");
202    }
203
204    #[test]
205    fn interpolates_earlier_entries() {
206        let values = parse("HOST=localhost\nURL=http://${HOST}:8000/app", ".env").unwrap();
207        assert_eq!(values["URL"], "http://localhost:8000/app");
208    }
209
210    #[test]
211    fn unknown_interpolation_becomes_empty() {
212        let values = parse("URL=http://${NOPE}/x", ".env").unwrap();
213        assert_eq!(values["URL"], "http:///x");
214    }
215
216    #[test]
217    fn reports_the_offending_line() {
218        let err = parse("GOOD=1\nthis line is broken\n", ".env").unwrap_err();
219        match err {
220            Error::Config { line, .. } => assert_eq!(line, 2),
221            other => panic!("expected a config error, got {other:?}"),
222        }
223    }
224
225    #[test]
226    fn missing_file_is_not_an_error() {
227        assert!(load("/definitely/not/here/.env").unwrap().is_empty());
228    }
229}