Skip to main content

podup/
env_file.rs

1//! `env_file:` loading for services.
2//!
3//! Reads KEY=VALUE pairs from files listed in a service's `env_file:` field.
4//! Service-level `environment:` takes precedence over `env_file:` values.
5
6use std::collections::HashMap;
7use std::path::Path;
8
9use crate::compose::types::EnvFileEntry;
10use crate::error::{ComposeError, Result};
11
12/// Load all `env_file` paths relative to `base_dir`.
13///
14/// Returns a merged map.  If the same key appears in multiple files, the
15/// last file wins (later entries in the list override earlier ones).
16/// `env_file:` never overrides service-level `environment:`.
17///
18/// Each file is parsed with dotenv rules (quote stripping, escapes, inline
19/// comments, multi-line quoted values).
20///
21/// Returns [`ComposeError::FileNotFound`] when an env file does not exist.
22pub 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
30/// Load env_file entries supporting both short and long-form (with `required` and `format`).
31///
32/// When `required: false`, a missing file is silently skipped instead of returning an error.
33pub 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
72/// Merge env_file values with service environment.
73///
74/// `service_env` takes precedence: only keys not already in `service_env` are added.
75pub 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// ---------------------------------------------------------------------------
94// Unit tests
95// ---------------------------------------------------------------------------
96
97#[cfg(test)]
98mod tests {
99	use super::*;
100	use crate::compose::types::EnvFileEntry;
101
102	// load_env_file_entries
103
104	#[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	// merge_env
176
177	#[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}