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 definitions and,
4//! optionally, an `environments.toml` file plus `<ENV>_*` env-var overrides.
5//! Resolving a name merges those layers (later wins) into an [`Environment`].
6
7use std::collections::BTreeMap;
8
9use serde::Deserialize;
10
11use crate::{Result, error::CliCoreError};
12
13/// Standard OAuth slice of an environment, consumed by `PkceAuthProvider`.
14///
15/// `auth_url`, `token_url`, and `scopes` may be empty when a layer set only
16/// `client_id`. Consumers should treat empty endpoint strings as "fall back to
17/// the provider's default base endpoints".
18#[non_exhaustive]
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct OAuthConfig {
21    /// OAuth client id.
22    pub client_id: String,
23    /// Authorization endpoint.
24    pub auth_url: String,
25    /// Token endpoint.
26    pub token_url: String,
27    /// Default scopes.
28    pub scopes: Vec<String>,
29}
30
31/// A fully-resolved environment.
32#[non_exhaustive]
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Environment {
35    /// Environment name (e.g. `prod`).
36    pub name: String,
37    /// OAuth configuration, present when the environment participates in OAuth.
38    pub oauth: Option<OAuthConfig>,
39    /// App-specific fields (for example `api_url`).
40    pub extra: BTreeMap<String, String>,
41}
42
43/// An unresolved per-environment declaration (one layer of configuration).
44///
45/// Fields are optional so layers can override individual values during
46/// resolution. The same shape parses the `environments.toml` file.
47#[derive(Clone, Debug, Default, Deserialize)]
48pub struct EnvironmentDef {
49    #[serde(default)]
50    client_id: Option<String>,
51    #[serde(default)]
52    auth_url: Option<String>,
53    #[serde(default)]
54    token_url: Option<String>,
55    #[serde(default)]
56    scopes: Option<Vec<String>>,
57    /// Everything not recognised above is captured here (app-specific fields).
58    #[serde(flatten, default)]
59    extra: BTreeMap<String, String>,
60}
61
62impl EnvironmentDef {
63    /// Creates an empty declaration; every field falls back to lower layers.
64    #[must_use]
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Sets the OAuth client id.
70    #[must_use]
71    pub fn with_client_id(mut self, value: impl Into<String>) -> Self {
72        self.client_id = Some(value.into());
73        self
74    }
75
76    /// Sets the authorization endpoint.
77    #[must_use]
78    pub fn with_auth_url(mut self, value: impl Into<String>) -> Self {
79        self.auth_url = Some(value.into());
80        self
81    }
82
83    /// Sets the token endpoint.
84    #[must_use]
85    pub fn with_token_url(mut self, value: impl Into<String>) -> Self {
86        self.token_url = Some(value.into());
87        self
88    }
89
90    /// Sets the default scopes.
91    #[must_use]
92    pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
93        self.scopes = Some(scopes.iter().map(|s| s.as_ref().to_owned()).collect());
94        self
95    }
96
97    /// Sets an app-specific bag field.
98    #[must_use]
99    pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
100        self.extra.insert(key.into(), value.into());
101        self
102    }
103}
104
105/// Engine-owned environment system: definitions + resolution + active-env state.
106#[derive(Clone, Debug)]
107pub struct Environments {
108    default: String,
109    defs: BTreeMap<String, EnvironmentDef>,
110    use_config_file: bool,
111    app_id: String,
112    file_path_override: Option<std::path::PathBuf>,
113}
114
115impl Environments {
116    /// Creates an environment system with the given default environment name.
117    #[must_use]
118    pub fn new(default_env: impl Into<String>) -> Self {
119        Self {
120            default: default_env.into(),
121            defs: BTreeMap::new(),
122            use_config_file: false,
123            app_id: String::new(),
124            file_path_override: None,
125        }
126    }
127
128    /// Registers (or replaces) a compiled-in environment definition.
129    #[must_use]
130    pub fn with_environment(mut self, name: impl Into<String>, def: EnvironmentDef) -> Self {
131        self.defs.insert(name.into(), def);
132        self
133    }
134
135    /// Enables loading `<config-dir>/<app_id>/environments.toml` during resolution.
136    #[must_use]
137    pub fn with_config_file(mut self, enabled: bool) -> Self {
138        self.use_config_file = enabled;
139        self
140    }
141
142    /// Sets the application id used to locate the config file.
143    ///
144    /// The consumer must set this to the same `app_id` passed to
145    /// [`CliConfig::new`](crate::CliConfig::new) before sharing the
146    /// [`Environments`] with both
147    /// [`CliConfig::with_environments`](crate::CliConfig::with_environments) and
148    /// `PkceAuthProvider::with_environments` (with the `pkce-auth` feature),
149    /// or [`config_file_path`](Self::config_file_path) returns `None` and the
150    /// `environments.toml` file layer silently resolves empty.
151    #[must_use]
152    pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
153        self.app_id = app_id.into();
154        self
155    }
156
157    /// Test/advanced seam: force the environments file path.
158    #[must_use]
159    pub fn with_config_file_path_override(mut self, path: std::path::PathBuf) -> Self {
160        self.file_path_override = Some(path);
161        self.use_config_file = true;
162        self
163    }
164
165    /// The default environment name.
166    #[must_use]
167    pub fn default_env(&self) -> &str {
168        &self.default
169    }
170
171    /// Enumerable environment names (compiled-in + file-defined), sorted.
172    ///
173    /// Any error from reading or parsing the environments file (missing file,
174    /// permission/read error, or malformed TOML) is silently swallowed and only
175    /// the compiled-in names are returned. Use [`resolve`](Self::resolve) when
176    /// you need those errors surfaced; a fallible listing variant can be added
177    /// later if needed.
178    ///
179    /// # Blocking
180    ///
181    /// When the config-file layer is enabled, this performs synchronous
182    /// filesystem I/O to read and parse `environments.toml` (like
183    /// [`resolve`](Self::resolve)). Avoid calling it repeatedly on a
184    /// latency-sensitive async path.
185    #[must_use]
186    pub fn list(&self) -> Vec<String> {
187        let mut names: std::collections::BTreeSet<String> = self.defs.keys().cloned().collect();
188        if let Ok(file) = self.file_defs() {
189            names.extend(file.into_keys());
190        }
191        names.into_iter().collect()
192    }
193
194    /// Resolves `name` by merging compiled defaults, the config file,
195    /// and `<ENV>_*` env-var overrides (later wins) into an [`Environment`].
196    ///
197    /// When only `client_id` was set on the matching layer(s), the returned
198    /// [`Environment`]'s `oauth.auth_url` / `oauth.token_url` will be empty
199    /// strings. Consumers should treat empty endpoint strings as "fall back to
200    /// the provider's default base endpoints".
201    ///
202    /// # Blocking
203    ///
204    /// When the config-file layer is enabled (via
205    /// [`with_config_file`](Self::with_config_file)), this performs synchronous
206    /// filesystem I/O to read and parse `environments.toml`. Resolve the
207    /// environment once at startup (or off the latency-sensitive path) rather
208    /// than calling it per request inside an async handler.
209    ///
210    /// # Errors
211    ///
212    /// Returns an error when `name` is not known to any layer or when the
213    /// environments file exists but cannot be read or parsed.
214    pub fn resolve(&self, name: &str) -> Result<Environment> {
215        let compiled = self.defs.get(name);
216        // Parse the file once; reuse for both membership check and merge.
217        let mut all_file_defs = self.file_defs()?;
218        let file = all_file_defs.remove(name);
219        if compiled.is_none() && file.is_none() {
220            let mut known: std::collections::BTreeSet<String> = self.defs.keys().cloned().collect();
221            known.extend(all_file_defs.into_keys());
222            let known_list: Vec<String> = known.into_iter().collect();
223            let known_display = if known_list.is_empty() {
224                "(none defined)".to_owned()
225            } else {
226                known_list.join(", ")
227            };
228            return Err(CliCoreError::message(format!(
229                "unknown environment {name:?}; known: {known_display}"
230            )));
231        }
232        let mut merged = EnvironmentDef::default();
233        if let Some(def) = compiled {
234            merge_into(&mut merged, def);
235        }
236        if let Some(def) = &file {
237            merge_into(&mut merged, def);
238        }
239        apply_env_vars(name, &mut merged);
240        Ok(finalize(name, merged))
241    }
242
243    /// Path to `environments.toml` next to the engine config file, or `None`
244    /// when the file layer is disabled or the config dir cannot be determined.
245    #[must_use]
246    pub fn config_file_path(&self) -> Option<std::path::PathBuf> {
247        if !self.use_config_file {
248            return None;
249        }
250        let config = crate::config::config_file_path(&self.app_id)?;
251        Some(config.with_file_name("environments.toml"))
252    }
253
254    fn effective_file_path(&self) -> Option<std::path::PathBuf> {
255        if let Some(path) = &self.file_path_override {
256            return Some(path.clone());
257        }
258        self.config_file_path()
259    }
260
261    /// Parses the environments file into a name -> def map. Missing file = empty.
262    fn file_defs(&self) -> Result<BTreeMap<String, EnvironmentDef>> {
263        let Some(path) = self.effective_file_path() else {
264            return Ok(BTreeMap::new());
265        };
266        let text = match std::fs::read_to_string(&path) {
267            Ok(text) => text,
268            Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
269            Err(err) => {
270                return Err(CliCoreError::message(format!(
271                    "reading environments file {path:?}: {err}"
272                )));
273            }
274        };
275        toml_edit::de::from_str::<BTreeMap<String, EnvironmentDef>>(&text).map_err(|err| {
276            CliCoreError::message(format!("parsing environments file {path:?}: {err}"))
277        })
278    }
279
280    /// Config-file key under which the sticky active environment is stored.
281    pub(crate) const ACTIVE_ENV_KEY: &'static str = "environment.active";
282
283    /// Reads the persisted active environment from a loaded config file.
284    #[must_use]
285    pub fn active_from_config(config: &crate::config::ConfigFile) -> Option<String> {
286        config.get(Self::ACTIVE_ENV_KEY)
287    }
288
289    /// Resolves the active environment name with precedence:
290    /// explicit `--env` override > persisted active > configured default.
291    #[must_use]
292    pub fn effective_active(
293        &self,
294        flag: Option<&str>,
295        config: &crate::config::ConfigFile,
296    ) -> String {
297        flag.map(ToOwned::to_owned)
298            .or_else(|| Self::active_from_config(config))
299            .unwrap_or_else(|| self.default.clone())
300    }
301
302    /// Persists `name` as the active environment (loads, sets, saves a fresh
303    /// config file for `app_id`). Validates that `name` resolves first.
304    ///
305    /// # Errors
306    ///
307    /// Returns an error when `name` does not resolve to a known environment, or
308    /// when the config file cannot be written.
309    pub fn persist_active(&self, name: &str) -> Result<()> {
310        self.resolve(name)?; // reject unknown names
311        // Persisting writes the engine config file, which is keyed by app_id.
312        // Validate it up front so a missing/invalid app_id yields a clear,
313        // actionable error rather than a misleading "no config path" failure
314        // from ConfigFile::save() that points at XDG/HOME.
315        if crate::config::config_file_path(&self.app_id).is_none() {
316            return Err(CliCoreError::message(format!(
317                "cannot persist active environment {name:?}: the environment system has no usable app_id; \
318                 set one via Environments::with_app_id (matching the CliConfig app_id)"
319            )));
320        }
321        let mut config = crate::config::ConfigFile::load(&self.app_id);
322        config.set(Self::ACTIVE_ENV_KEY, name)?;
323        config.save()
324    }
325}
326
327/// Merges `src` into `dst`, with `src` winning on any field it sets.
328fn merge_into(dst: &mut EnvironmentDef, src: &EnvironmentDef) {
329    if src.client_id.is_some() {
330        dst.client_id = src.client_id.clone();
331    }
332    if src.auth_url.is_some() {
333        dst.auth_url = src.auth_url.clone();
334    }
335    if src.token_url.is_some() {
336        dst.token_url = src.token_url.clone();
337    }
338    if src.scopes.is_some() {
339        dst.scopes = src.scopes.clone();
340    }
341    for (k, v) in &src.extra {
342        dst.extra.insert(k.clone(), v.clone());
343    }
344}
345
346/// Applies `<ENV>_*` overrides: the three OAuth fields always, and any bag key
347/// already present in the merged record (keyed `<ENV>_<KEY>`).
348///
349/// The prefix is `name.to_uppercase().replace('-', "_")`, so environment names
350/// that differ only by `-` vs `_` map to the same prefix and will collide.
351///
352/// Scopes are intentionally not env-var overridable; set them via the
353/// compiled-in layer or the `environments.toml` file.
354fn apply_env_vars(name: &str, def: &mut EnvironmentDef) {
355    let prefix = name.to_uppercase().replace('-', "_");
356    if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_CLIENT_ID")) {
357        def.client_id = Some(v);
358    }
359    if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_AUTH_URL")) {
360        def.auth_url = Some(v);
361    }
362    if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_TOKEN_URL")) {
363        def.token_url = Some(v);
364    }
365    let keys: Vec<String> = def.extra.keys().cloned().collect();
366    for key in keys {
367        let var = format!("{prefix}_{}", key.to_uppercase().replace('-', "_"));
368        if let Ok(v) = std::env::var(&var) {
369            def.extra.insert(key, v);
370        }
371    }
372}
373
374/// Turns a fully-merged declaration into a resolved [`Environment`]. OAuth is
375/// present when a client id was set by any layer.
376fn finalize(name: &str, def: EnvironmentDef) -> Environment {
377    let EnvironmentDef {
378        client_id,
379        auth_url,
380        token_url,
381        scopes,
382        extra,
383    } = def;
384    let oauth = client_id.map(|id| OAuthConfig {
385        client_id: id,
386        auth_url: auth_url.unwrap_or_default(),
387        token_url: token_url.unwrap_or_default(),
388        scopes: scopes.unwrap_or_default(),
389    });
390    Environment {
391        name: name.to_owned(),
392        oauth,
393        extra,
394    }
395}
396
397#[cfg(test)]
398#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
399mod tests {
400    use super::*;
401
402    use std::sync::Mutex;
403    static ENV_LOCK: Mutex<()> = Mutex::new(());
404
405    /// RAII guard that removes an env var on drop, even if a test panics.
406    struct EnvGuard(&'static str);
407    impl Drop for EnvGuard {
408        fn drop(&mut self) {
409            // SAFETY: test holds ENV_LOCK; clean up on any exit including panic.
410            unsafe { std::env::remove_var(self.0) }
411        }
412    }
413
414    fn sample() -> Environments {
415        Environments::new("prod")
416            .with_environment(
417                "prod",
418                EnvironmentDef::new()
419                    .with_client_id("prod-client")
420                    .with_auth_url("https://api.example.com/authorize")
421                    .with_token_url("https://api.example.com/token")
422                    .with_scopes(&["openid"])
423                    .with_field("api_url", "https://api.example.com"),
424            )
425            .with_environment("dev", EnvironmentDef::new().with_client_id("dev-client"))
426    }
427
428    #[test]
429    fn oauth_config_defaults_are_empty() {
430        let c = OAuthConfig::default();
431        assert!(c.client_id.is_empty() && c.scopes.is_empty());
432    }
433
434    /// With no environments defined at all, the unknown-env error renders a
435    /// readable placeholder instead of a dangling `known: `.
436    #[test]
437    fn resolve_unknown_env_with_no_defs_uses_placeholder() {
438        let err = Environments::new("prod")
439            .resolve("prod")
440            .expect_err("nothing defined should fail");
441        let message = err.to_string();
442        assert!(
443            message.contains("(none defined)"),
444            "expected placeholder, got: {message}"
445        );
446    }
447
448    /// `persist_active` without an `app_id` returns a clear, actionable error
449    /// (mentioning `app_id`) rather than a misleading config-path failure.
450    #[test]
451    fn persist_active_without_app_id_errors_clearly() {
452        let err = sample()
453            .persist_active("prod")
454            .expect_err("persist without app_id should fail");
455        let message = err.to_string();
456        assert!(
457            message.contains("app_id"),
458            "error should mention app_id, got: {message}"
459        );
460    }
461
462    #[test]
463    fn builder_registers_compiled_environment() {
464        let envs = Environments::new("prod").with_environment(
465            "prod",
466            EnvironmentDef::new()
467                .with_client_id("prod-client")
468                .with_auth_url("https://api.example.com/authorize")
469                .with_token_url("https://api.example.com/token")
470                .with_scopes(&["openid"])
471                .with_field("api_url", "https://api.example.com"),
472        );
473        assert_eq!(envs.default_env(), "prod");
474        assert_eq!(envs.list(), vec!["prod".to_owned()]);
475    }
476
477    #[test]
478    fn resolve_returns_compiled_record() {
479        let _g = ENV_LOCK
480            .lock()
481            .unwrap_or_else(std::sync::PoisonError::into_inner);
482        let env = sample().resolve("prod").expect("prod resolves");
483        let oauth = env.oauth.expect("oauth present");
484        assert_eq!(oauth.client_id, "prod-client");
485        assert_eq!(oauth.scopes, vec!["openid".to_owned()]);
486        assert_eq!(
487            env.extra.get("api_url").map(String::as_str),
488            Some("https://api.example.com")
489        );
490    }
491
492    #[test]
493    fn resolve_unknown_env_errors_with_known_names() {
494        let _g = ENV_LOCK
495            .lock()
496            .unwrap_or_else(std::sync::PoisonError::into_inner);
497        let err = sample().resolve("nope").unwrap_err().to_string();
498        assert!(err.contains("nope"));
499        assert!(err.contains("prod") && err.contains("dev"));
500    }
501
502    #[test]
503    fn resolve_with_only_client_id_yields_partial_oauth() {
504        let _g = ENV_LOCK
505            .lock()
506            .unwrap_or_else(std::sync::PoisonError::into_inner);
507        let envs = Environments::new("dev")
508            .with_environment("dev", EnvironmentDef::new().with_client_id("dev-only"));
509        let env = envs.resolve("dev").expect("dev resolves");
510        let oauth = env.oauth.expect("oauth present when client_id is set");
511        assert_eq!(oauth.client_id, "dev-only");
512        assert!(
513            oauth.auth_url.is_empty(),
514            "auth_url should be empty (fall back to provider default)"
515        );
516        assert!(
517            oauth.token_url.is_empty(),
518            "token_url should be empty (fall back to provider default)"
519        );
520        assert!(oauth.scopes.is_empty());
521    }
522
523    #[test]
524    fn env_var_layer_overrides_oauth_and_known_bag_keys() {
525        let _g = ENV_LOCK
526            .lock()
527            .unwrap_or_else(std::sync::PoisonError::into_inner);
528        // SAFETY: serialized by ENV_LOCK; guards remove vars on any exit incl. panic.
529        unsafe { std::env::set_var("PROD_OAUTH_CLIENT_ID", "override-client") };
530        let _g1 = EnvGuard("PROD_OAUTH_CLIENT_ID");
531        unsafe { std::env::set_var("PROD_API_URL", "https://api.override.example.com") };
532        let _g2 = EnvGuard("PROD_API_URL");
533
534        let env = sample().resolve("prod").expect("prod resolves");
535        assert_eq!(env.oauth.unwrap().client_id, "override-client");
536        assert_eq!(
537            env.extra.get("api_url").map(String::as_str),
538            Some("https://api.override.example.com")
539        );
540    }
541
542    #[test]
543    fn environments_file_path_sits_next_to_config() {
544        let envs = sample().with_app_id("gddy").with_config_file(true);
545        let path = envs.config_file_path().expect("path resolves with app id");
546        assert!(path.ends_with("gddy/environments.toml"), "got {path:?}");
547    }
548
549    #[test]
550    fn file_layer_overrides_compiled_and_adds_custom_env() {
551        let _g = ENV_LOCK
552            .lock()
553            .unwrap_or_else(std::sync::PoisonError::into_inner);
554        let dir = tempfile::tempdir().expect("tempdir");
555        let file = dir.path().join("environments.toml");
556        std::fs::write(
557            &file,
558            r#"
559[prod]
560client_id = "file-client"
561
562[custom]
563client_id = "custom-client"
564api_url = "https://api.custom.example.com"
565"#,
566        )
567        .expect("write file");
568
569        let envs = sample()
570            .with_config_file(true)
571            .with_config_file_path_override(file);
572
573        // File overrides the compiled prod client id, keeps compiled api_url.
574        let prod = envs.resolve("prod").expect("prod");
575        assert_eq!(prod.oauth.unwrap().client_id, "file-client");
576        assert_eq!(
577            prod.extra.get("api_url").map(String::as_str),
578            Some("https://api.example.com")
579        );
580
581        // Custom env exists only in the file.
582        let custom = envs.resolve("custom").expect("custom");
583        assert_eq!(custom.oauth.unwrap().client_id, "custom-client");
584        assert!(envs.list().contains(&"custom".to_owned()));
585    }
586
587    const ACTIVE_KEY: &str = "environment.active";
588
589    #[test]
590    fn active_env_round_trips_through_config_file() {
591        use crate::config::ConfigFile;
592        let mut cfg = ConfigFile::default();
593        assert_eq!(Environments::active_from_config(&cfg), None);
594
595        cfg.set(ACTIVE_KEY, "ote").expect("set");
596        assert_eq!(
597            Environments::active_from_config(&cfg).as_deref(),
598            Some("ote")
599        );
600    }
601
602    #[test]
603    fn effective_active_prefers_override_then_config_then_default() {
604        use crate::config::ConfigFile;
605        let envs = sample();
606        let mut cfg = ConfigFile::default();
607        cfg.set(ACTIVE_KEY, "dev").expect("set");
608
609        assert_eq!(envs.effective_active(Some("prod"), &cfg), "prod"); // explicit wins
610        assert_eq!(envs.effective_active(None, &cfg), "dev"); // config next
611        let empty = ConfigFile::default();
612        assert_eq!(envs.effective_active(None, &empty), "prod"); // default last
613    }
614}