1use std::{
2 collections::HashMap,
3 fmt::{self, Display, Write as _},
4 marker::PhantomData,
5 path::PathBuf,
6};
7
8use strum::{EnumIter, EnumString, IntoEnumIterator as _};
9
10use crate::{group_error, group_info, utils::git};
11
12#[derive(Clone, Debug, PartialEq, Default)]
14pub struct ImplicitIndex;
15
16#[derive(Clone, Debug, PartialEq, Default)]
18pub struct ExplicitIndex;
19
20pub trait IndexStyle {
22 fn format(base: &str, index: u8) -> String;
23}
24
25impl IndexStyle for ImplicitIndex {
26 fn format(base: &str, index: u8) -> String {
27 if index == 1 {
28 base.to_string()
29 } else {
30 format!("{base}{index}")
31 }
32 }
33}
34
35impl IndexStyle for ExplicitIndex {
36 fn format(base: &str, index: u8) -> String {
37 format!("{base}{index}")
38 }
39}
40
41#[derive(Clone, Debug, Default, PartialEq)]
42pub struct Environment<M = ImplicitIndex> {
43 pub name: EnvironmentName,
44 pub index: EnvironmentIndex,
45 _marker: PhantomData<M>,
46}
47
48impl<M> Environment<M> {
49 pub fn new(name: EnvironmentName, index: u8) -> Self {
50 Self {
51 name,
52 index: index.into(),
53 _marker: PhantomData,
54 }
55 }
56
57 pub fn index(&self) -> u8 {
58 self.index.index
59 }
60}
61
62impl Environment<ImplicitIndex> {
63 pub fn into_explicit(self) -> Environment<ExplicitIndex> {
67 Environment {
68 name: self.name.clone(),
69 index: self.index().into(),
70 _marker: PhantomData,
71 }
72 }
73}
74
75impl Environment<ExplicitIndex> {
76 pub fn into_implicit(self) -> Environment<ImplicitIndex> {
77 Environment {
78 name: self.name.clone(),
79 index: self.index().into(),
80 _marker: PhantomData,
81 }
82 }
83}
84
85impl<M: IndexStyle> Display for Environment<M> {
86 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
87 write!(f, "{}", self.medium())
88 }
89}
90
91impl<M: IndexStyle> Environment<M> {
92 pub fn long(&self) -> String {
93 M::format(self.name.long(), self.index())
94 }
95
96 pub fn medium(&self) -> String {
97 M::format(self.name.medium(), self.index())
98 }
99
100 pub fn short(&self) -> String {
101 M::format(&self.name.short().to_string(), self.index())
102 }
103
104 fn dotenv_files_for_family(&self, family: DotEnvFamily) -> [String; 2] {
110 let suffix = family.to_string();
111 let env_medium = self.medium();
112 if suffix.is_empty() {
113 [".env".to_owned(), format!(".env.{env_medium}")]
115 } else {
116 [
118 format!(".env{suffix}"),
119 format!(".env.{env_medium}{suffix}"),
120 ]
121 }
122 }
123
124 pub fn get_dotenv_filename(&self) -> String {
126 self.dotenv_files_for_family(DotEnvFamily::Base)[1].clone()
128 }
129
130 pub fn get_dotenv_secrets_filename(&self) -> String {
132 self.dotenv_files_for_family(DotEnvFamily::Secrets)[1].clone()
134 }
135
136 pub fn get_env_files(&self) -> Vec<String> {
139 DotEnvFamily::iter()
140 .flat_map(|family| self.dotenv_files_for_family(family))
141 .collect()
142 }
143
144 pub fn load(&self, prefix: Option<&str>) -> anyhow::Result<()> {
146 let files = self.get_env_files();
147 for file in files {
148 let path = if let Some(p) = prefix {
149 PathBuf::from(p).join(&file)
150 } else {
151 PathBuf::from(&file)
152 };
153 if path.exists() {
154 match dotenvy::from_path(&path) {
155 Ok(_) => {
156 group_info!("loading '{}' file...", path.display());
157 }
158 Err(e) => {
159 group_error!("error while loading '{}' file ({})", path.display(), e);
160 }
161 }
162 }
163 }
164
165 Ok(())
166 }
167
168 pub fn merge_env_files(&self) -> anyhow::Result<PathBuf> {
170 let repo_root = git::git_repo_root_or_cwd()?;
171 let files = self.get_env_files();
172 let mut merged: HashMap<String, String> = HashMap::new();
175 for filename in files {
176 let path = repo_root.join(&filename);
177 if !path.exists() {
178 eprintln!(
179 "⚠️ Warning: environment file '{}' ({}) not found, skipping...",
180 filename,
181 path.display()
182 );
183 continue;
184 }
185 for item in dotenvy::from_path_iter(&path)? {
186 let (key, value) = item?;
187 std::env::set_var(&key, &value);
188 merged.insert(key, value);
189 }
190 }
191 let mut keys: Vec<_> = merged.keys().cloned().collect();
192 keys.sort();
193 let mut out = String::new();
195 for key in keys {
196 let val = &merged[&key];
197 writeln!(&mut out, "{key}={val}")?;
198 }
199 let tmp_path = std::env::temp_dir().join(format!("merged-env-{}.tmp", std::process::id()));
200 std::fs::write(&tmp_path, out)?;
201 Ok(tmp_path)
202 }
203}
204
205#[derive(EnumString, EnumIter, Default, Clone, Debug, PartialEq, clap::ValueEnum)]
206#[strum(serialize_all = "lowercase")]
207pub enum EnvironmentName {
208 #[default]
210 #[clap(alias = "dev")]
211 Development,
212 #[clap(alias = "stag")]
214 Staging,
215 #[clap(alias = "test")]
217 Test,
218 #[clap(alias = "prod")]
220 Production,
221}
222
223impl Display for EnvironmentName {
224 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
225 write!(f, "{}", self.medium())
226 }
227}
228
229impl EnvironmentName {
230 pub fn long(&self) -> &'static str {
231 match self {
232 EnvironmentName::Development => "development",
233 EnvironmentName::Staging => "staging",
234 EnvironmentName::Test => "test",
235 EnvironmentName::Production => "production",
236 }
237 }
238
239 pub fn medium(&self) -> &'static str {
240 match self {
241 EnvironmentName::Development => "dev",
242 EnvironmentName::Staging => "stag",
243 EnvironmentName::Test => "test",
244 EnvironmentName::Production => "prod",
245 }
246 }
247
248 pub fn short(&self) -> char {
249 match self {
250 EnvironmentName::Development => 'd',
251 EnvironmentName::Staging => 's',
252 EnvironmentName::Test => 't',
253 EnvironmentName::Production => 'p',
254 }
255 }
256}
257
258#[derive(Clone, Debug, PartialEq)]
259pub struct EnvironmentIndex {
260 pub index: u8,
261}
262
263impl Default for EnvironmentIndex {
264 fn default() -> Self {
265 Self { index: 1 }
266 }
267}
268
269impl Display for EnvironmentIndex {
270 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
271 write!(f, "{}", self.index)
272 }
273}
274
275impl From<u8> for EnvironmentIndex {
276 fn from(index: u8) -> Self {
277 Self { index }
278 }
279}
280
281#[derive(EnumString, EnumIter, Clone, Debug, PartialEq, clap::ValueEnum)]
282enum DotEnvFamily {
283 Base,
284 Secrets,
285 Infra,
286 InfraSecrets,
287}
288
289impl Display for DotEnvFamily {
290 fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
291 match self {
292 DotEnvFamily::Base => write!(f, ""),
293 DotEnvFamily::Secrets => write!(f, ".secrets"),
294 DotEnvFamily::Infra => write!(f, ".infra"),
295 DotEnvFamily::InfraSecrets => write!(f, ".infra.secrets"),
296 }
297 }
298}
299
300#[cfg(test)]
301mod tests {
302 use super::*;
303 use rstest::rstest;
304 use serial_test::serial;
305 use std::env;
306
307 type TestEnv = Environment<ImplicitIndex>;
309
310 fn expected_vars(env: &TestEnv) -> Vec<(String, String)> {
311 let suffix = match env.name {
312 EnvironmentName::Development => "DEV",
313 EnvironmentName::Staging => "STAG",
314 EnvironmentName::Test => "TEST",
315 EnvironmentName::Production => "PROD",
316 };
317
318 vec![
319 ("FROM_DOTENV".to_string(), ".env".to_string()),
320 (
321 format!("FROM_DOTENV_{suffix}").to_string(),
322 env.get_dotenv_filename(),
323 ),
324 (
325 format!("FROM_DOTENV_{suffix}_SECRETS").to_string(),
326 env.get_dotenv_secrets_filename(),
327 ),
328 ]
329 }
330
331 #[rstest]
332 #[case::dev(TestEnv::new(EnvironmentName::Development, 1))]
333 #[case::stag(TestEnv::new(EnvironmentName::Staging, 1))]
334 #[case::test(TestEnv::new(EnvironmentName::Test, 1))]
335 #[case::prod(TestEnv::new(EnvironmentName::Production, 1))]
336 #[serial]
337 fn test_environment_load(#[case] env: TestEnv) {
338 for (key, _) in expected_vars(&env) {
340 env::remove_var(key);
341 }
342
343 env.load(Some("../.."))
345 .expect("Environment load should succeed");
346
347 for (key, expected_value) in expected_vars(&env) {
349 let actual_value =
350 env::var(&key).unwrap_or_else(|_| panic!("Missing expected env var: {key}"));
351 assert_eq!(
352 actual_value, expected_value,
353 "Environment variable {key} should be set to {expected_value} but was {actual_value}"
354 );
355 }
356 }
357
358 #[rstest]
359 #[case::dev(TestEnv::new(EnvironmentName::Development, 1))]
360 #[case::stag(TestEnv::new(EnvironmentName::Staging, 1))]
361 #[case::test(TestEnv::new(EnvironmentName::Test, 1))]
362 #[case::prod(TestEnv::new(EnvironmentName::Production, 1))]
363 #[serial]
364 fn test_environment_merge_env_files(#[case] env: TestEnv) {
365 for (key, _) in expected_vars(&env) {
367 env::remove_var(key);
368 }
369 let merged_path = env
371 .merge_env_files()
372 .expect("merge_env_files should succeed");
373 assert!(
374 merged_path.exists(),
375 "Merged env file should exist at {}",
376 merged_path.display()
377 );
378 let mut merged_map: std::collections::HashMap<String, String> =
380 std::collections::HashMap::new();
381 for item in
382 dotenvy::from_path_iter(&merged_path).expect("Reading merged env file should succeed")
383 {
384 let (key, value) = item.expect("Parsing key/value from merged env file should succeed");
385 merged_map.insert(key, value);
386 }
387 for (key, expected_value) in expected_vars(&env) {
389 let actual_value = merged_map
390 .get(&key)
391 .unwrap_or_else(|| panic!("Missing expected merged env var: {key}"));
392 assert_eq!(
393 actual_value, &expected_value,
394 "Merged env var {key} should be {expected_value} but was {actual_value}"
395 );
396 }
397 }
398
399 #[test]
400 #[serial]
401 fn test_environment_merge_env_files_expansion() {
402 let env = Environment::<ImplicitIndex>::new(EnvironmentName::Staging, 1);
403 env::remove_var("LOG_LEVEL_TEST");
405 env::remove_var("RUST_LOG_TEST");
406 env::remove_var("RUST_LOG_STAG_TEST");
407
408 let merged_path = env
409 .merge_env_files()
410 .expect("merge_env_files should succeed");
411 let mut merged_map: std::collections::HashMap<String, String> =
412 std::collections::HashMap::new();
413 for item in
414 dotenvy::from_path_iter(&merged_path).expect("Reading merged env file should succeed")
415 {
416 let (key, value) = item.expect("Parsing key/value from merged env file should succeed");
417 merged_map.insert(key, value);
418 }
419
420 let log_level = merged_map
421 .get("LOG_LEVEL_TEST")
422 .expect("LOG_LEVEL_TEST should be present in merged env file");
423 let rust_log = merged_map
424 .get("RUST_LOG_TEST")
425 .expect("RUST_LOG_TEST should be present in merged env file");
426
427 assert!(
429 !rust_log.contains("${LOG_LEVEL_TEST}"),
430 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL}}', got: {rust_log}"
431 );
432 assert!(
434 rust_log.contains(log_level),
435 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
436 );
437 let rust_log_stag = merged_map
439 .get("RUST_LOG_STAG_TEST")
440 .expect("RUST_LOG_STAG_TEST should be present in merged env file");
441 assert!(
443 !rust_log_stag.contains("${LOG_LEVEL_TEST}"),
444 "RUST_LOG_STAG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log_stag}"
445 );
446 assert!(
448 rust_log_stag.contains(log_level),
449 "RUST_LOG_STAG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_STAG_TEST={rust_log_stag}"
450 );
451 }
452}