api_testing_core/
env_file.rs1use std::io::{BufRead, BufReader};
2use std::path::Path;
3
4use crate::Result;
5
6fn is_valid_key(key: &str) -> bool {
7 let mut chars = key.chars();
8 let Some(first) = chars.next() else {
9 return false;
10 };
11 let first_ok = first == '_' || first.is_ascii_alphabetic();
12 if !first_ok {
13 return false;
14 }
15
16 chars.all(|c| c == '_' || c.is_ascii_alphanumeric())
17}
18
19fn parse_assignment_line(line: &str) -> Option<(String, String)> {
20 let line = line.trim_end_matches('\r');
21 let mut line = line.trim();
22 if line.is_empty() || line.starts_with('#') {
23 return None;
24 }
25
26 if let Some(rest) = line.strip_prefix("export")
27 && rest.starts_with(char::is_whitespace)
28 {
29 line = rest.trim();
30 }
31
32 let (lhs, rhs) = line.split_once('=')?;
33 let key = lhs.trim();
34 if !is_valid_key(key) {
35 return None;
36 }
37
38 let raw_value = rhs.trim();
39 let value = if let Some(stripped) = parse_quoted_value(raw_value) {
40 stripped
41 } else {
42 strip_inline_comment(raw_value).to_string()
43 };
44
45 Some((key.to_string(), value))
46}
47
48fn parse_quoted_value(value: &str) -> Option<String> {
49 let mut chars = value.chars();
50 let quote = chars.next()?;
51 if quote != '"' && quote != '\'' {
52 return None;
53 }
54
55 let closing_index = value[1..].find(quote).map(|idx| idx + 1)?;
56 let remainder = value[closing_index + 1..].trim_start();
57 if !remainder.is_empty() && !remainder.starts_with('#') {
58 return None;
59 }
60
61 Some(value[1..closing_index].to_string())
62}
63
64fn strip_inline_comment(value: &str) -> &str {
65 let mut prev_was_space = false;
66 for (idx, ch) in value.char_indices() {
67 if ch == '#' && prev_was_space {
68 return value[..idx].trim_end();
69 }
70 prev_was_space = ch.is_whitespace();
71 }
72 value.trim_end()
73}
74
75pub fn normalize_env_key(raw: &str) -> String {
76 let raw = raw.trim().to_ascii_uppercase();
77 let mut out = String::new();
78 let mut prev_us = false;
79 for c in raw.chars() {
80 if c.is_ascii_alphanumeric() {
81 out.push(c);
82 prev_us = false;
83 } else if !out.is_empty() && !prev_us {
84 out.push('_');
85 prev_us = true;
86 }
87 }
88 while out.ends_with('_') {
89 out.pop();
90 }
91 out
92}
93
94pub fn read_prefixed_var(prefix: &str, profile: &str, files: &[&Path]) -> Result<Option<String>> {
95 let env_key = normalize_env_key(profile);
96 let var = format!("{prefix}{env_key}");
97 read_var_last_wins(&var, files)
98}
99
100pub fn read_var_last_wins(key: &str, files: &[&Path]) -> Result<Option<String>> {
109 let mut value: Option<String> = None;
110
111 for file in files {
112 if !file.is_file() {
113 continue;
114 }
115
116 let f = std::fs::File::open(file)?;
117 let reader = BufReader::new(f);
118 for line in reader.lines() {
119 let line = line?;
120 let Some((found_key, found_value)) = parse_assignment_line(&line) else {
121 continue;
122 };
123 if found_key == key {
124 value = Some(found_value);
125 }
126 }
127 }
128
129 match value {
130 Some(v) if !v.is_empty() => Ok(Some(v)),
131 _ => Ok(None),
132 }
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138 use pretty_assertions::assert_eq;
139
140 use tempfile::TempDir;
141
142 fn write(path: &Path, contents: &str) {
143 std::fs::create_dir_all(path.parent().expect("parent")).expect("mkdir");
144 std::fs::write(path, contents).expect("write");
145 }
146
147 #[test]
148 fn env_file_read_var_handles_export_and_quotes() {
149 let tmp = TempDir::new().expect("tmp");
150 let f = tmp.path().join("a.env");
151 write(
152 &f,
153 r#"
154# comment
155 export FOO = "bar"
156BAZ='qux'
157NOPE= plain
158"#,
159 );
160
161 assert_eq!(
162 read_var_last_wins("FOO", &[&f]).unwrap(),
163 Some("bar".to_string())
164 );
165 assert_eq!(
166 read_var_last_wins("BAZ", &[&f]).unwrap(),
167 Some("qux".to_string())
168 );
169 assert_eq!(
170 read_var_last_wins("NOPE", &[&f]).unwrap(),
171 Some("plain".to_string())
172 );
173 assert_eq!(read_var_last_wins("MISSING", &[&f]).unwrap(), None);
174 }
175
176 #[test]
177 fn env_file_read_var_last_wins_across_files_and_lines() {
178 let tmp = TempDir::new().expect("tmp");
179 let base = tmp.path().join("base.env");
180 let local = tmp.path().join("local.env");
181 write(&base, "A=1\nA=2\n");
182 write(&local, "A=3\n");
183
184 assert_eq!(
185 read_var_last_wins("A", &[&base, &local]).unwrap(),
186 Some("3".to_string())
187 );
188 }
189
190 #[test]
191 fn env_file_empty_value_clears_key() {
192 let tmp = TempDir::new().expect("tmp");
193 let base = tmp.path().join("base.env");
194 let local = tmp.path().join("local.env");
195 write(&base, "A=1\n");
196 write(&local, "A=\n");
197
198 assert_eq!(read_var_last_wins("A", &[&base, &local]).unwrap(), None);
199 }
200
201 #[test]
202 fn env_file_inline_comments_only_strip_unquoted_values() {
203 let tmp = TempDir::new().expect("tmp");
204 let f = tmp.path().join("inline.env");
205 write(
206 &f,
207 r#"
208FOO=bar # comment
209BAR="baz # keep"
210BAZ='qux # keep'
211QUX=keep#hash
212"#,
213 );
214
215 assert_eq!(
216 read_var_last_wins("FOO", &[&f]).unwrap(),
217 Some("bar".to_string())
218 );
219 assert_eq!(
220 read_var_last_wins("BAR", &[&f]).unwrap(),
221 Some("baz # keep".to_string())
222 );
223 assert_eq!(
224 read_var_last_wins("BAZ", &[&f]).unwrap(),
225 Some("qux # keep".to_string())
226 );
227 assert_eq!(
228 read_var_last_wins("QUX", &[&f]).unwrap(),
229 Some("keep#hash".to_string())
230 );
231 }
232
233 #[test]
234 fn env_file_normalize_env_key_is_stable() {
235 assert_eq!(normalize_env_key("my-profile"), "MY_PROFILE");
236 assert_eq!(normalize_env_key(" team.alpha "), "TEAM_ALPHA");
237 assert_eq!(normalize_env_key("___bad__"), "BAD");
238 }
239}