1use std::collections::HashMap;
7use std::path::Path;
8
9use crate::compose::types::EnvFileEntry;
10use crate::error::{ComposeError, Result};
11
12pub fn load_env_files(paths: &[String], base_dir: &Path) -> Result<HashMap<String, String>> {
23 let entries: Vec<EnvFileEntry> = paths
24 .iter()
25 .map(|p| EnvFileEntry::Path(p.clone()))
26 .collect();
27 load_env_file_entries(&entries, base_dir)
28}
29
30pub fn load_env_file_entries(
34 entries: &[EnvFileEntry],
35 base_dir: &Path,
36) -> Result<HashMap<String, String>> {
37 let mut result: HashMap<String, String> = HashMap::new();
38
39 for entry in entries {
40 if let EnvFileEntry::Config {
41 format: Some(fmt), ..
42 } = entry
43 {
44 if fmt != "dotenv" {
45 return Err(ComposeError::Unsupported(format!(
46 "env_file format '{fmt}' not supported (only 'dotenv')"
47 )));
48 }
49 }
50
51 let abs = base_dir.join(entry.path());
52 let content = match std::fs::read_to_string(&abs) {
53 Ok(c) => c,
54 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
55 if entry.required() {
56 return Err(ComposeError::FileNotFound(abs.display().to_string()));
57 } else {
58 continue;
59 }
60 }
61 Err(e) => return Err(ComposeError::Io(e)),
62 };
63
64 for (key, value) in crate::dotenv::parse(&content) {
65 result.insert(key, value);
66 }
67 }
68
69 Ok(result)
70}
71
72pub fn merge_env(
76 service_env: HashMap<String, Option<String>>,
77 env_file_vars: HashMap<String, String>,
78) -> Vec<String> {
79 let mut merged = service_env;
80 for (k, v) in env_file_vars {
81 merged.entry(k).or_insert(Some(v));
82 }
83
84 merged
85 .into_iter()
86 .map(|(k, v)| match v {
87 Some(val) => format!("{k}={val}"),
88 None => k,
89 })
90 .collect()
91}
92
93#[cfg(test)]
98mod tests {
99 use super::*;
100 use crate::compose::types::EnvFileEntry;
101
102 #[test]
105 fn loads_key_value_pairs() {
106 let dir = tempfile::tempdir().unwrap();
107 std::fs::write(dir.path().join(".env"), "FOO=bar\nBAZ=qux\n").unwrap();
108 let entries = vec![EnvFileEntry::Path(".env".into())];
109 let m = load_env_file_entries(&entries, dir.path()).unwrap();
110 assert_eq!(m.get("FOO").map(|s| s.as_str()), Some("bar"));
111 assert_eq!(m.get("BAZ").map(|s| s.as_str()), Some("qux"));
112 }
113
114 #[test]
115 fn skips_comments_and_blank_lines() {
116 let dir = tempfile::tempdir().unwrap();
117 std::fs::write(dir.path().join(".env"), "# comment\n\nFOO=bar\n").unwrap();
118 let entries = vec![EnvFileEntry::Path(".env".into())];
119 let m = load_env_file_entries(&entries, dir.path()).unwrap();
120 assert_eq!(m.len(), 1);
121 }
122
123 #[test]
124 fn key_without_equals_has_empty_value() {
125 let dir = tempfile::tempdir().unwrap();
126 std::fs::write(dir.path().join(".env"), "BARE\n").unwrap();
127 let entries = vec![EnvFileEntry::Path(".env".into())];
128 let m = load_env_file_entries(&entries, dir.path()).unwrap();
129 assert_eq!(m.get("BARE").map(|s| s.as_str()), Some(""));
130 }
131
132 #[test]
133 fn last_file_wins_on_duplicate_key() {
134 let dir = tempfile::tempdir().unwrap();
135 std::fs::write(dir.path().join("a.env"), "FOO=first\n").unwrap();
136 std::fs::write(dir.path().join("b.env"), "FOO=second\n").unwrap();
137 let entries = vec![
138 EnvFileEntry::Path("a.env".into()),
139 EnvFileEntry::Path("b.env".into()),
140 ];
141 let m = load_env_file_entries(&entries, dir.path()).unwrap();
142 assert_eq!(m.get("FOO").map(|s| s.as_str()), Some("second"));
143 }
144
145 #[test]
146 fn missing_required_file_returns_error() {
147 let dir = tempfile::tempdir().unwrap();
148 let entries = vec![EnvFileEntry::Path("nonexistent.env".into())];
149 assert!(load_env_file_entries(&entries, dir.path()).is_err());
150 }
151
152 #[test]
153 fn missing_optional_file_skipped() {
154 let dir = tempfile::tempdir().unwrap();
155 let entries = vec![EnvFileEntry::Config {
156 path: "nonexistent.env".into(),
157 required: Some(false),
158 format: None,
159 }];
160 let m = load_env_file_entries(&entries, dir.path()).unwrap();
161 assert!(m.is_empty());
162 }
163
164 #[test]
165 fn unsupported_format_returns_error() {
166 let dir = tempfile::tempdir().unwrap();
167 let entries = vec![EnvFileEntry::Config {
168 path: ".env".into(),
169 required: Some(false),
170 format: Some("json".into()),
171 }];
172 assert!(load_env_file_entries(&entries, dir.path()).is_err());
173 }
174
175 #[test]
178 fn service_env_wins_over_file_env() {
179 let service_env: HashMap<String, Option<String>> =
180 [("FOO".to_string(), Some("from-service".to_string()))].into();
181 let file_env: HashMap<String, String> =
182 [("FOO".to_string(), "from-file".to_string())].into();
183 let result = merge_env(service_env, file_env);
184 let foo_entry = result
185 .iter()
186 .find(|s| s.starts_with("FOO="))
187 .unwrap()
188 .clone();
189 assert_eq!(foo_entry, "FOO=from-service");
190 }
191
192 #[test]
193 fn file_env_fills_missing_keys() {
194 let service_env: HashMap<String, Option<String>> = HashMap::new();
195 let file_env: HashMap<String, String> = [("BAR".to_string(), "baz".to_string())].into();
196 let result = merge_env(service_env, file_env);
197 assert!(result.iter().any(|s| s == "BAR=baz"));
198 }
199
200 #[test]
201 fn key_only_env_var_has_no_equals() {
202 let service_env: HashMap<String, Option<String>> =
203 [("PASSTHROUGH".to_string(), None)].into();
204 let result = merge_env(service_env, HashMap::new());
205 assert!(result.iter().any(|s| s == "PASSTHROUGH"));
206 }
207}