Skip to main content

xbp_cli/utils/
env_files.rs

1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5pub fn normalize_env_value(raw: &str) -> String {
6    let mut value = raw.trim().to_string();
7
8    loop {
9        let trimmed = value.trim();
10        let bytes = trimmed.as_bytes();
11
12        if bytes.len() >= 2 {
13            let first = bytes[0] as char;
14            let last = bytes[bytes.len() - 1] as char;
15            if (first == '"' || first == '\'') && first == last {
16                value = trimmed[1..trimmed.len() - 1].trim().to_string();
17                continue;
18            }
19        }
20
21        let unescaped = trimmed.replace("\\\"", "\"").replace("\\'", "'");
22        if unescaped != trimmed {
23            value = unescaped;
24            continue;
25        }
26
27        return trimmed.to_string();
28    }
29}
30
31pub fn parse_env_content(content: &str) -> HashMap<String, String> {
32    let mut result = HashMap::new();
33
34    for line in content.lines() {
35        let mut trimmed = line.trim();
36        if trimmed.is_empty() || trimmed.starts_with('#') {
37            continue;
38        }
39
40        if trimmed.starts_with("export ") {
41            trimmed = trimmed.trim_start_matches("export ").trim();
42        }
43
44        let Some((key, value)) = trimmed.split_once('=') else {
45            continue;
46        };
47
48        let key = key.trim();
49        if key.is_empty() {
50            continue;
51        }
52
53        result.insert(key.to_string(), normalize_env_value(value));
54    }
55
56    result
57}
58
59pub fn parse_env_file(path: &Path) -> Result<HashMap<String, String>, String> {
60    let content = fs::read_to_string(path)
61        .map_err(|e| format!("Failed to read {}: {}", path.display(), e))?;
62    Ok(parse_env_content(&content))
63}
64
65pub fn to_env_references(vars: &HashMap<String, String>) -> HashMap<String, String> {
66    vars.keys()
67        .map(|key| (key.clone(), format!("${{{}}}", key)))
68        .collect()
69}
70
71pub fn resolve_env_placeholders(
72    project_root: &Path,
73    envs: &HashMap<String, String>,
74) -> HashMap<String, String> {
75    let lookup = load_env_lookup(project_root);
76
77    envs.iter()
78        .map(|(key, value)| {
79            let resolved = env_reference_name(value)
80                .and_then(|name| lookup.get(name).cloned())
81                .unwrap_or_else(|| value.clone());
82            (key.clone(), resolved)
83        })
84        .collect()
85}
86
87fn load_env_lookup(project_root: &Path) -> HashMap<String, String> {
88    let mut lookup = HashMap::new();
89
90    for name in [".env", ".env.local", ".env.development", ".env.production"] {
91        let path = project_root.join(name);
92        if !path.exists() {
93            continue;
94        }
95
96        if let Ok(parsed) = parse_env_file(&path) {
97            lookup.extend(parsed);
98        }
99    }
100
101    lookup.extend(std::env::vars());
102    lookup
103}
104
105fn env_reference_name(value: &str) -> Option<&str> {
106    let trimmed = value.trim();
107    if let Some(name) = trimmed
108        .strip_prefix("${")
109        .and_then(|rest| rest.strip_suffix('}'))
110    {
111        return (!name.trim().is_empty()).then_some(name.trim());
112    }
113
114    trimmed
115        .strip_prefix('$')
116        .map(str::trim)
117        .filter(|name| !name.is_empty())
118}
119
120#[cfg(test)]
121mod tests {
122    use super::{
123        normalize_env_value, parse_env_content, resolve_env_placeholders, to_env_references,
124    };
125    use std::collections::HashMap;
126    use std::fs;
127    use std::path::PathBuf;
128
129    fn make_temp_dir(label: &str) -> PathBuf {
130        let nanos = std::time::SystemTime::now()
131            .duration_since(std::time::UNIX_EPOCH)
132            .expect("system clock should be after epoch")
133            .as_nanos();
134        let dir = std::env::temp_dir().join(format!("xbp-env-files-{label}-{nanos}"));
135        fs::create_dir_all(&dir).expect("temp dir should be created");
136        dir
137    }
138
139    #[test]
140    fn normalize_env_value_strips_redundant_wrapping_quotes() {
141        assert_eq!(normalize_env_value(r#""hello""#), "hello");
142        assert_eq!(normalize_env_value("'hello'"), "hello");
143        assert_eq!(normalize_env_value(r#"'\"hello\"'"#), "hello");
144        assert_eq!(normalize_env_value("''hello''"), "hello");
145        assert_eq!(normalize_env_value("hello"), "hello");
146    }
147
148    #[test]
149    fn parse_env_content_normalizes_quotes_and_exports() {
150        let parsed = parse_env_content(
151            r#"
152                export FIRST='"hello"'
153                SECOND='world'
154                THIRD=plain
155            "#,
156        );
157
158        assert_eq!(parsed.get("FIRST"), Some(&"hello".to_string()));
159        assert_eq!(parsed.get("SECOND"), Some(&"world".to_string()));
160        assert_eq!(parsed.get("THIRD"), Some(&"plain".to_string()));
161    }
162
163    #[test]
164    fn to_env_references_maps_values_to_placeholders() {
165        let mut vars = HashMap::new();
166        vars.insert("DATABASE_URL".to_string(), "postgres://demo".to_string());
167
168        let refs = to_env_references(&vars);
169        assert_eq!(
170            refs.get("DATABASE_URL"),
171            Some(&"${DATABASE_URL}".to_string())
172        );
173    }
174
175    #[test]
176    fn resolve_env_placeholders_reads_local_env_files() {
177        let project_root = make_temp_dir("resolve-placeholders");
178        fs::write(
179            project_root.join(".env.local"),
180            "DATABASE_URL='postgres://demo'\nAPI_KEY='\"secret\"'\n",
181        )
182        .expect("env file should be written");
183
184        let mut envs = HashMap::new();
185        envs.insert("DATABASE_URL".to_string(), "${DATABASE_URL}".to_string());
186        envs.insert("API_KEY".to_string(), "${API_KEY}".to_string());
187        envs.insert("NODE_ENV".to_string(), "production".to_string());
188
189        let resolved = resolve_env_placeholders(&project_root, &envs);
190        assert_eq!(
191            resolved.get("DATABASE_URL"),
192            Some(&"postgres://demo".to_string())
193        );
194        assert_eq!(resolved.get("API_KEY"), Some(&"secret".to_string()));
195        assert_eq!(resolved.get("NODE_ENV"), Some(&"production".to_string()));
196
197        let _ = fs::remove_dir_all(project_root);
198    }
199}