1use std::path::Path;
4
5pub 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#[allow(unsafe_code)]
25fn set_if_absent(key: &str, value: &str) {
26 unsafe { std::env::set_var(key, value) };
27}
28
29pub 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
52fn 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}