Skip to main content

batty_cli/
env_file.rs

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            // SAFETY: batty mutates process env during single-threaded CLI startup,
59            // before it spawns workers or background threads.
60            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            // SAFETY: tests mutate process env in a bounded scope and restore it on drop.
126            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            // SAFETY: tests mutate process env in a bounded scope and restore it on drop.
135            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                    // SAFETY: tests restore the original value before exiting.
147                    unsafe {
148                        std::env::set_var(self.key, value);
149                    }
150                }
151                None => {
152                    // SAFETY: tests restore the original absence before exiting.
153                    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}