Skip to main content

cli_engine/
environments.rs

1//! First-class environment definitions and layered resolution.
2//!
3//! An [`Environments`] value holds compiled-in environment TOML tables and,
4//! optionally, an `environments.toml` file plus a fallback for names known to
5//! neither. Resolving a name merges those layers (later wins, shallow —
6//! no deep-merging into nested tables/arrays) into one [`EnvSource`], then
7//! threads it (plus an app-scoped environment-variable source) through an
8//! [`crate::env_config::EnvConfig`] struct's own assembly instructions.
9//!
10//! This module owns no per-field knowledge at all. Consumers can create structs
11//! that use `#[derive(EnvConfig)]` to create strongly-typed environment
12//! configs populated by [`Environments`].
13
14use std::collections::BTreeMap;
15use std::sync::Arc;
16
17use crate::env_config::{EnvConfig, EnvConfigError, EnvSource, EnvVarSource};
18use crate::{Result, error::CliCoreError};
19
20/// A consumer-supplied callback that defines an environment purely from outside
21/// the compiled/file layers (for example, from environment variables) when a
22/// name isn't known to either. See [`Environments::with_fallback`].
23type EnvironmentFallback = Arc<dyn Fn(&str) -> Option<EnvTable> + Send + Sync>;
24
25/// A compiled-in environment's raw configuration, expressed as a TOML table so
26/// it can merge with the `environments.toml` file layer on equal footing.
27/// Values accepted by [`EnvTable::with`] cover the common Rust literal types
28/// (`&str`/`String`/`bool`/integers/floats and `Vec<T>` of those) via
29/// [`Into<toml::Value>`], so a compiled-in environment reads like ordinary
30/// Rust, not embedded TOML text.
31#[derive(Debug, Clone, Default)]
32pub struct EnvTable(toml::Table);
33
34impl EnvTable {
35    /// Creates an empty table.
36    #[must_use]
37    pub fn new() -> Self {
38        Self(toml::Table::new())
39    }
40
41    /// Sets `key` to `value`.
42    #[must_use]
43    pub fn with(mut self, key: impl Into<String>, value: impl Into<toml::Value>) -> Self {
44        self.0.insert(key.into(), value.into());
45        self
46    }
47}
48
49/// Engine-owned environment system: compiled/file tables + resolution +
50/// active-env state.
51#[derive(Clone)]
52pub struct Environments {
53    default: String,
54    compiled: BTreeMap<String, EnvTable>,
55    use_config_file: bool,
56    app_id: String,
57    file_path_override: Option<std::path::PathBuf>,
58    fallback: Option<EnvironmentFallback>,
59}
60
61impl std::fmt::Debug for Environments {
62    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63        f.debug_struct("Environments")
64            .field("default", &self.default)
65            .field("compiled", &self.compiled)
66            .field("use_config_file", &self.use_config_file)
67            .field("app_id", &self.app_id)
68            .field("file_path_override", &self.file_path_override)
69            .field("fallback", &self.fallback.is_some())
70            .finish()
71    }
72}
73
74impl Environments {
75    /// Creates an environment system with the given default environment name.
76    ///
77    /// If `default_env` is sourced from the consumer's own persisted state,
78    /// read that state *raw* rather than through anything that calls back
79    /// into [`resolve`](Self::resolve) or a lazily-initialized singleton's
80    /// `instance()`. A consumer wiring a lazy singleton whose default depends
81    /// on its own config can otherwise deadlock re-entering that singleton's
82    /// own initialization while it is still being constructed.
83    #[must_use]
84    pub fn new(default_env: impl Into<String>) -> Self {
85        Self {
86            default: default_env.into(),
87            compiled: BTreeMap::new(),
88            use_config_file: false,
89            app_id: String::new(),
90            file_path_override: None,
91            fallback: None,
92        }
93    }
94
95    /// Registers a compiled-in environment's table, merging onto whatever is
96    /// already registered for `name` (later call wins, key-by-key — same
97    /// overlay rule as the `environments.toml` file layer). Accepts an
98    /// [`EnvTable`] directly, or any `#[derive(EnvConfig)]` struct *value* —
99    /// the derive generates `impl From<Self> for EnvTable`, so a compiled-in
100    /// environment can be written as a plain typed struct instead of a
101    /// stringly-keyed builder:
102    ///
103    /// ```
104    /// use cli_engine::{EnvConfig, environments::Environments};
105    ///
106    /// #[derive(Default, EnvConfig)]
107    /// struct ApiConfig {
108    ///     api_url: String,
109    ///     #[env_config(default = String::new())]
110    ///     client_id: String,
111    /// }
112    ///
113    /// let environments = Environments::new("prod").with_environment(
114    ///     "prod",
115    ///     ApiConfig { api_url: "https://api.example.com".to_owned(), client_id: "abc".to_owned() },
116    /// );
117    /// # let _ = environments;
118    /// ```
119    ///
120    /// A struct value has no "absent" state — every field is written, even
121    /// ones left at their type's default — so splitting concerns across
122    /// several smaller structs (each covering only the keys it cares about)
123    /// composes better than one struct with placeholder values for fields it
124    /// doesn't set; merging (rather than replacing) is what makes that
125    /// composition possible across repeated calls for the same `name`.
126    #[must_use]
127    pub fn with_environment(mut self, name: impl Into<String>, table: impl Into<EnvTable>) -> Self {
128        let table = table.into();
129        self.compiled
130            .entry(name.into())
131            .and_modify(|existing| overlay(&mut existing.0, &table.0))
132            .or_insert(table);
133        self
134    }
135
136    /// Enables loading `<config-dir>/<app_id>/environments.toml` during resolution.
137    #[must_use]
138    pub fn with_config_file(mut self, enabled: bool) -> Self {
139        self.use_config_file = enabled;
140        self
141    }
142
143    /// Sets the application id used to locate the config file and as the
144    /// prefix for the app-scoped environment-variable override tier (see
145    /// [`resolve`](Self::resolve)).
146    ///
147    /// The consumer must set this to the same `app_id` passed to
148    /// [`CliConfig::new`](crate::CliConfig::new) before sharing the
149    /// [`Environments`] with both
150    /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) and
151    /// `PkceAuthProvider::with_environments` (with the `pkce-auth` feature),
152    /// or [`config_file_path`](Self::config_file_path) returns `None` and the
153    /// `environments.toml` file layer silently resolves empty.
154    #[must_use]
155    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
156        self.app_id = app_id.into();
157        self
158    }
159
160    /// Test/advanced seam: force the environments file path.
161    #[must_use]
162    pub fn with_config_file_path_override(mut self, path: std::path::PathBuf) -> Self {
163        self.file_path_override = Some(path);
164        self.use_config_file = true;
165        self
166    }
167
168    /// Registers an opt-in seam for defining an environment purely from
169    /// outside the compiled/file layers.
170    ///
171    /// [`resolve`](Self::resolve)/[`source`](Self::source) — and therefore
172    /// every path built on them, including the built-in `--env` flag and the
173    /// `env` command group — consult `fallback` with the requested name
174    /// whenever that name is unknown to both the compiled-in and
175    /// `environments.toml` layers. Returning `Some(table)` lets a brand-new,
176    /// never-declared name resolve (typically by having `fallback` read its
177    /// own `<NAME>_*` environment variables and build a table from them);
178    /// returning `None` preserves the existing "unknown environment" error.
179    ///
180    /// The returned [`EnvTable`] is treated the same as a compiled-in table:
181    /// it does not skip the `environments.toml` layer, which still merges on
182    /// top of it (later wins). `fallback` is never consulted for a name
183    /// already known to the compiled-in or file layer.
184    #[must_use]
185    pub fn with_fallback<F>(mut self, fallback: F) -> Self
186    where
187        F: Fn(&str) -> Option<EnvTable> + Send + Sync + 'static,
188    {
189        self.fallback = Some(Arc::new(fallback));
190        self
191    }
192
193    /// The default environment name.
194    #[must_use]
195    pub fn default_env(&self) -> &str {
196        &self.default
197    }
198
199    /// The app id set via [`with_app_id`](Self::with_app_id), or empty if
200    /// never set. Exposed so a consumer building its own
201    /// [`crate::env_config::SourceChain`] (for example `PkceAuthProvider`,
202    /// which has fallback tiers outside this system's own compiled/file
203    /// layers) can reuse the same app-scoped environment-variable prefix that
204    /// [`resolve`](Self::resolve) uses internally.
205    #[must_use]
206    pub fn app_id(&self) -> &str {
207        &self.app_id
208    }
209
210    /// Enumerable environment names (compiled-in + file-defined), sorted.
211    ///
212    /// Any error from reading or parsing the environments file (missing file,
213    /// permission/read error, or malformed TOML) is silently swallowed and only
214    /// the compiled-in names are returned. Use [`source`](Self::source) or
215    /// [`resolve`](Self::resolve) when you need those errors surfaced.
216    ///
217    /// # Blocking
218    ///
219    /// When the config-file layer is enabled, this performs synchronous
220    /// filesystem I/O to read and parse `environments.toml` (like
221    /// [`resolve`](Self::resolve)). Avoid calling it repeatedly on a
222    /// latency-sensitive async path.
223    #[must_use]
224    pub fn list(&self) -> Vec<String> {
225        let mut names: std::collections::BTreeSet<String> = self.compiled.keys().cloned().collect();
226        if let Ok(file) = self.file_tables() {
227            names.extend(file.into_keys());
228        }
229        names.into_iter().collect()
230    }
231
232    /// Builds the merged [`EnvSource`] for `name`: the compiled-in table
233    /// overlaid by the `environments.toml` file table for the same name
234    /// (file wins key-by-key), or a registered [`with_fallback`](Self::with_fallback)
235    /// table when `name` is unknown to both.
236    ///
237    /// This is the seam behind [`resolve`](Self::resolve), exposed directly
238    /// for generic introspection (for example, `env info` printing whatever
239    /// keys an environment's merged table actually has) without needing to
240    /// know about any particular [`EnvConfig`] struct.
241    ///
242    /// # Blocking
243    ///
244    /// When the config-file layer is enabled, this performs synchronous
245    /// filesystem I/O to read and parse `environments.toml`. Avoid calling it
246    /// repeatedly on a latency-sensitive async path.
247    ///
248    /// # Errors
249    ///
250    /// Returns an error when `name` is not known to any layer (including a
251    /// registered [`with_fallback`](Self::with_fallback)) or when the
252    /// environments file exists but cannot be read or parsed.
253    pub fn source(&self, name: &str) -> Result<EnvSource> {
254        let compiled = self.compiled.get(name);
255        let mut all_file_tables = self.file_tables()?;
256        let file = all_file_tables.remove(name);
257        // The fallback only ever introduces a name unknown to the compiled-in
258        // and file layers; a name known to either never consults it.
259        let fallback = if compiled.is_none() && file.is_none() {
260            self.fallback.as_ref().and_then(|f| f(name))
261        } else {
262            None
263        };
264        if compiled.is_none() && file.is_none() && fallback.is_none() {
265            let mut known: std::collections::BTreeSet<String> =
266                self.compiled.keys().cloned().collect();
267            known.extend(all_file_tables.into_keys());
268            let known_list: Vec<String> = known.into_iter().collect();
269            let known_display = if known_list.is_empty() {
270                "(none defined)".to_owned()
271            } else {
272                known_list.join(", ")
273            };
274            return Err(CliCoreError::message(format!(
275                "unknown environment {name:?}; known: {known_display}"
276            )));
277        }
278        let mut merged = toml::Table::new();
279        if let Some(table) = compiled {
280            overlay(&mut merged, &table.0);
281        }
282        if let Some(table) = &fallback {
283            overlay(&mut merged, &table.0);
284        }
285        if let Some(table) = &file {
286            overlay(&mut merged, table);
287        }
288        Ok(EnvSource::new(name, merged))
289    }
290
291    /// Resolves `name` into a typed [`EnvConfig`] section: the common path,
292    /// `T::assemble` over a chain of the app-scoped environment-variable
293    /// source (see the design note below) and `name`'s merged [`EnvSource`].
294    ///
295    /// # Environment-variable overrides are app-scoped, not environment-scoped
296    ///
297    /// A field's `#[env_config(env = "SUFFIX")]` checks
298    /// `<APP_ID_UPPER>_<SUFFIX>` here — not `<NAME_UPPER>_<SUFFIX>`. At any
299    /// single resolution there is exactly one environment being asked about,
300    /// so scoping the override variable by environment name buys nothing an
301    /// app-scoped name doesn't already give for free, while a bare
302    /// environment name as a prefix (`PROD_`, `DEV_`) is a real collision
303    /// risk in a shared shell/CI environment that an app-scoped prefix
304    /// (`GDDY_...`) avoids categorically. A consumer needing extra fallback
305    /// tiers outside this system's own compiled/file/fallback layers (for
306    /// example a legacy provider-scoped env var) builds its own
307    /// [`crate::env_config::SourceChain`] and calls `T::assemble` directly —
308    /// see `PkceAuthProvider`.
309    ///
310    /// # Blocking
311    ///
312    /// See [`source`](Self::source).
313    ///
314    /// # Errors
315    ///
316    /// Returns an error under the same conditions as [`source`](Self::source),
317    /// or when a field's present value fails to convert to its type, or a
318    /// required field has no value in any source and no default (see
319    /// [`EnvConfigError`]).
320    pub fn resolve<T: EnvConfig>(&self, name: &str) -> std::result::Result<T, EnvConfigError> {
321        let source = self.source(name)?;
322        if self.app_id.is_empty() {
323            let chain = crate::env_config::SourceChain::new().push(&source);
324            T::assemble(&chain)
325        } else {
326            let app_scoped = EnvVarSource {
327                prefix: self.app_id.to_uppercase(),
328            };
329            let chain = crate::env_config::SourceChain::new()
330                .push(&app_scoped)
331                .push(&source);
332            T::assemble(&chain)
333        }
334    }
335
336    /// Path to `environments.toml` next to the engine config file, or `None`
337    /// when the file layer is disabled or the config dir cannot be determined.
338    #[must_use]
339    pub fn config_file_path(&self) -> Option<std::path::PathBuf> {
340        if !self.use_config_file {
341            return None;
342        }
343        let config = crate::config::config_file_path(&self.app_id)?;
344        Some(config.with_file_name("environments.toml"))
345    }
346
347    fn effective_file_path(&self) -> Option<std::path::PathBuf> {
348        if let Some(path) = &self.file_path_override {
349            return Some(path.clone());
350        }
351        self.config_file_path()
352    }
353
354    /// Parses the environments file into a name -> table map. Missing file = empty.
355    ///
356    /// Also accepts a legacy, undocumented nested top-level `[environments.prod]`
357    /// table alongside the recommended flat `[prod]` shape, so files already
358    /// written against that shape keep parsing without being rewritten. When a
359    /// name appears under both, the nested entry's keys win, per-key.
360    fn file_tables(&self) -> Result<BTreeMap<String, toml::Table>> {
361        let Some(path) = self.effective_file_path() else {
362            return Ok(BTreeMap::new());
363        };
364        let text = match std::fs::read_to_string(&path) {
365            Ok(text) => text,
366            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
367            Err(err) => {
368                return Err(CliCoreError::message(format!(
369                    "reading environments file {path:?}: {err}"
370                )));
371            }
372        };
373        let top: toml::Table = toml::from_str(&text).map_err(|err| {
374            CliCoreError::message(format!("parsing environments file {path:?}: {err}"))
375        })?;
376        let mut tables: BTreeMap<String, toml::Table> = BTreeMap::new();
377        for (name, value) in &top {
378            if name == "environments" {
379                continue;
380            }
381            if let Some(table) = value.as_table() {
382                tables.insert(name.clone(), table.clone());
383            }
384        }
385        if let Some(nested) = top.get("environments").and_then(toml::Value::as_table) {
386            for (name, value) in nested {
387                let Some(table) = value.as_table() else {
388                    continue;
389                };
390                match tables.get_mut(name) {
391                    Some(existing) => overlay(existing, table),
392                    None => {
393                        tables.insert(name.clone(), table.clone());
394                    }
395                }
396            }
397        }
398        Ok(tables)
399    }
400
401    /// Config-file key under which the sticky active environment is stored.
402    pub(crate) const ACTIVE_ENV_KEY: &'static str = "environment.active";
403
404    /// Reads the persisted active environment from a loaded config file.
405    #[must_use]
406    pub fn active_from_config(config: &crate::config::ConfigFile) -> Option<String> {
407        config.get(Self::ACTIVE_ENV_KEY)
408    }
409
410    /// Resolves the active environment name with precedence:
411    /// explicit `--env` override > persisted active > configured default.
412    #[must_use]
413    pub fn effective_active(
414        &self,
415        flag: Option<&str>,
416        config: &crate::config::ConfigFile,
417    ) -> String {
418        flag.map(ToOwned::to_owned)
419            .or_else(|| Self::active_from_config(config))
420            .unwrap_or_else(|| self.default.clone())
421    }
422
423    /// Persists `name` as the active environment (loads, sets, saves a fresh
424    /// config file for `app_id`). Validates that `name` resolves first.
425    ///
426    /// # Errors
427    ///
428    /// Returns an error when `name` does not resolve to a known environment, or
429    /// when the config file cannot be written.
430    pub fn persist_active(&self, name: &str) -> Result<()> {
431        self.source(name)?; // reject unknown names
432        // Persisting writes the engine config file, which is keyed by app_id.
433        // Validate it up front so a missing/invalid app_id yields a clear,
434        // actionable error rather than a misleading "no config path" failure
435        // from ConfigFile::save() that points at XDG/HOME.
436        if crate::config::config_file_path(&self.app_id).is_none() {
437            return Err(CliCoreError::message(format!(
438                "cannot persist active environment {name:?}: the environment system has no usable app_id; \
439                 set one via Environments::with_app_id (matching the CliConfig app_id)"
440            )));
441        }
442        let mut config = crate::config::ConfigFile::load(&self.app_id);
443        config.set(Self::ACTIVE_ENV_KEY, name)?;
444        config.save()
445    }
446}
447
448/// Copies every key from `src` into `dst`, overwriting; shallow — a nested
449/// table or array value replaces `dst`'s prior value wholesale, it is never
450/// merged into.
451fn overlay(dst: &mut toml::Table, src: &toml::Table) {
452    for (key, value) in src {
453        dst.insert(key.clone(), value.clone());
454    }
455}
456
457#[cfg(test)]
458#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
459mod tests {
460    use super::*;
461    use cli_engine_macros::EnvConfig as DeriveEnvConfig;
462
463    use std::sync::Mutex;
464    static ENV_LOCK: Mutex<()> = Mutex::new(());
465
466    /// RAII guard that removes an env var on drop, even if a test panics.
467    struct EnvGuard(&'static str);
468    impl Drop for EnvGuard {
469        fn drop(&mut self) {
470            // SAFETY: test holds ENV_LOCK; clean up on any exit including panic.
471            unsafe { std::env::remove_var(self.0) }
472        }
473    }
474
475    #[derive(Debug, Clone, DeriveEnvConfig)]
476    struct OAuthLike {
477        client_id: String,
478        #[env_config(default = String::new())]
479        auth_url: String,
480        #[env_config(default = String::new())]
481        token_url: String,
482        #[env_config(default = Vec::new())]
483        scopes: Vec<String>,
484    }
485
486    #[derive(Debug, Clone, DeriveEnvConfig)]
487    struct ApiLike {
488        #[env_config(env = "API_URL")]
489        api_url: String,
490    }
491
492    fn sample() -> Environments {
493        Environments::new("prod")
494            .with_environment(
495                "prod",
496                EnvTable::new()
497                    .with("client_id", "prod-client")
498                    .with("auth_url", "https://api.example.com/authorize")
499                    .with("token_url", "https://api.example.com/token")
500                    .with("scopes", vec!["openid".to_owned()])
501                    .with("api_url", "https://api.example.com"),
502            )
503            .with_environment("dev", EnvTable::new().with("client_id", "dev-client"))
504    }
505
506    #[test]
507    fn resolve_unknown_env_with_no_defs_uses_placeholder() {
508        let err = Environments::new("prod")
509            .source("prod")
510            .expect_err("nothing defined should fail");
511        let message = err.to_string();
512        assert!(
513            message.contains("(none defined)"),
514            "expected placeholder, got: {message}"
515        );
516    }
517
518    #[test]
519    fn persist_active_without_app_id_errors_clearly() {
520        // `persist_active` resolves "prod" internally, which reads the same
521        // PROD_* env vars the tests above mutate; take ENV_LOCK so a
522        // concurrently-running test can't inject a value (e.g. an invalid
523        // PROD_MIN_STAGE) that fails resolution for an unrelated reason.
524        let _g = ENV_LOCK
525            .lock()
526            .unwrap_or_else(std::sync::PoisonError::into_inner);
527        let err = sample()
528            .persist_active("prod")
529            .expect_err("persist without app_id should fail");
530        let message = err.to_string();
531        assert!(
532            message.contains("app_id"),
533            "error should mention app_id, got: {message}"
534        );
535    }
536
537    #[test]
538    fn builder_registers_compiled_environment() {
539        let envs = Environments::new("prod")
540            .with_environment("prod", EnvTable::new().with("client_id", "prod-client"));
541        assert_eq!(envs.default_env(), "prod");
542        assert_eq!(envs.list(), vec!["prod".to_owned()]);
543    }
544
545    /// A struct value works as a compiled-in environment, not just an
546    /// `EnvTable` — the derive's `impl From<T> for EnvTable` maps each field
547    /// by its own `key`.
548    #[test]
549    fn with_environment_accepts_a_typed_struct_value() {
550        let _g = ENV_LOCK
551            .lock()
552            .unwrap_or_else(std::sync::PoisonError::into_inner);
553        let envs = Environments::new("prod").with_environment(
554            "prod",
555            OAuthLike {
556                client_id: "prod-client".to_owned(),
557                auth_url: "https://api.example.com/authorize".to_owned(),
558                token_url: "https://api.example.com/token".to_owned(),
559                scopes: vec!["openid".to_owned()],
560            },
561        );
562        let oauth: OAuthLike = envs.resolve("prod").expect("prod resolves");
563        assert_eq!(oauth.client_id, "prod-client");
564        assert_eq!(oauth.auth_url, "https://api.example.com/authorize");
565    }
566
567    /// Two `with_environment` calls for the same name merge (later call wins
568    /// per key) rather than the second replacing the first outright — this is
569    /// what lets a consumer split one environment's compiled defaults across
570    /// several small structs instead of one struct with placeholder fields
571    /// for keys it doesn't set.
572    #[test]
573    fn with_environment_merges_across_repeated_calls_for_the_same_name() {
574        let _g = ENV_LOCK
575            .lock()
576            .unwrap_or_else(std::sync::PoisonError::into_inner);
577        let envs = Environments::new("prod")
578            .with_environment("prod", EnvTable::new().with("client_id", "prod-client"))
579            .with_environment(
580                "prod",
581                EnvTable::new().with("api_url", "https://api.example.com"),
582            );
583        let oauth: OAuthLike = envs.resolve("prod").expect("prod resolves");
584        assert_eq!(oauth.client_id, "prod-client", "first call's key survives");
585        let api: ApiLike = envs.resolve("prod").expect("prod resolves");
586        assert_eq!(
587            api.api_url, "https://api.example.com",
588            "second call's key is also present"
589        );
590    }
591
592    #[test]
593    fn resolve_returns_compiled_record() {
594        let _g = ENV_LOCK
595            .lock()
596            .unwrap_or_else(std::sync::PoisonError::into_inner);
597        let oauth: OAuthLike = sample().resolve("prod").expect("prod resolves");
598        assert_eq!(oauth.client_id, "prod-client");
599        assert_eq!(oauth.auth_url, "https://api.example.com/authorize");
600        assert_eq!(oauth.token_url, "https://api.example.com/token");
601        assert_eq!(oauth.scopes, vec!["openid".to_owned()]);
602    }
603
604    #[test]
605    fn resolve_unknown_env_errors_with_known_names() {
606        let _g = ENV_LOCK
607            .lock()
608            .unwrap_or_else(std::sync::PoisonError::into_inner);
609        let err = sample().source("nope").unwrap_err().to_string();
610        assert!(err.contains("nope"));
611        assert!(err.contains("prod") && err.contains("dev"));
612    }
613
614    #[test]
615    fn app_scoped_env_var_overrides_toml_value() {
616        let _g = ENV_LOCK
617            .lock()
618            .unwrap_or_else(std::sync::PoisonError::into_inner);
619        // SAFETY: serialized by ENV_LOCK; guard removes the var on any exit.
620        unsafe { std::env::set_var("MYAPP_API_URL", "https://override.example.com") };
621        let _guard = EnvGuard("MYAPP_API_URL");
622
623        let envs = sample().with_app_id("myapp");
624        let api: ApiLike = envs.resolve("prod").expect("prod resolves");
625        assert_eq!(api.api_url, "https://override.example.com");
626    }
627
628    #[test]
629    fn environments_file_path_sits_next_to_config() {
630        let envs = sample().with_app_id("gddy").with_config_file(true);
631        let path = envs.config_file_path().expect("path resolves with app id");
632        assert!(path.ends_with("gddy/environments.toml"), "got {path:?}");
633    }
634
635    #[test]
636    fn file_layer_overrides_compiled_and_adds_custom_env() {
637        let _g = ENV_LOCK
638            .lock()
639            .unwrap_or_else(std::sync::PoisonError::into_inner);
640        let dir = tempfile::tempdir().expect("tempdir");
641        let file = dir.path().join("environments.toml");
642        std::fs::write(
643            &file,
644            r#"
645[prod]
646client_id = "file-client"
647
648[custom]
649client_id = "custom-client"
650api_url = "https://api.custom.example.com"
651"#,
652        )
653        .expect("write file");
654
655        let envs = sample()
656            .with_config_file(true)
657            .with_config_file_path_override(file);
658
659        let prod: OAuthLike = envs.resolve("prod").expect("prod");
660        assert_eq!(prod.client_id, "file-client");
661        let prod_api: ApiLike = envs.resolve("prod").expect("prod");
662        assert_eq!(prod_api.api_url, "https://api.example.com");
663
664        let custom: OAuthLike = envs.resolve("custom").expect("custom");
665        assert_eq!(custom.client_id, "custom-client");
666        assert!(envs.list().contains(&"custom".to_owned()));
667    }
668
669    /// gddy's already-distributed `environments.toml` nests every entry under
670    /// a top-level `[environments]` table (mirroring its own hand-rolled
671    /// `EnvironmentsFile { environments: BTreeMap<..> }`), unlike cli-engine's
672    /// flat `[<name>]` shape. Those files must parse with zero edits.
673    #[test]
674    fn nested_environments_table_shape_parses_like_flat_shape() {
675        let _g = ENV_LOCK
676            .lock()
677            .unwrap_or_else(std::sync::PoisonError::into_inner);
678        let dir = tempfile::tempdir().expect("tempdir");
679        let file = dir.path().join("environments.toml");
680        std::fs::write(
681            &file,
682            r#"
683[environments.dev]
684api_url = "https://api.dev-godaddy.com"
685client_id = "94488449-5769-4ecf-8bf4-9f8aa83859a3"
686
687[environments.test]
688api_url = "https://api.test-godaddy.com"
689client_id = "e710d8b9-f4e5-4178-b1bf-98dfcd15d4ed"
690"#,
691        )
692        .expect("write file");
693
694        let envs = Environments::new("prod")
695            .with_config_file(true)
696            .with_config_file_path_override(file);
697
698        let dev: OAuthLike = envs.resolve("dev").expect("dev");
699        assert_eq!(dev.client_id, "94488449-5769-4ecf-8bf4-9f8aa83859a3");
700
701        let test: OAuthLike = envs.resolve("test").expect("test");
702        assert_eq!(test.client_id, "e710d8b9-f4e5-4178-b1bf-98dfcd15d4ed");
703        assert!(envs.list().contains(&"dev".to_owned()));
704        assert!(envs.list().contains(&"test".to_owned()));
705    }
706
707    /// When a name appears in both the flat top-level shape and the nested
708    /// `[environments.<name>]` shape, the nested entry's fields win, and
709    /// fields it doesn't set still fall back to the flat entry.
710    #[test]
711    fn nested_environments_table_wins_over_flat_entry_for_same_name() {
712        let _g = ENV_LOCK
713            .lock()
714            .unwrap_or_else(std::sync::PoisonError::into_inner);
715        let dir = tempfile::tempdir().expect("tempdir");
716        let file = dir.path().join("environments.toml");
717        std::fs::write(
718            &file,
719            r#"
720[prod]
721client_id = "flat-client"
722api_url = "https://api.flat.example.com"
723
724[environments.prod]
725client_id = "nested-client"
726"#,
727        )
728        .expect("write file");
729
730        let envs = Environments::new("prod")
731            .with_config_file(true)
732            .with_config_file_path_override(file);
733
734        let prod: OAuthLike = envs.resolve("prod").expect("prod");
735        assert_eq!(prod.client_id, "nested-client");
736        let prod_api: ApiLike = envs.resolve("prod").expect("prod");
737        assert_eq!(prod_api.api_url, "https://api.flat.example.com");
738    }
739
740    const ACTIVE_KEY: &str = "environment.active";
741
742    #[test]
743    fn active_env_round_trips_through_config_file() {
744        use crate::config::ConfigFile;
745        let mut cfg = ConfigFile::default();
746        assert_eq!(Environments::active_from_config(&cfg), None);
747
748        cfg.set(ACTIVE_KEY, "ote").expect("set");
749        assert_eq!(
750            Environments::active_from_config(&cfg).as_deref(),
751            Some("ote")
752        );
753    }
754
755    #[test]
756    fn effective_active_prefers_override_then_config_then_default() {
757        use crate::config::ConfigFile;
758        let envs = sample();
759        let mut cfg = ConfigFile::default();
760        cfg.set(ACTIVE_KEY, "dev").expect("set");
761
762        assert_eq!(envs.effective_active(Some("prod"), &cfg), "prod"); // explicit wins
763        assert_eq!(envs.effective_active(None, &cfg), "dev"); // config next
764        let empty = ConfigFile::default();
765        assert_eq!(envs.effective_active(None, &empty), "prod"); // default last
766    }
767
768    #[test]
769    fn fallback_resolves_a_name_unknown_to_compiled_and_file_layers() {
770        let _g = ENV_LOCK
771            .lock()
772            .unwrap_or_else(std::sync::PoisonError::into_inner);
773        let envs = sample().with_fallback(|name| {
774            Some(EnvTable::new().with("client_id", format!("{name}-fallback-client")))
775        });
776        let env: OAuthLike = envs.resolve("throwaway").expect("fallback should resolve");
777        assert_eq!(env.client_id, "throwaway-fallback-client");
778    }
779
780    /// A fallback returning `None` preserves the original "unknown environment"
781    /// error, including the known-names listing.
782    #[test]
783    fn fallback_returning_none_preserves_unknown_env_error() {
784        let _g = ENV_LOCK
785            .lock()
786            .unwrap_or_else(std::sync::PoisonError::into_inner);
787        let envs = sample().with_fallback(|_name| None);
788        let err = envs.source("nope").unwrap_err().to_string();
789        assert!(err.contains("nope"));
790        assert!(err.contains("prod") && err.contains("dev"));
791    }
792
793    /// The fallback is never consulted for a name already known to the
794    /// compiled-in layer — a fallback that would yield different values must
795    /// not be able to shadow it.
796    #[test]
797    fn fallback_is_not_consulted_for_a_known_name() {
798        let _g = ENV_LOCK
799            .lock()
800            .unwrap_or_else(std::sync::PoisonError::into_inner);
801        let envs = sample()
802            .with_fallback(|_name| Some(EnvTable::new().with("client_id", "should-not-win")));
803        let env: OAuthLike = envs.resolve("prod").expect("prod resolves");
804        assert_eq!(env.client_id, "prod-client");
805    }
806
807    /// Mirrors gddy's DEVEX-947 case: a brand-new environment name, never
808    /// declared in the compiled-in or file layers, becomes selectable purely
809    /// because its own `<NAME>_API_URL`-style env var is set.
810    #[test]
811    fn fallback_plus_env_var_layer_defines_a_brand_new_environment() {
812        let _g = ENV_LOCK
813            .lock()
814            .unwrap_or_else(std::sync::PoisonError::into_inner);
815        // SAFETY: serialized by ENV_LOCK; guard removes the var on any exit.
816        unsafe { std::env::set_var("THROWAWAY_API_URL", "https://api.throwaway.example.com") };
817        let _guard = EnvGuard("THROWAWAY_API_URL");
818
819        let envs = sample().with_fallback(|name| {
820            std::env::var(format!("{}_API_URL", name.to_uppercase()))
821                .ok()
822                .map(|api_url| EnvTable::new().with("api_url", api_url))
823        });
824        let env: ApiLike = envs.resolve("throwaway").expect("fallback should resolve");
825        assert_eq!(env.api_url, "https://api.throwaway.example.com");
826    }
827}