tracel_xtask/
environment.rs1use std::{collections::HashMap, fmt::Write as _, path::PathBuf};
2
3use strum::{Display, EnumIter, EnumString};
4
5use crate::{group_error, group_info, utils::git};
6
7#[derive(EnumString, EnumIter, Default, Display, Clone, Debug, PartialEq, clap::ValueEnum)]
8#[strum(serialize_all = "lowercase")]
9pub enum Environment {
10 #[default]
12 #[strum(serialize = "dev")]
13 #[clap(alias = "dev")]
14 Development,
15 #[strum(serialize = "stag")]
17 #[clap(alias = "stag")]
18 Staging,
19 #[strum(serialize = "test")]
21 #[clap(alias = "test")]
22 Test,
23 #[strum(serialize = "prod")]
25 #[clap(alias = "prod")]
26 Production,
27}
28
29impl Environment {
30 pub fn get_dotenv_filename(&self) -> String {
31 format!(".env.{self}")
32 }
33
34 pub fn get_dotenv_secrets_filename(&self) -> String {
35 format!("{}.secrets", self.get_dotenv_filename())
36 }
37
38 pub fn get_env_files(&self) -> [String; 3] {
39 let filename = self.get_dotenv_filename();
40 let secrets_filename = self.get_dotenv_secrets_filename();
41 [
42 ".env".to_owned(),
43 filename.to_owned(),
44 secrets_filename.to_owned(),
45 ]
46 }
47
48 pub fn load(&self, prefix: Option<&str>) -> anyhow::Result<()> {
52 let files = self.get_env_files();
53 files.iter().for_each(|f| {
54 let path = if let Some(p) = prefix {
55 std::path::PathBuf::from(p).join(f)
56 } else {
57 std::path::PathBuf::from(f)
58 };
59 if path.exists() {
60 match dotenvy::from_filename(f) {
61 Ok(_) => {
62 group_info!("loading '{}' file...", f);
63 }
64 Err(e) => {
65 group_error!("error while loading '{}' file ({})", f, e);
66 }
67 }
68 } else {
69 group_info!("environment file '{}' does not exist, skipping...", f);
70 }
71 });
72 Ok(())
73 }
74
75 pub fn merge_env_files(&self) -> anyhow::Result<PathBuf> {
77 let repo_root = git::git_repo_root_or_cwd()?;
78 let files = self.get_env_files();
79 let mut merged: HashMap<String, String> = HashMap::new();
82 for filename in files {
83 let path = repo_root.join(&filename);
84 if !path.exists() {
85 eprintln!(
86 "⚠️ Warning: environment file '{}' ({}) not found, skipping...",
87 filename,
88 path.display()
89 );
90 continue;
91 }
92 for item in dotenvy::from_path_iter(&path)? {
93 let (key, value) = item?;
94 std::env::set_var(&key, &value);
95 merged.insert(key, value);
96 }
97 }
98 let mut keys: Vec<_> = merged.keys().cloned().collect();
99 keys.sort();
100 let mut out = String::new();
102 for key in keys {
103 let val = &merged[&key];
104 writeln!(&mut out, "{key}={val}")?;
105 }
106 let tmp_path = std::env::temp_dir().join(format!("merged-env-{}.tmp", std::process::id()));
107 std::fs::write(&tmp_path, out)?;
108 Ok(tmp_path)
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115 use rstest::rstest;
116 use serial_test::serial;
117 use std::env;
118
119 fn expected_vars(env: &Environment) -> Vec<(String, String)> {
120 let suffix = match env {
121 Environment::Development => "DEV",
122 Environment::Staging => "STAG",
123 Environment::Test => "TEST",
124 Environment::Production => "PROD",
125 };
126
127 vec![
128 ("FROM_DOTENV".to_string(), ".env".to_string()),
129 (
130 format!("FROM_DOTENV_{suffix}").to_string(),
131 env.get_dotenv_filename(),
132 ),
133 (
134 format!("FROM_DOTENV_{suffix}_SECRETS").to_string(),
135 env.get_dotenv_secrets_filename(),
136 ),
137 ]
138 }
139
140 #[rstest]
141 #[case::dev(Environment::Development)]
142 #[case::stag(Environment::Staging)]
143 #[case::test(Environment::Test)]
144 #[case::prod(Environment::Production)]
145 #[serial]
146 fn test_environment_load(#[case] env: Environment) {
147 for (key, _) in expected_vars(&env) {
149 env::remove_var(key);
150 }
151
152 env.load(Some("../.."))
154 .expect("Environment load should succeed");
155
156 for (key, expected_value) in expected_vars(&env) {
158 let actual_value =
159 env::var(&key).unwrap_or_else(|_| panic!("Missing expected env var: {key}"));
160 assert_eq!(
161 actual_value, expected_value,
162 "Environment variable {key} should be set to {expected_value} but was {actual_value}"
163 );
164 }
165 }
166
167 #[rstest]
168 #[case::dev(Environment::Development)]
169 #[case::stag(Environment::Staging)]
170 #[case::test(Environment::Test)]
171 #[case::prod(Environment::Production)]
172 #[serial]
173 fn test_environment_merge_env_files(#[case] env: Environment) {
174 for (key, _) in expected_vars(&env) {
176 env::remove_var(key);
177 }
178 let merged_path = env
180 .merge_env_files()
181 .expect("merge_env_files should succeed");
182 assert!(
183 merged_path.exists(),
184 "Merged env file should exist at {}",
185 merged_path.display()
186 );
187 let mut merged_map: std::collections::HashMap<String, String> =
189 std::collections::HashMap::new();
190 for item in
191 dotenvy::from_path_iter(&merged_path).expect("Reading merged env file should succeed")
192 {
193 let (key, value) = item.expect("Parsing key/value from merged env file should succeed");
194 merged_map.insert(key, value);
195 }
196 for (key, expected_value) in expected_vars(&env) {
198 let actual_value = merged_map
199 .get(&key)
200 .unwrap_or_else(|| panic!("Missing expected merged env var: {key}"));
201 assert_eq!(
202 actual_value, &expected_value,
203 "Merged env var {key} should be {expected_value} but was {actual_value}"
204 );
205 }
206 }
207
208 #[test]
209 #[serial]
210 fn test_environment_merge_env_files_expansion() {
211 let env = Environment::Staging;
212 env::remove_var("LOG_LEVEL_TEST");
214 env::remove_var("RUST_LOG_TEST");
215 env::remove_var("RUST_LOG_STAG_TEST");
216
217 let merged_path = env
218 .merge_env_files()
219 .expect("merge_env_files should succeed");
220 let mut merged_map: std::collections::HashMap<String, String> =
221 std::collections::HashMap::new();
222 for item in
223 dotenvy::from_path_iter(&merged_path).expect("Reading merged env file should succeed")
224 {
225 let (key, value) = item.expect("Parsing key/value from merged env file should succeed");
226 merged_map.insert(key, value);
227 }
228
229 let log_level = merged_map
230 .get("LOG_LEVEL_TEST")
231 .expect("LOG_LEVEL_TEST should be present in merged env file");
232 let rust_log = merged_map
233 .get("RUST_LOG_TEST")
234 .expect("RUST_LOG_TEST should be present in merged env file");
235
236 assert!(
238 !rust_log.contains("${LOG_LEVEL_TEST}"),
239 "RUST_LOG_TEST should not contain the raw placeholder '${{LOG_LEVEL}}', got: {rust_log}"
240 );
241 assert!(
243 rust_log.contains(log_level),
244 "RUST_LOG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_TEST={rust_log}"
245 );
246 let rust_log_stag = merged_map
248 .get("RUST_LOG_STAG_TEST")
249 .expect("RUST_LOG_STAG_TEST should be present in merged env file");
250 assert!(
252 !rust_log_stag.contains("${LOG_LEVEL_TEST}"),
253 "RUST_LOG_STAG_TEST should not contain the raw placeholder '${{LOG_LEVEL_TEST}}', got: {rust_log_stag}"
254 );
255 assert!(
257 rust_log_stag.contains(log_level),
258 "RUST_LOG_STAG_TEST should contain the expanded LOG_LEVEL_TEST value; LOG_LEVEL_TEST={log_level}, RUST_LOG_STAG_TEST={rust_log_stag}"
259 );
260 }
261}