1use crate::error::{Error, Result};
8use std::collections::BTreeMap;
9use std::path::Path;
10
11pub 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 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 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 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
147pub 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 unsafe { std::env::set_var(key, value) };
165 }
166 }
167 Ok(values)
168}
169
170pub 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}