1use std::collections::BTreeMap;
15use std::sync::Arc;
16
17use crate::env_config::{EnvConfig, EnvConfigError, EnvSource, EnvVarSource};
18use crate::{Result, error::CliCoreError};
19
20type EnvironmentFallback = Arc<dyn Fn(&str) -> Option<EnvTable> + Send + Sync>;
24
25#[derive(Debug, Clone, Default)]
32pub struct EnvTable(toml::Table);
33
34impl EnvTable {
35 #[must_use]
37 pub fn new() -> Self {
38 Self(toml::Table::new())
39 }
40
41 #[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#[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 #[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 #[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 #[must_use]
138 pub fn with_config_file(mut self, enabled: bool) -> Self {
139 self.use_config_file = enabled;
140 self
141 }
142
143 #[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 #[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 #[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 #[must_use]
195 pub fn default_env(&self) -> &str {
196 &self.default
197 }
198
199 #[must_use]
206 pub fn app_id(&self) -> &str {
207 &self.app_id
208 }
209
210 #[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 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 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 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 #[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 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 pub(crate) const ACTIVE_ENV_KEY: &'static str = "environment.active";
403
404 #[must_use]
406 pub fn active_from_config(config: &crate::config::ConfigFile) -> Option<String> {
407 config.get(Self::ACTIVE_ENV_KEY)
408 }
409
410 #[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 pub fn persist_active(&self, name: &str) -> Result<()> {
431 self.source(name)?; 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
448fn 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 struct EnvGuard(&'static str);
468 impl Drop for EnvGuard {
469 fn drop(&mut self) {
470 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 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 #[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 #[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 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 #[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 #[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"); assert_eq!(envs.effective_active(None, &cfg), "dev"); let empty = ConfigFile::default();
765 assert_eq!(envs.effective_active(None, &empty), "prod"); }
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 #[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 #[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 #[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 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}