1#[cfg(target_os = "macos")]
7use directories::BaseDirs;
8use directories::ProjectDirs;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::{Path, PathBuf};
13
14use super::credential::CredentialStore;
15use super::error::{ConfigError, Result};
16
17#[derive(Debug, Serialize, Deserialize, Default, Clone)]
19pub struct Config {
20 #[serde(default, rename = "default_enterprise")]
22 pub default_enterprise: Option<String>,
23 #[serde(default, rename = "default_cloud")]
25 pub default_cloud: Option<String>,
26 #[serde(default, rename = "default_database")]
28 pub default_database: Option<String>,
29 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub files_api_key: Option<String>,
33 #[serde(default)]
35 pub profiles: HashMap<String, Profile>,
36}
37
38#[derive(Debug, Serialize, Deserialize, Clone)]
40pub struct Profile {
41 pub deployment_type: DeploymentType,
43 #[serde(flatten)]
45 pub credentials: ProfileCredentials,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub files_api_key: Option<String>,
50 #[serde(default, skip_serializing_if = "Option::is_none")]
52 pub resilience: Option<super::ResilienceConfig>,
53 #[serde(default, skip_serializing_if = "Vec::is_empty")]
55 pub tags: Vec<String>,
56}
57
58#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
60#[serde(rename_all = "lowercase")]
61pub enum DeploymentType {
62 Cloud,
63 Enterprise,
64 Database,
65}
66
67#[derive(Debug, Serialize, Deserialize, Clone)]
69#[serde(untagged)]
70pub enum ProfileCredentials {
71 Cloud {
72 api_key: String,
73 api_secret: String,
74 #[serde(default = "default_cloud_url")]
75 api_url: String,
76 },
77 Enterprise {
78 url: String,
79 username: String,
80 password: Option<String>, #[serde(default)]
82 insecure: bool,
83 #[serde(default)]
85 ca_cert: Option<String>,
86 },
87 Database {
88 host: String,
89 port: u16,
90 #[serde(default)]
91 password: Option<String>,
92 #[serde(default = "default_tls")]
93 tls: bool,
94 #[serde(default = "default_username")]
95 username: String,
96 #[serde(default)]
97 database: u8,
98 },
99}
100
101impl std::fmt::Display for DeploymentType {
102 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
103 match self {
104 DeploymentType::Cloud => write!(f, "cloud"),
105 DeploymentType::Enterprise => write!(f, "enterprise"),
106 DeploymentType::Database => write!(f, "database"),
107 }
108 }
109}
110
111fn default_tls() -> bool {
112 true
113}
114
115fn default_username() -> String {
116 "default".to_string()
117}
118
119impl Profile {
120 pub fn cloud_credentials(&self) -> Option<(&str, &str, &str)> {
122 match &self.credentials {
123 ProfileCredentials::Cloud {
124 api_key,
125 api_secret,
126 api_url,
127 } => Some((api_key.as_str(), api_secret.as_str(), api_url.as_str())),
128 _ => None,
129 }
130 }
131
132 #[allow(clippy::type_complexity)]
134 pub fn enterprise_credentials(&self) -> Option<(&str, &str, Option<&str>, bool, Option<&str>)> {
135 match &self.credentials {
136 ProfileCredentials::Enterprise {
137 url,
138 username,
139 password,
140 insecure,
141 ca_cert,
142 } => Some((
143 url.as_str(),
144 username.as_str(),
145 password.as_deref(),
146 *insecure,
147 ca_cert.as_deref(),
148 )),
149 _ => None,
150 }
151 }
152
153 pub fn has_password(&self) -> bool {
155 matches!(
156 self.credentials,
157 ProfileCredentials::Enterprise {
158 password: Some(_),
159 ..
160 } | ProfileCredentials::Database {
161 password: Some(_),
162 ..
163 }
164 )
165 }
166
167 pub fn resolve_cloud_credentials(&self) -> Result<Option<(String, String, String)>> {
169 match &self.credentials {
170 ProfileCredentials::Cloud {
171 api_key,
172 api_secret,
173 api_url,
174 } => {
175 let store = CredentialStore::new();
176
177 let resolved_key = store
179 .get_credential(api_key, Some("REDIS_CLOUD_API_KEY"))
180 .map_err(|e| {
181 ConfigError::CredentialError(format!("Failed to resolve API key: {}", e))
182 })?;
183 let resolved_secret = store
184 .get_credential(api_secret, Some("REDIS_CLOUD_API_SECRET"))
185 .map_err(|e| {
186 ConfigError::CredentialError(format!("Failed to resolve API secret: {}", e))
187 })?;
188 let resolved_url = store
189 .get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
190 .map_err(|e| {
191 ConfigError::CredentialError(format!("Failed to resolve API URL: {}", e))
192 })?;
193
194 Ok(Some((resolved_key, resolved_secret, resolved_url)))
195 }
196 _ => Ok(None),
197 }
198 }
199
200 #[allow(clippy::type_complexity)]
202 pub fn resolve_enterprise_credentials(
203 &self,
204 ) -> Result<Option<(String, String, Option<String>, bool, Option<String>)>> {
205 match &self.credentials {
206 ProfileCredentials::Enterprise {
207 url,
208 username,
209 password,
210 insecure,
211 ca_cert,
212 } => {
213 let store = CredentialStore::new();
214
215 let resolved_url = store
217 .get_credential(url, Some("REDIS_ENTERPRISE_URL"))
218 .map_err(|e| {
219 ConfigError::CredentialError(format!("Failed to resolve URL: {}", e))
220 })?;
221 let resolved_username = store
222 .get_credential(username, Some("REDIS_ENTERPRISE_USER"))
223 .map_err(|e| {
224 ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
225 })?;
226 let resolved_password = password
227 .as_ref()
228 .map(|p| {
229 store
230 .get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
231 .map_err(|e| {
232 ConfigError::CredentialError(format!(
233 "Failed to resolve password: {}",
234 e
235 ))
236 })
237 })
238 .transpose()?;
239
240 Ok(Some((
241 resolved_url,
242 resolved_username,
243 resolved_password,
244 *insecure,
245 ca_cert.clone(),
246 )))
247 }
248 _ => Ok(None),
249 }
250 }
251
252 #[allow(clippy::type_complexity)]
254 pub fn database_credentials(&self) -> Option<(&str, u16, Option<&str>, bool, &str, u8)> {
255 match &self.credentials {
256 ProfileCredentials::Database {
257 host,
258 port,
259 password,
260 tls,
261 username,
262 database,
263 } => Some((
264 host.as_str(),
265 *port,
266 password.as_deref(),
267 *tls,
268 username.as_str(),
269 *database,
270 )),
271 _ => None,
272 }
273 }
274
275 #[allow(clippy::type_complexity)]
277 pub fn resolve_database_credentials(
278 &self,
279 ) -> Result<Option<(String, u16, Option<String>, bool, String, u8)>> {
280 match &self.credentials {
281 ProfileCredentials::Database {
282 host,
283 port,
284 password,
285 tls,
286 username,
287 database,
288 } => {
289 let store = CredentialStore::new();
290
291 let resolved_host =
293 store
294 .get_credential(host, Some("REDIS_HOST"))
295 .map_err(|e| {
296 ConfigError::CredentialError(format!("Failed to resolve host: {}", e))
297 })?;
298 let resolved_username = store
299 .get_credential(username, Some("REDIS_USERNAME"))
300 .map_err(|e| {
301 ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
302 })?;
303 let resolved_password = password
304 .as_ref()
305 .map(|p| {
306 store
307 .get_credential(p, Some("REDIS_PASSWORD"))
308 .map_err(|e| {
309 ConfigError::CredentialError(format!(
310 "Failed to resolve password: {}",
311 e
312 ))
313 })
314 })
315 .transpose()?;
316
317 Ok(Some((
318 resolved_host,
319 *port,
320 resolved_password,
321 *tls,
322 resolved_username,
323 *database,
324 )))
325 }
326 _ => Ok(None),
327 }
328 }
329}
330
331impl Config {
332 pub fn find_first_profile_of_type(&self, deployment_type: DeploymentType) -> Option<&str> {
334 let mut profiles: Vec<_> = self
335 .profiles
336 .iter()
337 .filter(|(_, p)| p.deployment_type == deployment_type)
338 .map(|(name, _)| name.as_str())
339 .collect();
340 profiles.sort();
341 profiles.first().copied()
342 }
343
344 pub fn get_profiles_of_type(&self, deployment_type: DeploymentType) -> Vec<&str> {
346 let mut profiles: Vec<_> = self
347 .profiles
348 .iter()
349 .filter(|(_, p)| p.deployment_type == deployment_type)
350 .map(|(name, _)| name.as_str())
351 .collect();
352 profiles.sort();
353 profiles
354 }
355
356 pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
358 if let Some(profile_name) = explicit_profile {
359 return Ok(profile_name.to_string());
361 }
362
363 if let Some(ref default) = self.default_enterprise {
364 return Ok(default.clone());
366 }
367
368 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
369 return Ok(profile_name.to_string());
371 }
372
373 let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
375 let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
376 if !cloud_profiles.is_empty() || !database_profiles.is_empty() {
377 let mut suggestions = Vec::new();
378 if !cloud_profiles.is_empty() {
379 suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
380 }
381 if !database_profiles.is_empty() {
382 suggestions.push(format!("database: {}", database_profiles.join(", ")));
383 }
384 Err(ConfigError::NoProfilesOfType {
385 deployment_type: "enterprise".to_string(),
386 suggestion: format!(
387 "Available profiles: {}. Use 'redisctl profile set' to create an enterprise profile.",
388 suggestions.join("; ")
389 ),
390 })
391 } else {
392 Err(ConfigError::NoProfilesOfType {
393 deployment_type: "enterprise".to_string(),
394 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
395 })
396 }
397 }
398
399 pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
401 if let Some(profile_name) = explicit_profile {
402 return Ok(profile_name.to_string());
404 }
405
406 if let Some(ref default) = self.default_cloud {
407 return Ok(default.clone());
409 }
410
411 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
412 return Ok(profile_name.to_string());
414 }
415
416 let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
418 let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
419 if !enterprise_profiles.is_empty() || !database_profiles.is_empty() {
420 let mut suggestions = Vec::new();
421 if !enterprise_profiles.is_empty() {
422 suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
423 }
424 if !database_profiles.is_empty() {
425 suggestions.push(format!("database: {}", database_profiles.join(", ")));
426 }
427 Err(ConfigError::NoProfilesOfType {
428 deployment_type: "cloud".to_string(),
429 suggestion: format!(
430 "Available profiles: {}. Use 'redisctl profile set' to create a cloud profile.",
431 suggestions.join("; ")
432 ),
433 })
434 } else {
435 Err(ConfigError::NoProfilesOfType {
436 deployment_type: "cloud".to_string(),
437 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
438 })
439 }
440 }
441
442 pub fn resolve_database_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
444 if let Some(profile_name) = explicit_profile {
445 return Ok(profile_name.to_string());
447 }
448
449 if let Some(ref default) = self.default_database {
450 return Ok(default.clone());
452 }
453
454 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Database) {
455 return Ok(profile_name.to_string());
457 }
458
459 let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
461 let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
462 if !cloud_profiles.is_empty() || !enterprise_profiles.is_empty() {
463 let mut suggestions = Vec::new();
464 if !cloud_profiles.is_empty() {
465 suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
466 }
467 if !enterprise_profiles.is_empty() {
468 suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
469 }
470 Err(ConfigError::NoProfilesOfType {
471 deployment_type: "database".to_string(),
472 suggestion: format!(
473 "Available profiles: {}. Use 'redisctl profile set' to create a database profile.",
474 suggestions.join("; ")
475 ),
476 })
477 } else {
478 Err(ConfigError::NoProfilesOfType {
479 deployment_type: "database".to_string(),
480 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
481 })
482 }
483 }
484
485 pub fn resolve_profile_deployment(
497 &self,
498 explicit_profile: Option<&str>,
499 ) -> Result<DeploymentType> {
500 if let Some(name) = explicit_profile {
502 let profile = self
503 .profiles
504 .get(name)
505 .ok_or_else(|| ConfigError::ProfileNotFound {
506 name: name.to_string(),
507 })?;
508 return Ok(profile.deployment_type);
509 }
510
511 let has_cloud = self
512 .profiles
513 .values()
514 .any(|p| p.deployment_type == DeploymentType::Cloud);
515 let has_enterprise = self
516 .profiles
517 .values()
518 .any(|p| p.deployment_type == DeploymentType::Enterprise);
519
520 match (has_cloud, has_enterprise) {
521 (true, false) => Ok(DeploymentType::Cloud),
522 (false, true) => Ok(DeploymentType::Enterprise),
523 (true, true) => Err(ConfigError::AmbiguousDeployment),
524 (false, false) => Err(ConfigError::NoProfilesOfType {
525 deployment_type: "cloud or enterprise".to_string(),
526 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
527 }),
528 }
529 }
530
531 pub fn load() -> Result<Self> {
533 let config_path = Self::config_path()?;
534 Self::load_from_path(&config_path)
535 }
536
537 pub fn load_from_path(config_path: &Path) -> Result<Self> {
539 if !config_path.exists() {
540 return Ok(Config::default());
541 }
542
543 let content = fs::read_to_string(config_path).map_err(|e| ConfigError::LoadError {
544 path: config_path.display().to_string(),
545 source: e,
546 })?;
547
548 let expanded_content = Self::expand_env_vars(&content);
550
551 let config: Config = toml::from_str(&expanded_content)?;
552
553 Ok(config)
554 }
555
556 pub fn save(&self) -> Result<()> {
558 let config_path = Self::config_path()?;
559 self.save_to_path(&config_path)
560 }
561
562 pub fn save_to_path(&self, config_path: &Path) -> Result<()> {
564 if let Some(parent) = config_path.parent() {
566 fs::create_dir_all(parent).map_err(|e| ConfigError::SaveError {
567 path: parent.display().to_string(),
568 source: e,
569 })?;
570 }
571
572 let content = toml::to_string_pretty(self)?;
573
574 fs::write(config_path, content).map_err(|e| ConfigError::SaveError {
575 path: config_path.display().to_string(),
576 source: e,
577 })?;
578
579 Ok(())
580 }
581
582 pub fn set_profile(&mut self, name: String, profile: Profile) {
584 self.profiles.insert(name, profile);
585 }
586
587 pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
589 if self.default_enterprise.as_deref() == Some(name) {
591 self.default_enterprise = None;
592 }
593 if self.default_cloud.as_deref() == Some(name) {
594 self.default_cloud = None;
595 }
596 if self.default_database.as_deref() == Some(name) {
597 self.default_database = None;
598 }
599 self.profiles.remove(name)
600 }
601
602 pub fn list_profiles(&self) -> Vec<(&String, &Profile)> {
604 let mut profiles: Vec<_> = self.profiles.iter().collect();
605 profiles.sort_by_key(|(name, _)| *name);
606 profiles
607 }
608
609 pub fn config_path() -> Result<PathBuf> {
618 #[cfg(target_os = "macos")]
620 {
621 if let Some(base_dirs) = BaseDirs::new() {
622 let home_dir = base_dirs.home_dir();
623 let linux_style_path = home_dir
624 .join(".config")
625 .join("redisctl")
626 .join("config.toml");
627
628 if linux_style_path.exists() {
630 return Ok(linux_style_path);
631 }
632
633 if linux_style_path
635 .parent()
636 .map(|p| p.exists())
637 .unwrap_or(false)
638 {
639 return Ok(linux_style_path);
640 }
641 }
642 }
643
644 let proj_dirs =
646 ProjectDirs::from("com", "redis", "redisctl").ok_or(ConfigError::ConfigDirError)?;
647
648 Ok(proj_dirs.config_dir().join("config.toml"))
649 }
650
651 fn expand_env_vars(content: &str) -> String {
663 let expanded =
666 shellexpand::env_with_context_no_errors(content, |var| std::env::var(var).ok());
667 expanded.to_string()
668 }
669}
670
671fn default_cloud_url() -> String {
672 "https://api.redislabs.com/v1".to_string()
673}
674
675#[cfg(test)]
676mod tests {
677 use super::*;
678
679 #[test]
680 fn test_config_serialization() {
681 let mut config = Config::default();
682
683 let cloud_profile = Profile {
684 deployment_type: DeploymentType::Cloud,
685 credentials: ProfileCredentials::Cloud {
686 api_key: "test-key".to_string(),
687 api_secret: "test-secret".to_string(),
688 api_url: "https://api.redislabs.com/v1".to_string(),
689 },
690 files_api_key: None,
691 resilience: None,
692 tags: vec![],
693 };
694
695 config.set_profile("test".to_string(), cloud_profile);
696 config.default_cloud = Some("test".to_string());
697
698 let serialized = toml::to_string(&config).unwrap();
699 let deserialized: Config = toml::from_str(&serialized).unwrap();
700
701 assert_eq!(config.default_cloud, deserialized.default_cloud);
702 assert_eq!(config.profiles.len(), deserialized.profiles.len());
703 }
704
705 #[test]
706 fn test_profile_credential_access() {
707 let cloud_profile = Profile {
708 deployment_type: DeploymentType::Cloud,
709 credentials: ProfileCredentials::Cloud {
710 api_key: "key".to_string(),
711 api_secret: "secret".to_string(),
712 api_url: "url".to_string(),
713 },
714 files_api_key: None,
715 resilience: None,
716 tags: vec![],
717 };
718
719 let (key, secret, url) = cloud_profile.cloud_credentials().unwrap();
720 assert_eq!(key, "key");
721 assert_eq!(secret, "secret");
722 assert_eq!(url, "url");
723 assert!(cloud_profile.enterprise_credentials().is_none());
724 }
725
726 #[test]
727 #[serial_test::serial]
728 fn test_env_var_expansion() {
729 unsafe {
731 std::env::set_var("TEST_API_KEY", "test-key-value");
732 std::env::set_var("TEST_API_SECRET", "test-secret-value");
733 }
734
735 let content = r#"
736[profiles.test]
737deployment_type = "cloud"
738api_key = "${TEST_API_KEY}"
739api_secret = "${TEST_API_SECRET}"
740"#;
741
742 let expanded = Config::expand_env_vars(content);
743 assert!(expanded.contains("test-key-value"));
744 assert!(expanded.contains("test-secret-value"));
745
746 unsafe {
748 std::env::remove_var("TEST_API_KEY");
749 std::env::remove_var("TEST_API_SECRET");
750 }
751 }
752
753 #[test]
754 #[serial_test::serial]
755 fn test_env_var_expansion_with_defaults() {
756 unsafe {
758 std::env::remove_var("NONEXISTENT_VAR"); }
760
761 let content = r#"
762[profiles.test]
763deployment_type = "cloud"
764api_key = "${NONEXISTENT_VAR:-default-key}"
765api_url = "${NONEXISTENT_URL:-https://api.redislabs.com/v1}"
766"#;
767
768 let expanded = Config::expand_env_vars(content);
769 assert!(expanded.contains("default-key"));
770 assert!(expanded.contains("https://api.redislabs.com/v1"));
771 }
772
773 #[test]
774 #[serial_test::serial]
775 fn test_env_var_expansion_mixed() {
776 unsafe {
778 std::env::set_var("TEST_DYNAMIC_KEY", "dynamic-value");
779 }
780
781 let content = r#"
782[profiles.test]
783deployment_type = "cloud"
784api_key = "${TEST_DYNAMIC_KEY}"
785api_secret = "static-secret"
786api_url = "${MISSING_VAR:-https://api.redislabs.com/v1}"
787"#;
788
789 let expanded = Config::expand_env_vars(content);
790 assert!(expanded.contains("dynamic-value"));
791 assert!(expanded.contains("static-secret"));
792 assert!(expanded.contains("https://api.redislabs.com/v1"));
793
794 unsafe {
796 std::env::remove_var("TEST_DYNAMIC_KEY");
797 }
798 }
799
800 #[test]
801 #[serial_test::serial]
802 fn test_full_config_with_env_expansion() {
803 unsafe {
805 std::env::set_var("REDIS_TEST_KEY", "expanded-key");
806 std::env::set_var("REDIS_TEST_SECRET", "expanded-secret");
807 }
808
809 let config_content = r#"
810default_cloud = "test"
811
812[profiles.test]
813deployment_type = "cloud"
814api_key = "${REDIS_TEST_KEY}"
815api_secret = "${REDIS_TEST_SECRET}"
816api_url = "${REDIS_TEST_URL:-https://api.redislabs.com/v1}"
817"#;
818
819 let expanded = Config::expand_env_vars(config_content);
820 let config: Config = toml::from_str(&expanded).unwrap();
821
822 assert_eq!(config.default_cloud, Some("test".to_string()));
823
824 let profile = config.profiles.get("test").unwrap();
825 let (key, secret, url) = profile.cloud_credentials().unwrap();
826 assert_eq!(key, "expanded-key");
827 assert_eq!(secret, "expanded-secret");
828 assert_eq!(url, "https://api.redislabs.com/v1");
829
830 unsafe {
832 std::env::remove_var("REDIS_TEST_KEY");
833 std::env::remove_var("REDIS_TEST_SECRET");
834 }
835 }
836
837 #[test]
838 fn test_enterprise_profile_resolution() {
839 let mut config = Config::default();
840
841 let enterprise_profile = Profile {
843 deployment_type: DeploymentType::Enterprise,
844 credentials: ProfileCredentials::Enterprise {
845 url: "https://localhost:9443".to_string(),
846 username: "admin".to_string(),
847 password: Some("password".to_string()),
848 insecure: false,
849 ca_cert: None,
850 },
851 files_api_key: None,
852 resilience: None,
853 tags: vec![],
854 };
855 config.set_profile("ent1".to_string(), enterprise_profile);
856
857 assert_eq!(
859 config.resolve_enterprise_profile(Some("ent1")).unwrap(),
860 "ent1"
861 );
862
863 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
865
866 config.default_enterprise = Some("ent1".to_string());
868 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
869 }
870
871 #[test]
872 fn test_cloud_profile_resolution() {
873 let mut config = Config::default();
874
875 let cloud_profile = Profile {
877 deployment_type: DeploymentType::Cloud,
878 credentials: ProfileCredentials::Cloud {
879 api_key: "key".to_string(),
880 api_secret: "secret".to_string(),
881 api_url: "https://api.redislabs.com/v1".to_string(),
882 },
883 files_api_key: None,
884 resilience: None,
885 tags: vec![],
886 };
887 config.set_profile("cloud1".to_string(), cloud_profile);
888
889 assert_eq!(
891 config.resolve_cloud_profile(Some("cloud1")).unwrap(),
892 "cloud1"
893 );
894
895 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
897
898 config.default_cloud = Some("cloud1".to_string());
900 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
901 }
902
903 #[test]
904 fn test_mixed_profile_resolution() {
905 let mut config = Config::default();
906
907 let cloud_profile = Profile {
909 deployment_type: DeploymentType::Cloud,
910 credentials: ProfileCredentials::Cloud {
911 api_key: "key".to_string(),
912 api_secret: "secret".to_string(),
913 api_url: "https://api.redislabs.com/v1".to_string(),
914 },
915 files_api_key: None,
916 resilience: None,
917 tags: vec![],
918 };
919 config.set_profile("cloud1".to_string(), cloud_profile.clone());
920 config.set_profile("cloud2".to_string(), cloud_profile);
921
922 let enterprise_profile = Profile {
924 deployment_type: DeploymentType::Enterprise,
925 credentials: ProfileCredentials::Enterprise {
926 url: "https://localhost:9443".to_string(),
927 username: "admin".to_string(),
928 password: Some("password".to_string()),
929 insecure: false,
930 ca_cert: None,
931 },
932 files_api_key: None,
933 resilience: None,
934 tags: vec![],
935 };
936 config.set_profile("ent1".to_string(), enterprise_profile.clone());
937 config.set_profile("ent2".to_string(), enterprise_profile);
938
939 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
941 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
942
943 config.default_cloud = Some("cloud2".to_string());
945 config.default_enterprise = Some("ent2".to_string());
946
947 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud2");
949 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent2");
950 }
951
952 #[test]
953 fn test_no_profile_errors() {
954 let config = Config::default();
955
956 assert!(config.resolve_enterprise_profile(None).is_err());
958 assert!(config.resolve_cloud_profile(None).is_err());
959 }
960
961 #[test]
962 fn test_wrong_profile_type_help() {
963 let mut config = Config::default();
964
965 let cloud_profile = Profile {
967 deployment_type: DeploymentType::Cloud,
968 credentials: ProfileCredentials::Cloud {
969 api_key: "key".to_string(),
970 api_secret: "secret".to_string(),
971 api_url: "https://api.redislabs.com/v1".to_string(),
972 },
973 files_api_key: None,
974 resilience: None,
975 tags: vec![],
976 };
977 config.set_profile("cloud1".to_string(), cloud_profile);
978
979 let err = config.resolve_enterprise_profile(None).unwrap_err();
981 assert!(err.to_string().contains("No enterprise profiles"));
982 assert!(err.to_string().contains("cloud: cloud1"));
983 }
984
985 #[test]
986 fn test_database_profile_serialization() {
987 let mut config = Config::default();
988
989 let db_profile = Profile {
990 deployment_type: DeploymentType::Database,
991 credentials: ProfileCredentials::Database {
992 host: "localhost".to_string(),
993 port: 6379,
994 password: Some("secret".to_string()),
995 tls: true,
996 username: "default".to_string(),
997 database: 0,
998 },
999 files_api_key: None,
1000 resilience: None,
1001 tags: vec![],
1002 };
1003
1004 config.set_profile("myredis".to_string(), db_profile);
1005 config.default_database = Some("myredis".to_string());
1006
1007 let serialized = toml::to_string(&config).unwrap();
1008 let deserialized: Config = toml::from_str(&serialized).unwrap();
1009
1010 assert_eq!(config.default_database, deserialized.default_database);
1011 assert_eq!(config.profiles.len(), deserialized.profiles.len());
1012
1013 let profile = deserialized.profiles.get("myredis").unwrap();
1014 assert_eq!(profile.deployment_type, DeploymentType::Database);
1015
1016 let (host, port, password, tls, username, database) =
1017 profile.database_credentials().unwrap();
1018 assert_eq!(host, "localhost");
1019 assert_eq!(port, 6379);
1020 assert_eq!(password, Some("secret"));
1021 assert!(tls);
1022 assert_eq!(username, "default");
1023 assert_eq!(database, 0);
1024 }
1025
1026 #[test]
1027 fn test_database_profile_resolution() {
1028 let mut config = Config::default();
1029
1030 let db_profile = Profile {
1032 deployment_type: DeploymentType::Database,
1033 credentials: ProfileCredentials::Database {
1034 host: "localhost".to_string(),
1035 port: 6379,
1036 password: None,
1037 tls: false,
1038 username: "default".to_string(),
1039 database: 0,
1040 },
1041 files_api_key: None,
1042 resilience: None,
1043 tags: vec![],
1044 };
1045 config.set_profile("db1".to_string(), db_profile);
1046
1047 assert_eq!(config.resolve_database_profile(Some("db1")).unwrap(), "db1");
1049
1050 assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
1052
1053 config.default_database = Some("db1".to_string());
1055 assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
1056 }
1057
1058 #[test]
1059 fn test_database_profile_defaults() {
1060 let toml_content = r#"
1062[profiles.minimal]
1063deployment_type = "database"
1064host = "redis.example.com"
1065port = 12345
1066"#;
1067 let config: Config = toml::from_str(toml_content).unwrap();
1068 let profile = config.profiles.get("minimal").unwrap();
1069
1070 let (host, port, password, tls, username, database) =
1071 profile.database_credentials().unwrap();
1072 assert_eq!(host, "redis.example.com");
1073 assert_eq!(port, 12345);
1074 assert!(password.is_none());
1075 assert!(tls); assert_eq!(username, "default"); assert_eq!(database, 0); }
1079
1080 fn make_cloud_profile() -> Profile {
1083 Profile {
1084 deployment_type: DeploymentType::Cloud,
1085 credentials: ProfileCredentials::Cloud {
1086 api_key: "k".to_string(),
1087 api_secret: "s".to_string(),
1088 api_url: "https://api.redislabs.com/v1".to_string(),
1089 },
1090 files_api_key: None,
1091 resilience: None,
1092 tags: vec![],
1093 }
1094 }
1095
1096 fn make_enterprise_profile() -> Profile {
1097 Profile {
1098 deployment_type: DeploymentType::Enterprise,
1099 credentials: ProfileCredentials::Enterprise {
1100 url: "https://localhost:9443".to_string(),
1101 username: "admin".to_string(),
1102 password: Some("pw".to_string()),
1103 insecure: false,
1104 ca_cert: None,
1105 },
1106 files_api_key: None,
1107 resilience: None,
1108 tags: vec![],
1109 }
1110 }
1111
1112 #[test]
1113 fn test_resolve_profile_deployment_explicit_cloud() {
1114 let mut config = Config::default();
1115 config.set_profile("mycloud".to_string(), make_cloud_profile());
1116 config.set_profile("myent".to_string(), make_enterprise_profile());
1117
1118 assert_eq!(
1119 config.resolve_profile_deployment(Some("mycloud")).unwrap(),
1120 DeploymentType::Cloud
1121 );
1122 }
1123
1124 #[test]
1125 fn test_resolve_profile_deployment_explicit_enterprise() {
1126 let mut config = Config::default();
1127 config.set_profile("mycloud".to_string(), make_cloud_profile());
1128 config.set_profile("myent".to_string(), make_enterprise_profile());
1129
1130 assert_eq!(
1131 config.resolve_profile_deployment(Some("myent")).unwrap(),
1132 DeploymentType::Enterprise
1133 );
1134 }
1135
1136 #[test]
1137 fn test_resolve_profile_deployment_explicit_not_found() {
1138 let config = Config::default();
1139 let err = config
1140 .resolve_profile_deployment(Some("nonexistent"))
1141 .unwrap_err();
1142 assert!(err.to_string().contains("not found"));
1143 }
1144
1145 #[test]
1146 fn test_resolve_profile_deployment_cloud_only() {
1147 let mut config = Config::default();
1148 config.set_profile("c1".to_string(), make_cloud_profile());
1149
1150 assert_eq!(
1151 config.resolve_profile_deployment(None).unwrap(),
1152 DeploymentType::Cloud
1153 );
1154 }
1155
1156 #[test]
1157 fn test_resolve_profile_deployment_enterprise_only() {
1158 let mut config = Config::default();
1159 config.set_profile("e1".to_string(), make_enterprise_profile());
1160
1161 assert_eq!(
1162 config.resolve_profile_deployment(None).unwrap(),
1163 DeploymentType::Enterprise
1164 );
1165 }
1166
1167 #[test]
1168 fn test_resolve_profile_deployment_ambiguous() {
1169 let mut config = Config::default();
1170 config.set_profile("c1".to_string(), make_cloud_profile());
1171 config.set_profile("e1".to_string(), make_enterprise_profile());
1172
1173 let err = config.resolve_profile_deployment(None).unwrap_err();
1174 let msg = err.to_string();
1175 assert!(msg.contains("ambiguous deployment type"));
1176 }
1177
1178 #[test]
1179 fn test_resolve_profile_deployment_no_profiles() {
1180 let config = Config::default();
1181 let err = config.resolve_profile_deployment(None).unwrap_err();
1182 assert!(err.to_string().contains("No cloud or enterprise"));
1183 }
1184
1185 #[test]
1186 fn test_resolve_profile_deployment_ignores_database_profiles() {
1187 let mut config = Config::default();
1188 config.set_profile(
1189 "db1".to_string(),
1190 Profile {
1191 deployment_type: DeploymentType::Database,
1192 credentials: ProfileCredentials::Database {
1193 host: "localhost".to_string(),
1194 port: 6379,
1195 password: None,
1196 tls: false,
1197 username: "default".to_string(),
1198 database: 0,
1199 },
1200 files_api_key: None,
1201 resilience: None,
1202 tags: vec![],
1203 },
1204 );
1205
1206 let err = config.resolve_profile_deployment(None).unwrap_err();
1208 assert!(err.to_string().contains("No cloud or enterprise"));
1209 }
1210}