1use std::collections::BTreeMap;
8
9use serde::Deserialize;
10
11use crate::{Result, error::CliCoreError};
12
13#[non_exhaustive]
19#[derive(Clone, Debug, Default, PartialEq, Eq)]
20pub struct OAuthConfig {
21 pub client_id: String,
23 pub auth_url: String,
25 pub token_url: String,
27 pub scopes: Vec<String>,
29}
30
31#[non_exhaustive]
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct Environment {
35 pub name: String,
37 pub oauth: Option<OAuthConfig>,
39 pub extra: BTreeMap<String, String>,
41}
42
43#[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 #[serde(flatten, default)]
59 extra: BTreeMap<String, String>,
60}
61
62impl EnvironmentDef {
63 #[must_use]
65 pub fn new() -> Self {
66 Self::default()
67 }
68
69 #[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 #[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 #[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 #[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 #[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#[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 #[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 #[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 #[must_use]
137 pub fn with_config_file(mut self, enabled: bool) -> Self {
138 self.use_config_file = enabled;
139 self
140 }
141
142 #[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 #[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 #[must_use]
167 pub fn default_env(&self) -> &str {
168 &self.default
169 }
170
171 #[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 pub fn resolve(&self, name: &str) -> Result<Environment> {
215 let compiled = self.defs.get(name);
216 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 #[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 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 pub(crate) const ACTIVE_ENV_KEY: &'static str = "environment.active";
282
283 #[must_use]
285 pub fn active_from_config(config: &crate::config::ConfigFile) -> Option<String> {
286 config.get(Self::ACTIVE_ENV_KEY)
287 }
288
289 #[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 pub fn persist_active(&self, name: &str) -> Result<()> {
310 self.resolve(name)?; 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
327fn 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
346fn 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
374fn 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 struct EnvGuard(&'static str);
407 impl Drop for EnvGuard {
408 fn drop(&mut self) {
409 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 #[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 #[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 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 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 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"); assert_eq!(envs.effective_active(None, &cfg), "dev"); let empty = ConfigFile::default();
612 assert_eq!(envs.effective_active(None, &empty), "prod"); }
614}