tracel_xtask/
environment.rs

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/// Implicit index which means that index '1' is omitted in display.
13#[derive(Clone, Debug, PartialEq, Default)]
14pub struct ImplicitIndex;
15
16/// Explicit index which means that index is always in display.
17#[derive(Clone, Debug, PartialEq, Default)]
18pub struct ExplicitIndex;
19
20/// Style for how to format `{base}{index}`.
21pub 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    /// Turn an non explicit environment into an explicit one.
64    /// An explicit environment will always append the index number to its display names.
65    /// Whereas a non-explicit one (default) only append the index if it is different than 1.
66    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    /// Return the two .env files for a given family:
105    /// - Base: `.env`, `.env.<env_medium>`
106    /// - Secrets: `.env.secrets`, `.env.<env_medium>.secrets`
107    /// - Infra: `.env.infra`, `.env.<env_medium>.infra`
108    /// - InfraSecrets: `.env.infra.secrets`, `.env.<env_medium>.infra.secrets`
109    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            // Base
114            [".env".to_owned(), format!(".env.{env_medium}")]
115        } else {
116            // Other families
117            [
118                format!(".env{suffix}"),
119                format!(".env.{env_medium}{suffix}"),
120            ]
121        }
122    }
123
124    /// Backward-compatible helper for env-specific base filename.
125    pub fn get_dotenv_filename(&self) -> String {
126        // second element of the Base family
127        self.dotenv_files_for_family(DotEnvFamily::Base)[1].clone()
128    }
129
130    /// Backward-compatible helper for env-specific secrets filename.
131    pub fn get_dotenv_secrets_filename(&self) -> String {
132        // second element of the Secrets family
133        self.dotenv_files_for_family(DotEnvFamily::Secrets)[1].clone()
134    }
135
136    /// All possible .env files for this environment, by family.
137    /// Order matters: later files override earlier ones.
138    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    /// Load the .env environment files family.
145    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    /// Merge all the .env files of the environment with all variable expanded
169    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        // merged set of env vars, the later files override earlier ones
173        // we sort keys to have a more deterministic merged file result
174        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        // write merged file
194        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    /// Development environment (alias: dev).
209    #[default]
210    #[clap(alias = "dev")]
211    Development,
212    /// Staging environment (alias: stag).
213    #[clap(alias = "stag")]
214    Staging,
215    /// Testing environment (alias: test).
216    #[clap(alias = "test")]
217    Test,
218    /// Production environment (alias: prod).
219    #[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    // For tests we always use the implicit style
308    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        // Remove possible prior values
339        for (key, _) in expected_vars(&env) {
340            env::remove_var(key);
341        }
342
343        // Run the actual function under test
344        env.load(Some("../.."))
345            .expect("Environment load should succeed");
346
347        // Assert each expected env var is present and has the correct value
348        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        // Make sure we start from a clean state
366        for (key, _) in expected_vars(&env) {
367            env::remove_var(key);
368        }
369        // Generate the merged env file
370        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        // Parse the merged file as a .env file again
379        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        // All the vars we expect from the individual files must be present
388        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        // Clean any prior values that could interfere
404        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        // 1) We should not see the raw placeholder anymore
428        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        // 2) The expanded LOG_LEVEL_TEST value should appear in RUST_LOG_TEST
433        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        // Cross-file expansion with RUST_LOG_STAG_TEST that references LOG_LEVEL_TEST from base .env
438        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        // 3) No raw placeholder in the cross-file value either
442        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        // 4) The expanded LOG_LEVEL_TEST value should appear in RUST_LOG_STAG_TEST
447        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}