xbp_cli/utils/
env_files.rs1use 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}