tracel_xtask/
environment.rs

1use 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    /// Development environment (alias: dev).
11    #[default]
12    #[strum(serialize = "dev")]
13    #[clap(alias = "dev")]
14    Development,
15    /// Staging environment (alias: stag).
16    #[strum(serialize = "stag")]
17    #[clap(alias = "stag")]
18    Staging,
19    /// Testing environment (alias: test).
20    #[strum(serialize = "test")]
21    #[clap(alias = "test")]
22    Test,
23    /// Production environment (alias: prod).
24    #[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    /// Load the .env environment files family.
49    /// You don't need to call it in an xtask binary but can be useful
50    /// in a non-xtask binary.
51    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    /// Merge all the .env files of the environment with all variable expanded
76    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        // merged set of env vars, the later files override earlier ones
80        // we sort keys to have a more deterministic merged file result
81        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        // write merged file
101        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        // Remove possible prior values
148        for (key, _) in expected_vars(&env) {
149            env::remove_var(key);
150        }
151
152        // Run the actual function under test
153        env.load(Some("../.."))
154            .expect("Environment load should succeed");
155
156        // Assert each expected env var is present and has the correct value
157        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        // Make sure we start from a clean state
175        for (key, _) in expected_vars(&env) {
176            env::remove_var(key);
177        }
178        // Generate the merged env file
179        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        // Parse the merged file as a .env file again
188        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        // All the vars we expect from the individual files must be present
197        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        // Clean any prior values that could interfere
213        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        // 1) We should not see the raw placeholder anymore
237        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        // 2) The expanded LOG_LEVEL_TEST value should appear in RUST_LOG_TEST
242        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        // Cross-file expansion with RUST_LOG_STAG_TEST that references LOG_LEVEL_TEST from base .env
247        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        // 3) No raw placeholder in the cross-file value either
251        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        // 4) The expanded LOG_LEVEL_TEST value should appear in RUST_LOG_STAG_TEST
256        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}