1use std::path::Path;
2
3use anyhow::{Context, Result};
4
5pub fn load_project_env(project_root: &Path) -> Result<()> {
6 load_env_file(&project_root.join(".env"))
7}
8
9pub fn upsert_env_var(path: &Path, key: &str, value: &str) -> Result<()> {
10 let mut lines = if path.exists() {
11 std::fs::read_to_string(path)
12 .with_context(|| format!("failed to read {}", path.display()))?
13 .lines()
14 .map(ToOwned::to_owned)
15 .collect::<Vec<_>>()
16 } else {
17 Vec::new()
18 };
19
20 let rendered = format!("{key}={}", render_env_value(value));
21 let mut replaced = false;
22 for line in &mut lines {
23 if env_key(line).is_some_and(|existing| existing == key) {
24 *line = rendered.clone();
25 replaced = true;
26 break;
27 }
28 }
29
30 if !replaced {
31 if !lines.is_empty() && !lines.last().is_some_and(String::is_empty) {
32 lines.push(String::new());
33 }
34 lines.push(rendered);
35 }
36
37 let output = if lines.is_empty() {
38 String::new()
39 } else {
40 format!("{}\n", lines.join("\n"))
41 };
42 std::fs::write(path, output).with_context(|| format!("failed to write {}", path.display()))?;
43 Ok(())
44}
45
46fn load_env_file(path: &Path) -> Result<()> {
47 if !path.exists() {
48 return Ok(());
49 }
50
51 let content = std::fs::read_to_string(path)
52 .with_context(|| format!("failed to read {}", path.display()))?;
53 for line in content.lines() {
54 let Some((key, value)) = parse_env_assignment(line) else {
55 continue;
56 };
57 if std::env::var_os(&key).is_none() {
58 unsafe {
61 std::env::set_var(key, value);
62 }
63 }
64 }
65
66 Ok(())
67}
68
69fn env_key(line: &str) -> Option<&str> {
70 let trimmed = line.trim();
71 if trimmed.is_empty() || trimmed.starts_with('#') {
72 return None;
73 }
74 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
75 let (key, _) = trimmed.split_once('=')?;
76 let key = key.trim();
77 if key.is_empty() { None } else { Some(key) }
78}
79
80fn parse_env_assignment(line: &str) -> Option<(String, String)> {
81 let trimmed = line.trim();
82 if trimmed.is_empty() || trimmed.starts_with('#') {
83 return None;
84 }
85 let trimmed = trimmed.strip_prefix("export ").unwrap_or(trimmed);
86 let (key, value) = trimmed.split_once('=')?;
87 let key = key.trim();
88 if key.is_empty() {
89 return None;
90 }
91
92 let value = value.trim();
93 let value = match (
94 value.strip_prefix('"').and_then(|v| v.strip_suffix('"')),
95 value.strip_prefix('\'').and_then(|v| v.strip_suffix('\'')),
96 ) {
97 (Some(unquoted), _) => unquoted.to_string(),
98 (_, Some(unquoted)) => unquoted.to_string(),
99 _ => value.to_string(),
100 };
101
102 Some((key.to_string(), value))
103}
104
105fn render_env_value(value: &str) -> String {
106 if value.contains(char::is_whitespace) || value.contains('#') {
107 format!("\"{}\"", value.replace('\\', "\\\\").replace('"', "\\\""))
108 } else {
109 value.to_string()
110 }
111}
112
113#[cfg(test)]
114mod tests {
115 use super::*;
116
117 struct EnvVarGuard {
118 key: &'static str,
119 original: Option<String>,
120 }
121
122 impl EnvVarGuard {
123 fn unset(key: &'static str) -> Self {
124 let original = std::env::var(key).ok();
125 unsafe {
127 std::env::remove_var(key);
128 }
129 Self { key, original }
130 }
131
132 fn set(key: &'static str, value: &str) -> Self {
133 let original = std::env::var(key).ok();
134 unsafe {
136 std::env::set_var(key, value);
137 }
138 Self { key, original }
139 }
140 }
141
142 impl Drop for EnvVarGuard {
143 fn drop(&mut self) {
144 match &self.original {
145 Some(value) => {
146 unsafe {
148 std::env::set_var(self.key, value);
149 }
150 }
151 None => {
152 unsafe {
154 std::env::remove_var(self.key);
155 }
156 }
157 }
158 }
159 }
160
161 #[test]
162 fn load_env_file_sets_missing_vars() {
163 let tmp = tempfile::tempdir().unwrap();
164 let path = tmp.path().join(".env");
165 std::fs::write(
166 &path,
167 "BATTY_TEST_FIRST=alpha\nexport BATTY_TEST_SECOND=\"beta value\"\n# comment\nBATTY_TEST_THIRD='gamma'\n",
168 )
169 .unwrap();
170
171 let _first = EnvVarGuard::unset("BATTY_TEST_FIRST");
172 let _second = EnvVarGuard::unset("BATTY_TEST_SECOND");
173 let _third = EnvVarGuard::unset("BATTY_TEST_THIRD");
174
175 load_env_file(&path).unwrap();
176
177 assert_eq!(std::env::var("BATTY_TEST_FIRST").unwrap(), "alpha");
178 assert_eq!(std::env::var("BATTY_TEST_SECOND").unwrap(), "beta value");
179 assert_eq!(std::env::var("BATTY_TEST_THIRD").unwrap(), "gamma");
180 }
181
182 #[test]
183 fn load_env_file_does_not_override_existing_vars() {
184 let tmp = tempfile::tempdir().unwrap();
185 let path = tmp.path().join(".env");
186 std::fs::write(&path, "BATTY_TEST_EXISTING=from-file\n").unwrap();
187
188 let _guard = EnvVarGuard::set("BATTY_TEST_EXISTING", "from-shell");
189 load_env_file(&path).unwrap();
190
191 assert_eq!(std::env::var("BATTY_TEST_EXISTING").unwrap(), "from-shell");
192 }
193
194 #[test]
195 fn upsert_env_var_replaces_existing_assignment() {
196 let tmp = tempfile::tempdir().unwrap();
197 let path = tmp.path().join(".env");
198 std::fs::write(&path, "FIRST=alpha\nSECOND=beta\n").unwrap();
199
200 upsert_env_var(&path, "SECOND", "updated").unwrap();
201
202 assert_eq!(
203 std::fs::read_to_string(&path).unwrap(),
204 "FIRST=alpha\nSECOND=updated\n"
205 );
206 }
207
208 #[test]
209 fn upsert_env_var_appends_new_assignment() {
210 let tmp = tempfile::tempdir().unwrap();
211 let path = tmp.path().join(".env");
212 std::fs::write(&path, "FIRST=alpha\n").unwrap();
213
214 upsert_env_var(&path, "SECOND", "beta value").unwrap();
215
216 assert_eq!(
217 std::fs::read_to_string(&path).unwrap(),
218 "FIRST=alpha\n\nSECOND=\"beta value\"\n"
219 );
220 }
221}