use std::collections::BTreeMap;
use serde::Deserialize;
use crate::{Result, error::CliCoreError};
#[non_exhaustive]
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct OAuthConfig {
pub client_id: String,
pub auth_url: String,
pub token_url: String,
pub scopes: Vec<String>,
}
#[non_exhaustive]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Environment {
pub name: String,
pub oauth: Option<OAuthConfig>,
pub extra: BTreeMap<String, String>,
}
#[derive(Clone, Debug, Default, Deserialize)]
pub struct EnvironmentDef {
#[serde(default)]
client_id: Option<String>,
#[serde(default)]
auth_url: Option<String>,
#[serde(default)]
token_url: Option<String>,
#[serde(default)]
scopes: Option<Vec<String>>,
#[serde(flatten, default)]
extra: BTreeMap<String, String>,
}
impl EnvironmentDef {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_client_id(mut self, value: impl Into<String>) -> Self {
self.client_id = Some(value.into());
self
}
#[must_use]
pub fn with_auth_url(mut self, value: impl Into<String>) -> Self {
self.auth_url = Some(value.into());
self
}
#[must_use]
pub fn with_token_url(mut self, value: impl Into<String>) -> Self {
self.token_url = Some(value.into());
self
}
#[must_use]
pub fn with_scopes(mut self, scopes: &[impl AsRef<str>]) -> Self {
self.scopes = Some(scopes.iter().map(|s| s.as_ref().to_owned()).collect());
self
}
#[must_use]
pub fn with_field(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
self.extra.insert(key.into(), value.into());
self
}
}
#[derive(Clone, Debug)]
pub struct Environments {
default: String,
defs: BTreeMap<String, EnvironmentDef>,
use_config_file: bool,
app_id: String,
file_path_override: Option<std::path::PathBuf>,
}
impl Environments {
#[must_use]
pub fn new(default_env: impl Into<String>) -> Self {
Self {
default: default_env.into(),
defs: BTreeMap::new(),
use_config_file: false,
app_id: String::new(),
file_path_override: None,
}
}
#[must_use]
pub fn with_environment(mut self, name: impl Into<String>, def: EnvironmentDef) -> Self {
self.defs.insert(name.into(), def);
self
}
#[must_use]
pub fn with_config_file(mut self, enabled: bool) -> Self {
self.use_config_file = enabled;
self
}
#[must_use]
pub fn with_app_id(mut self, app_id: impl Into<String>) -> Self {
self.app_id = app_id.into();
self
}
#[must_use]
pub fn with_config_file_path_override(mut self, path: std::path::PathBuf) -> Self {
self.file_path_override = Some(path);
self.use_config_file = true;
self
}
#[must_use]
pub fn default_env(&self) -> &str {
&self.default
}
#[must_use]
pub fn list(&self) -> Vec<String> {
let mut names: std::collections::BTreeSet<String> = self.defs.keys().cloned().collect();
if let Ok(file) = self.file_defs() {
names.extend(file.into_keys());
}
names.into_iter().collect()
}
pub fn resolve(&self, name: &str) -> Result<Environment> {
let compiled = self.defs.get(name);
let mut all_file_defs = self.file_defs()?;
let file = all_file_defs.remove(name);
if compiled.is_none() && file.is_none() {
let mut known: std::collections::BTreeSet<String> = self.defs.keys().cloned().collect();
known.extend(all_file_defs.into_keys());
let known_list: Vec<String> = known.into_iter().collect();
let known_display = if known_list.is_empty() {
"(none defined)".to_owned()
} else {
known_list.join(", ")
};
return Err(CliCoreError::message(format!(
"unknown environment {name:?}; known: {known_display}"
)));
}
let mut merged = EnvironmentDef::default();
if let Some(def) = compiled {
merge_into(&mut merged, def);
}
if let Some(def) = &file {
merge_into(&mut merged, def);
}
apply_env_vars(name, &mut merged);
Ok(finalize(name, merged))
}
#[must_use]
pub fn config_file_path(&self) -> Option<std::path::PathBuf> {
if !self.use_config_file {
return None;
}
let config = crate::config::config_file_path(&self.app_id)?;
Some(config.with_file_name("environments.toml"))
}
fn effective_file_path(&self) -> Option<std::path::PathBuf> {
if let Some(path) = &self.file_path_override {
return Some(path.clone());
}
self.config_file_path()
}
fn file_defs(&self) -> Result<BTreeMap<String, EnvironmentDef>> {
let Some(path) = self.effective_file_path() else {
return Ok(BTreeMap::new());
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(BTreeMap::new()),
Err(err) => {
return Err(CliCoreError::message(format!(
"reading environments file {path:?}: {err}"
)));
}
};
toml_edit::de::from_str::<BTreeMap<String, EnvironmentDef>>(&text).map_err(|err| {
CliCoreError::message(format!("parsing environments file {path:?}: {err}"))
})
}
pub(crate) const ACTIVE_ENV_KEY: &'static str = "environment.active";
#[must_use]
pub fn active_from_config(config: &crate::config::ConfigFile) -> Option<String> {
config.get(Self::ACTIVE_ENV_KEY)
}
#[must_use]
pub fn effective_active(
&self,
flag: Option<&str>,
config: &crate::config::ConfigFile,
) -> String {
flag.map(ToOwned::to_owned)
.or_else(|| Self::active_from_config(config))
.unwrap_or_else(|| self.default.clone())
}
pub fn persist_active(&self, name: &str) -> Result<()> {
self.resolve(name)?; if crate::config::config_file_path(&self.app_id).is_none() {
return Err(CliCoreError::message(format!(
"cannot persist active environment {name:?}: the environment system has no usable app_id; \
set one via Environments::with_app_id (matching the CliConfig app_id)"
)));
}
let mut config = crate::config::ConfigFile::load(&self.app_id);
config.set(Self::ACTIVE_ENV_KEY, name)?;
config.save()
}
}
fn merge_into(dst: &mut EnvironmentDef, src: &EnvironmentDef) {
if src.client_id.is_some() {
dst.client_id = src.client_id.clone();
}
if src.auth_url.is_some() {
dst.auth_url = src.auth_url.clone();
}
if src.token_url.is_some() {
dst.token_url = src.token_url.clone();
}
if src.scopes.is_some() {
dst.scopes = src.scopes.clone();
}
for (k, v) in &src.extra {
dst.extra.insert(k.clone(), v.clone());
}
}
fn apply_env_vars(name: &str, def: &mut EnvironmentDef) {
let prefix = name.to_uppercase().replace('-', "_");
if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_CLIENT_ID")) {
def.client_id = Some(v);
}
if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_AUTH_URL")) {
def.auth_url = Some(v);
}
if let Ok(v) = std::env::var(format!("{prefix}_OAUTH_TOKEN_URL")) {
def.token_url = Some(v);
}
let keys: Vec<String> = def.extra.keys().cloned().collect();
for key in keys {
let var = format!("{prefix}_{}", key.to_uppercase().replace('-', "_"));
if let Ok(v) = std::env::var(&var) {
def.extra.insert(key, v);
}
}
}
fn finalize(name: &str, def: EnvironmentDef) -> Environment {
let EnvironmentDef {
client_id,
auth_url,
token_url,
scopes,
extra,
} = def;
let oauth = client_id.map(|id| OAuthConfig {
client_id: id,
auth_url: auth_url.unwrap_or_default(),
token_url: token_url.unwrap_or_default(),
scopes: scopes.unwrap_or_default(),
});
Environment {
name: name.to_owned(),
oauth,
extra,
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, unsafe_code)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
struct EnvGuard(&'static str);
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0) }
}
}
fn sample() -> Environments {
Environments::new("prod")
.with_environment(
"prod",
EnvironmentDef::new()
.with_client_id("prod-client")
.with_auth_url("https://api.example.com/authorize")
.with_token_url("https://api.example.com/token")
.with_scopes(&["openid"])
.with_field("api_url", "https://api.example.com"),
)
.with_environment("dev", EnvironmentDef::new().with_client_id("dev-client"))
}
#[test]
fn oauth_config_defaults_are_empty() {
let c = OAuthConfig::default();
assert!(c.client_id.is_empty() && c.scopes.is_empty());
}
#[test]
fn resolve_unknown_env_with_no_defs_uses_placeholder() {
let err = Environments::new("prod")
.resolve("prod")
.expect_err("nothing defined should fail");
let message = err.to_string();
assert!(
message.contains("(none defined)"),
"expected placeholder, got: {message}"
);
}
#[test]
fn persist_active_without_app_id_errors_clearly() {
let err = sample()
.persist_active("prod")
.expect_err("persist without app_id should fail");
let message = err.to_string();
assert!(
message.contains("app_id"),
"error should mention app_id, got: {message}"
);
}
#[test]
fn builder_registers_compiled_environment() {
let envs = Environments::new("prod").with_environment(
"prod",
EnvironmentDef::new()
.with_client_id("prod-client")
.with_auth_url("https://api.example.com/authorize")
.with_token_url("https://api.example.com/token")
.with_scopes(&["openid"])
.with_field("api_url", "https://api.example.com"),
);
assert_eq!(envs.default_env(), "prod");
assert_eq!(envs.list(), vec!["prod".to_owned()]);
}
#[test]
fn resolve_returns_compiled_record() {
let _g = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let env = sample().resolve("prod").expect("prod resolves");
let oauth = env.oauth.expect("oauth present");
assert_eq!(oauth.client_id, "prod-client");
assert_eq!(oauth.scopes, vec!["openid".to_owned()]);
assert_eq!(
env.extra.get("api_url").map(String::as_str),
Some("https://api.example.com")
);
}
#[test]
fn resolve_unknown_env_errors_with_known_names() {
let _g = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let err = sample().resolve("nope").unwrap_err().to_string();
assert!(err.contains("nope"));
assert!(err.contains("prod") && err.contains("dev"));
}
#[test]
fn resolve_with_only_client_id_yields_partial_oauth() {
let _g = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let envs = Environments::new("dev")
.with_environment("dev", EnvironmentDef::new().with_client_id("dev-only"));
let env = envs.resolve("dev").expect("dev resolves");
let oauth = env.oauth.expect("oauth present when client_id is set");
assert_eq!(oauth.client_id, "dev-only");
assert!(
oauth.auth_url.is_empty(),
"auth_url should be empty (fall back to provider default)"
);
assert!(
oauth.token_url.is_empty(),
"token_url should be empty (fall back to provider default)"
);
assert!(oauth.scopes.is_empty());
}
#[test]
fn env_var_layer_overrides_oauth_and_known_bag_keys() {
let _g = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
unsafe { std::env::set_var("PROD_OAUTH_CLIENT_ID", "override-client") };
let _g1 = EnvGuard("PROD_OAUTH_CLIENT_ID");
unsafe { std::env::set_var("PROD_API_URL", "https://api.override.example.com") };
let _g2 = EnvGuard("PROD_API_URL");
let env = sample().resolve("prod").expect("prod resolves");
assert_eq!(env.oauth.unwrap().client_id, "override-client");
assert_eq!(
env.extra.get("api_url").map(String::as_str),
Some("https://api.override.example.com")
);
}
#[test]
fn environments_file_path_sits_next_to_config() {
let envs = sample().with_app_id("gddy").with_config_file(true);
let path = envs.config_file_path().expect("path resolves with app id");
assert!(path.ends_with("gddy/environments.toml"), "got {path:?}");
}
#[test]
fn file_layer_overrides_compiled_and_adds_custom_env() {
let _g = ENV_LOCK
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let dir = tempfile::tempdir().expect("tempdir");
let file = dir.path().join("environments.toml");
std::fs::write(
&file,
r#"
[prod]
client_id = "file-client"
[custom]
client_id = "custom-client"
api_url = "https://api.custom.example.com"
"#,
)
.expect("write file");
let envs = sample()
.with_config_file(true)
.with_config_file_path_override(file);
let prod = envs.resolve("prod").expect("prod");
assert_eq!(prod.oauth.unwrap().client_id, "file-client");
assert_eq!(
prod.extra.get("api_url").map(String::as_str),
Some("https://api.example.com")
);
let custom = envs.resolve("custom").expect("custom");
assert_eq!(custom.oauth.unwrap().client_id, "custom-client");
assert!(envs.list().contains(&"custom".to_owned()));
}
const ACTIVE_KEY: &str = "environment.active";
#[test]
fn active_env_round_trips_through_config_file() {
use crate::config::ConfigFile;
let mut cfg = ConfigFile::default();
assert_eq!(Environments::active_from_config(&cfg), None);
cfg.set(ACTIVE_KEY, "ote").expect("set");
assert_eq!(
Environments::active_from_config(&cfg).as_deref(),
Some("ote")
);
}
#[test]
fn effective_active_prefers_override_then_config_then_default() {
use crate::config::ConfigFile;
let envs = sample();
let mut cfg = ConfigFile::default();
cfg.set(ACTIVE_KEY, "dev").expect("set");
assert_eq!(envs.effective_active(Some("prod"), &cfg), "prod"); assert_eq!(envs.effective_active(None, &cfg), "dev"); let empty = ConfigFile::default();
assert_eq!(envs.effective_active(None, &empty), "prod"); }
}