1pub fn parse(content: &str, file: &str) -> Result<Vec<(String, String)>, String> {
19 let mut out = Vec::new();
20 for (i, raw) in content.lines().enumerate() {
21 let line = raw.trim();
22 if line.is_empty() || line.starts_with('#') {
23 continue;
24 }
25 let line = line.strip_prefix("export ").unwrap_or(line).trim_start();
26 let Some(eq) = line.find('=') else {
27 return Err(format!("{file}:{}: expected KEY=VALUE, got {raw:?}", i + 1));
28 };
29 let key = line[..eq].trim();
30 if key.is_empty()
31 || !key
32 .chars()
33 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.')
34 || key.starts_with(|c: char| c.is_ascii_digit())
35 {
36 return Err(format!("{file}:{}: invalid key {key:?}", i + 1));
37 }
38 let rest = line[eq + 1..].trim();
39 let value = if let Some(q) = rest.strip_prefix('"') {
40 let (v, after) = unescape_double(q)
42 .ok_or_else(|| format!("{file}:{}: unterminated \" quote", i + 1))?;
43 let after = after.trim();
44 if !after.is_empty() && !after.starts_with('#') {
45 return Err(format!(
46 "{file}:{}: unexpected trailing content {after:?}",
47 i + 1
48 ));
49 }
50 v
51 } else if let Some(q) = rest.strip_prefix('\'') {
52 let end = q
53 .find('\'')
54 .ok_or_else(|| format!("{file}:{}: unterminated ' quote", i + 1))?;
55 let after = q[end + 1..].trim();
56 if !after.is_empty() && !after.starts_with('#') {
57 return Err(format!(
58 "{file}:{}: unexpected trailing content {after:?}",
59 i + 1
60 ));
61 }
62 q[..end].to_string()
63 } else {
64 match rest.find(" #") {
66 Some(h) => rest[..h].trim().to_string(),
67 None => rest.to_string(),
68 }
69 };
70 out.push((key.to_string(), value));
71 }
72 Ok(out)
73}
74
75fn unescape_double(s: &str) -> Option<(String, &str)> {
77 let mut out = String::new();
78 let mut chars = s.char_indices();
79 while let Some((i, c)) = chars.next() {
80 match c {
81 '"' => return Some((out, &s[i + 1..])),
82 '\\' => match chars.next()?.1 {
83 'n' => out.push('\n'),
84 't' => out.push('\t'),
85 'r' => out.push('\r'),
86 '\\' => out.push('\\'),
87 '"' => out.push('"'),
88 other => {
89 out.push('\\');
90 out.push(other);
91 }
92 },
93 _ => out.push(c),
94 }
95 }
96 None
97}
98
99pub fn load_files(paths: &[String]) -> Result<Vec<(String, String)>, String> {
103 let mut merged: Vec<(String, String)> = Vec::new();
104 for path in paths {
105 let content = std::fs::read_to_string(path).map_err(|e| format!("--env {path}: {e}"))?;
106 for (k, v) in parse(&content, path)? {
107 merged.retain(|(ek, _)| ek != &k);
108 merged.push((k, v));
109 }
110 }
111 Ok(merged)
112}
113
114#[cfg(test)]
115mod tests {
116 use super::*;
117
118 #[test]
119 fn the_dotenv_subset_parses_and_bad_lines_say_where() {
120 let src = r#"
121# a comment
122FOO=bar
123export QUOTED="a b\nc"
124LIT='keep $THIS literal'
125TRAIL=value # trailing comment
126EMPTY=
127DOTTED.KEY=ok
128"#;
129 let v = parse(src, "x.env").unwrap();
130 let get = |k: &str| v.iter().find(|(ek, _)| ek == k).map(|(_, v)| v.as_str());
131 assert_eq!(get("FOO"), Some("bar"));
132 assert_eq!(get("QUOTED"), Some("a b\nc"));
133 assert_eq!(get("LIT"), Some("keep $THIS literal"));
134 assert_eq!(get("TRAIL"), Some("value"));
135 assert_eq!(get("EMPTY"), Some(""));
136 assert_eq!(get("DOTTED.KEY"), Some("ok"));
137
138 for (bad, what) in [
139 ("JUSTAWORD", "expected KEY=VALUE"),
140 ("2BAD=x", "invalid key"),
141 ("Q=\"unterminated", "unterminated"),
142 ("Q='unterminated", "unterminated"),
143 ("Q=\"x\" extra", "trailing content"),
144 ] {
145 let e = parse(bad, "y.env").unwrap_err();
146 assert!(e.contains(what), "{bad:?} → {e}");
147 assert!(e.contains("y.env:1"), "names the location: {e}");
148 }
149 }
150
151 #[test]
152 fn later_files_win_within_the_env_layer() {
153 let d = std::env::temp_dir().join(format!("agentd-envfile-{}", std::process::id()));
154 std::fs::create_dir_all(&d).unwrap();
155 let a = d.join("a.env");
156 let b = d.join("b.env");
157 std::fs::write(&a, "K=from_a\nONLY_A=1\n").unwrap();
158 std::fs::write(&b, "K=from_b\n").unwrap();
159 let merged = load_files(&[
160 a.to_string_lossy().into_owned(),
161 b.to_string_lossy().into_owned(),
162 ])
163 .unwrap();
164 let get = |k: &str| {
165 merged
166 .iter()
167 .find(|(ek, _)| ek == k)
168 .map(|(_, v)| v.as_str())
169 };
170 assert_eq!(get("K"), Some("from_b"));
171 assert_eq!(get("ONLY_A"), Some("1"));
172 let _ = std::fs::remove_dir_all(&d);
173 }
174}