Skip to main content

api_testing_core/
env_file.rs

1use 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
100/// Read an env var from a list of `.env`-like files using the legacy "last assignment wins" semantics.
101///
102/// Parity notes:
103/// - Lines are trimmed.
104/// - Lines starting with `#` are ignored.
105/// - Optional `export ` prefix is supported.
106/// - Values wrapped in single or double quotes are unwrapped.
107/// - Empty values are treated as "not set".
108pub 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}