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_with_env_vars(
185 api_secret,
186 vec!["REDIS_CLOUD_SECRET_KEY", "REDIS_CLOUD_API_SECRET"],
187 )
188 .map_err(|e| {
189 ConfigError::CredentialError(format!("Failed to resolve API secret: {}", e))
190 })?;
191 let resolved_url = store
192 .get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
193 .map_err(|e| {
194 ConfigError::CredentialError(format!("Failed to resolve API URL: {}", e))
195 })?;
196
197 Ok(Some((resolved_key, resolved_secret, resolved_url)))
198 }
199 _ => Ok(None),
200 }
201 }
202
203 #[allow(clippy::type_complexity)]
205 pub fn resolve_enterprise_credentials(
206 &self,
207 ) -> Result<Option<(String, String, Option<String>, bool, Option<String>)>> {
208 match &self.credentials {
209 ProfileCredentials::Enterprise {
210 url,
211 username,
212 password,
213 insecure,
214 ca_cert,
215 } => {
216 let store = CredentialStore::new();
217
218 let resolved_url = store
220 .get_credential(url, Some("REDIS_ENTERPRISE_URL"))
221 .map_err(|e| {
222 ConfigError::CredentialError(format!("Failed to resolve URL: {}", e))
223 })?;
224 let resolved_username = store
225 .get_credential(username, Some("REDIS_ENTERPRISE_USER"))
226 .map_err(|e| {
227 ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
228 })?;
229 let resolved_password = password
230 .as_ref()
231 .map(|p| {
232 store
233 .get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
234 .map_err(|e| {
235 ConfigError::CredentialError(format!(
236 "Failed to resolve password: {}",
237 e
238 ))
239 })
240 })
241 .transpose()?;
242
243 Ok(Some((
244 resolved_url,
245 resolved_username,
246 resolved_password,
247 *insecure,
248 ca_cert.clone(),
249 )))
250 }
251 _ => Ok(None),
252 }
253 }
254
255 #[allow(clippy::type_complexity)]
257 pub fn database_credentials(&self) -> Option<(&str, u16, Option<&str>, bool, &str, u8)> {
258 match &self.credentials {
259 ProfileCredentials::Database {
260 host,
261 port,
262 password,
263 tls,
264 username,
265 database,
266 } => Some((
267 host.as_str(),
268 *port,
269 password.as_deref(),
270 *tls,
271 username.as_str(),
272 *database,
273 )),
274 _ => None,
275 }
276 }
277
278 #[allow(clippy::type_complexity)]
280 pub fn resolve_database_credentials(
281 &self,
282 ) -> Result<Option<(String, u16, Option<String>, bool, String, u8)>> {
283 match &self.credentials {
284 ProfileCredentials::Database {
285 host,
286 port,
287 password,
288 tls,
289 username,
290 database,
291 } => {
292 let store = CredentialStore::new();
293
294 let resolved_host =
296 store
297 .get_credential(host, Some("REDIS_HOST"))
298 .map_err(|e| {
299 ConfigError::CredentialError(format!("Failed to resolve host: {}", e))
300 })?;
301 let resolved_username = store
302 .get_credential(username, Some("REDIS_USERNAME"))
303 .map_err(|e| {
304 ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
305 })?;
306 let resolved_password = password
307 .as_ref()
308 .map(|p| {
309 store
310 .get_credential(p, Some("REDIS_PASSWORD"))
311 .map_err(|e| {
312 ConfigError::CredentialError(format!(
313 "Failed to resolve password: {}",
314 e
315 ))
316 })
317 })
318 .transpose()?;
319
320 Ok(Some((
321 resolved_host,
322 *port,
323 resolved_password,
324 *tls,
325 resolved_username,
326 *database,
327 )))
328 }
329 _ => Ok(None),
330 }
331 }
332}
333
334impl Config {
335 pub fn find_first_profile_of_type(&self, deployment_type: DeploymentType) -> Option<&str> {
337 let mut profiles: Vec<_> = self
338 .profiles
339 .iter()
340 .filter(|(_, p)| p.deployment_type == deployment_type)
341 .map(|(name, _)| name.as_str())
342 .collect();
343 profiles.sort();
344 profiles.first().copied()
345 }
346
347 pub fn get_profiles_of_type(&self, deployment_type: DeploymentType) -> Vec<&str> {
349 let mut profiles: Vec<_> = self
350 .profiles
351 .iter()
352 .filter(|(_, p)| p.deployment_type == deployment_type)
353 .map(|(name, _)| name.as_str())
354 .collect();
355 profiles.sort();
356 profiles
357 }
358
359 pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
361 if let Some(profile_name) = explicit_profile {
362 return Ok(profile_name.to_string());
364 }
365
366 if let Some(ref default) = self.default_enterprise {
367 return Ok(default.clone());
369 }
370
371 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
372 return Ok(profile_name.to_string());
374 }
375
376 let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
378 let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
379 if !cloud_profiles.is_empty() || !database_profiles.is_empty() {
380 let mut suggestions = Vec::new();
381 if !cloud_profiles.is_empty() {
382 suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
383 }
384 if !database_profiles.is_empty() {
385 suggestions.push(format!("database: {}", database_profiles.join(", ")));
386 }
387 Err(ConfigError::NoProfilesOfType {
388 deployment_type: "enterprise".to_string(),
389 suggestion: format!(
390 "Available profiles: {}. Use 'redisctl profile set' to create an enterprise profile.",
391 suggestions.join("; ")
392 ),
393 })
394 } else {
395 Err(ConfigError::NoProfilesOfType {
396 deployment_type: "enterprise".to_string(),
397 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
398 })
399 }
400 }
401
402 pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
404 if let Some(profile_name) = explicit_profile {
405 return Ok(profile_name.to_string());
407 }
408
409 if let Some(ref default) = self.default_cloud {
410 return Ok(default.clone());
412 }
413
414 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
415 return Ok(profile_name.to_string());
417 }
418
419 let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
421 let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
422 if !enterprise_profiles.is_empty() || !database_profiles.is_empty() {
423 let mut suggestions = Vec::new();
424 if !enterprise_profiles.is_empty() {
425 suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
426 }
427 if !database_profiles.is_empty() {
428 suggestions.push(format!("database: {}", database_profiles.join(", ")));
429 }
430 Err(ConfigError::NoProfilesOfType {
431 deployment_type: "cloud".to_string(),
432 suggestion: format!(
433 "Available profiles: {}. Use 'redisctl profile set' to create a cloud profile.",
434 suggestions.join("; ")
435 ),
436 })
437 } else {
438 Err(ConfigError::NoProfilesOfType {
439 deployment_type: "cloud".to_string(),
440 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
441 })
442 }
443 }
444
445 pub fn resolve_database_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
447 if let Some(profile_name) = explicit_profile {
448 return Ok(profile_name.to_string());
450 }
451
452 if let Some(ref default) = self.default_database {
453 return Ok(default.clone());
455 }
456
457 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Database) {
458 return Ok(profile_name.to_string());
460 }
461
462 let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
464 let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
465 if !cloud_profiles.is_empty() || !enterprise_profiles.is_empty() {
466 let mut suggestions = Vec::new();
467 if !cloud_profiles.is_empty() {
468 suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
469 }
470 if !enterprise_profiles.is_empty() {
471 suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
472 }
473 Err(ConfigError::NoProfilesOfType {
474 deployment_type: "database".to_string(),
475 suggestion: format!(
476 "Available profiles: {}. Use 'redisctl profile set' to create a database profile.",
477 suggestions.join("; ")
478 ),
479 })
480 } else {
481 Err(ConfigError::NoProfilesOfType {
482 deployment_type: "database".to_string(),
483 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
484 })
485 }
486 }
487
488 pub fn resolve_profile_deployment(
500 &self,
501 explicit_profile: Option<&str>,
502 ) -> Result<DeploymentType> {
503 if let Some(name) = explicit_profile {
505 let profile = self
506 .profiles
507 .get(name)
508 .ok_or_else(|| ConfigError::ProfileNotFound {
509 name: name.to_string(),
510 })?;
511 return Ok(profile.deployment_type);
512 }
513
514 let has_cloud = self
515 .profiles
516 .values()
517 .any(|p| p.deployment_type == DeploymentType::Cloud);
518 let has_enterprise = self
519 .profiles
520 .values()
521 .any(|p| p.deployment_type == DeploymentType::Enterprise);
522
523 match (has_cloud, has_enterprise) {
524 (true, false) => Ok(DeploymentType::Cloud),
525 (false, true) => Ok(DeploymentType::Enterprise),
526 (true, true) => Err(ConfigError::AmbiguousDeployment),
527 (false, false) => Err(ConfigError::NoProfilesOfType {
528 deployment_type: "cloud or enterprise".to_string(),
529 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
530 }),
531 }
532 }
533
534 pub fn load() -> Result<Self> {
536 let config_path = Self::config_path()?;
537 Self::load_from_path(&config_path)
538 }
539
540 pub fn load_from_path(config_path: &Path) -> Result<Self> {
542 if !config_path.exists() {
543 return Ok(Config::default());
544 }
545
546 let content = fs::read_to_string(config_path).map_err(|e| ConfigError::LoadError {
547 path: config_path.display().to_string(),
548 source: e,
549 })?;
550
551 let expanded_content = Self::expand_env_vars(&content);
553
554 let config: Config = toml::from_str(&expanded_content)?;
555
556 Ok(config)
557 }
558
559 pub fn save(&self) -> Result<()> {
561 let config_path = Self::config_path()?;
562 self.save_to_path(&config_path)
563 }
564
565 pub fn save_to_path(&self, config_path: &Path) -> Result<()> {
567 if let Some(parent) = config_path.parent() {
569 fs::create_dir_all(parent).map_err(|e| ConfigError::SaveError {
570 path: parent.display().to_string(),
571 source: e,
572 })?;
573 }
574
575 let content = toml::to_string_pretty(self)?;
576
577 fs::write(config_path, content).map_err(|e| ConfigError::SaveError {
578 path: config_path.display().to_string(),
579 source: e,
580 })?;
581
582 Ok(())
583 }
584
585 pub fn set_profile(&mut self, name: String, profile: Profile) {
587 self.profiles.insert(name, profile);
588 }
589
590 pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
592 if self.default_enterprise.as_deref() == Some(name) {
594 self.default_enterprise = None;
595 }
596 if self.default_cloud.as_deref() == Some(name) {
597 self.default_cloud = None;
598 }
599 if self.default_database.as_deref() == Some(name) {
600 self.default_database = None;
601 }
602 self.profiles.remove(name)
603 }
604
605 pub fn list_profiles(&self) -> Vec<(&String, &Profile)> {
607 let mut profiles: Vec<_> = self.profiles.iter().collect();
608 profiles.sort_by_key(|(name, _)| *name);
609 profiles
610 }
611
612 pub fn config_path() -> Result<PathBuf> {
621 #[cfg(target_os = "macos")]
623 {
624 if let Some(base_dirs) = BaseDirs::new() {
625 let home_dir = base_dirs.home_dir();
626 let linux_style_path = home_dir
627 .join(".config")
628 .join("redisctl")
629 .join("config.toml");
630
631 if linux_style_path.exists() {
633 return Ok(linux_style_path);
634 }
635
636 if linux_style_path
638 .parent()
639 .map(|p| p.exists())
640 .unwrap_or(false)
641 {
642 return Ok(linux_style_path);
643 }
644 }
645 }
646
647 let proj_dirs =
649 ProjectDirs::from("com", "redis", "redisctl").ok_or(ConfigError::ConfigDirError)?;
650
651 Ok(proj_dirs.config_dir().join("config.toml"))
652 }
653
654 fn expand_env_vars(content: &str) -> String {
666 let expanded =
669 shellexpand::env_with_context_no_errors(content, |var| std::env::var(var).ok());
670 expanded.to_string()
671 }
672}
673
674fn default_cloud_url() -> String {
675 "https://api.redislabs.com/v1".to_string()
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 #[test]
683 fn test_config_serialization() {
684 let mut config = Config::default();
685
686 let cloud_profile = Profile {
687 deployment_type: DeploymentType::Cloud,
688 credentials: ProfileCredentials::Cloud {
689 api_key: "test-key".to_string(),
690 api_secret: "test-secret".to_string(),
691 api_url: "https://api.redislabs.com/v1".to_string(),
692 },
693 files_api_key: None,
694 resilience: None,
695 tags: vec![],
696 };
697
698 config.set_profile("test".to_string(), cloud_profile);
699 config.default_cloud = Some("test".to_string());
700
701 let serialized = toml::to_string(&config).unwrap();
702 let deserialized: Config = toml::from_str(&serialized).unwrap();
703
704 assert_eq!(config.default_cloud, deserialized.default_cloud);
705 assert_eq!(config.profiles.len(), deserialized.profiles.len());
706 }
707
708 #[test]
709 fn test_profile_credential_access() {
710 let cloud_profile = Profile {
711 deployment_type: DeploymentType::Cloud,
712 credentials: ProfileCredentials::Cloud {
713 api_key: "key".to_string(),
714 api_secret: "secret".to_string(),
715 api_url: "url".to_string(),
716 },
717 files_api_key: None,
718 resilience: None,
719 tags: vec![],
720 };
721
722 let (key, secret, url) = cloud_profile.cloud_credentials().unwrap();
723 assert_eq!(key, "key");
724 assert_eq!(secret, "secret");
725 assert_eq!(url, "url");
726 assert!(cloud_profile.enterprise_credentials().is_none());
727 }
728
729 #[test]
730 #[serial_test::serial]
731 fn test_env_var_expansion() {
732 unsafe {
734 std::env::set_var("TEST_API_KEY", "test-key-value");
735 std::env::set_var("TEST_API_SECRET", "test-secret-value");
736 }
737
738 let content = r#"
739[profiles.test]
740deployment_type = "cloud"
741api_key = "${TEST_API_KEY}"
742api_secret = "${TEST_API_SECRET}"
743"#;
744
745 let expanded = Config::expand_env_vars(content);
746 assert!(expanded.contains("test-key-value"));
747 assert!(expanded.contains("test-secret-value"));
748
749 unsafe {
751 std::env::remove_var("TEST_API_KEY");
752 std::env::remove_var("TEST_API_SECRET");
753 }
754 }
755
756 #[test]
757 #[serial_test::serial]
758 fn test_env_var_expansion_with_defaults() {
759 unsafe {
761 std::env::remove_var("NONEXISTENT_VAR"); }
763
764 let content = r#"
765[profiles.test]
766deployment_type = "cloud"
767api_key = "${NONEXISTENT_VAR:-default-key}"
768api_url = "${NONEXISTENT_URL:-https://api.redislabs.com/v1}"
769"#;
770
771 let expanded = Config::expand_env_vars(content);
772 assert!(expanded.contains("default-key"));
773 assert!(expanded.contains("https://api.redislabs.com/v1"));
774 }
775
776 #[test]
777 #[serial_test::serial]
778 fn test_env_var_expansion_mixed() {
779 unsafe {
781 std::env::set_var("TEST_DYNAMIC_KEY", "dynamic-value");
782 }
783
784 let content = r#"
785[profiles.test]
786deployment_type = "cloud"
787api_key = "${TEST_DYNAMIC_KEY}"
788api_secret = "static-secret"
789api_url = "${MISSING_VAR:-https://api.redislabs.com/v1}"
790"#;
791
792 let expanded = Config::expand_env_vars(content);
793 assert!(expanded.contains("dynamic-value"));
794 assert!(expanded.contains("static-secret"));
795 assert!(expanded.contains("https://api.redislabs.com/v1"));
796
797 unsafe {
799 std::env::remove_var("TEST_DYNAMIC_KEY");
800 }
801 }
802
803 #[test]
804 #[serial_test::serial]
805 fn test_full_config_with_env_expansion() {
806 unsafe {
808 std::env::set_var("REDIS_TEST_KEY", "expanded-key");
809 std::env::set_var("REDIS_TEST_SECRET", "expanded-secret");
810 }
811
812 let config_content = r#"
813default_cloud = "test"
814
815[profiles.test]
816deployment_type = "cloud"
817api_key = "${REDIS_TEST_KEY}"
818api_secret = "${REDIS_TEST_SECRET}"
819api_url = "${REDIS_TEST_URL:-https://api.redislabs.com/v1}"
820"#;
821
822 let expanded = Config::expand_env_vars(config_content);
823 let config: Config = toml::from_str(&expanded).unwrap();
824
825 assert_eq!(config.default_cloud, Some("test".to_string()));
826
827 let profile = config.profiles.get("test").unwrap();
828 let (key, secret, url) = profile.cloud_credentials().unwrap();
829 assert_eq!(key, "expanded-key");
830 assert_eq!(secret, "expanded-secret");
831 assert_eq!(url, "https://api.redislabs.com/v1");
832
833 unsafe {
835 std::env::remove_var("REDIS_TEST_KEY");
836 std::env::remove_var("REDIS_TEST_SECRET");
837 }
838 }
839
840 #[test]
841 fn test_enterprise_profile_resolution() {
842 let mut config = Config::default();
843
844 let enterprise_profile = Profile {
846 deployment_type: DeploymentType::Enterprise,
847 credentials: ProfileCredentials::Enterprise {
848 url: "https://localhost:9443".to_string(),
849 username: "admin".to_string(),
850 password: Some("password".to_string()),
851 insecure: false,
852 ca_cert: None,
853 },
854 files_api_key: None,
855 resilience: None,
856 tags: vec![],
857 };
858 config.set_profile("ent1".to_string(), enterprise_profile);
859
860 assert_eq!(
862 config.resolve_enterprise_profile(Some("ent1")).unwrap(),
863 "ent1"
864 );
865
866 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
868
869 config.default_enterprise = Some("ent1".to_string());
871 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
872 }
873
874 #[test]
875 fn test_cloud_profile_resolution() {
876 let mut config = Config::default();
877
878 let cloud_profile = Profile {
880 deployment_type: DeploymentType::Cloud,
881 credentials: ProfileCredentials::Cloud {
882 api_key: "key".to_string(),
883 api_secret: "secret".to_string(),
884 api_url: "https://api.redislabs.com/v1".to_string(),
885 },
886 files_api_key: None,
887 resilience: None,
888 tags: vec![],
889 };
890 config.set_profile("cloud1".to_string(), cloud_profile);
891
892 assert_eq!(
894 config.resolve_cloud_profile(Some("cloud1")).unwrap(),
895 "cloud1"
896 );
897
898 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
900
901 config.default_cloud = Some("cloud1".to_string());
903 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
904 }
905
906 #[test]
907 fn test_mixed_profile_resolution() {
908 let mut config = Config::default();
909
910 let cloud_profile = Profile {
912 deployment_type: DeploymentType::Cloud,
913 credentials: ProfileCredentials::Cloud {
914 api_key: "key".to_string(),
915 api_secret: "secret".to_string(),
916 api_url: "https://api.redislabs.com/v1".to_string(),
917 },
918 files_api_key: None,
919 resilience: None,
920 tags: vec![],
921 };
922 config.set_profile("cloud1".to_string(), cloud_profile.clone());
923 config.set_profile("cloud2".to_string(), cloud_profile);
924
925 let enterprise_profile = Profile {
927 deployment_type: DeploymentType::Enterprise,
928 credentials: ProfileCredentials::Enterprise {
929 url: "https://localhost:9443".to_string(),
930 username: "admin".to_string(),
931 password: Some("password".to_string()),
932 insecure: false,
933 ca_cert: None,
934 },
935 files_api_key: None,
936 resilience: None,
937 tags: vec![],
938 };
939 config.set_profile("ent1".to_string(), enterprise_profile.clone());
940 config.set_profile("ent2".to_string(), enterprise_profile);
941
942 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
944 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
945
946 config.default_cloud = Some("cloud2".to_string());
948 config.default_enterprise = Some("ent2".to_string());
949
950 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud2");
952 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent2");
953 }
954
955 #[test]
956 fn test_no_profile_errors() {
957 let config = Config::default();
958
959 assert!(config.resolve_enterprise_profile(None).is_err());
961 assert!(config.resolve_cloud_profile(None).is_err());
962 }
963
964 #[test]
965 fn test_wrong_profile_type_help() {
966 let mut config = Config::default();
967
968 let cloud_profile = Profile {
970 deployment_type: DeploymentType::Cloud,
971 credentials: ProfileCredentials::Cloud {
972 api_key: "key".to_string(),
973 api_secret: "secret".to_string(),
974 api_url: "https://api.redislabs.com/v1".to_string(),
975 },
976 files_api_key: None,
977 resilience: None,
978 tags: vec![],
979 };
980 config.set_profile("cloud1".to_string(), cloud_profile);
981
982 let err = config.resolve_enterprise_profile(None).unwrap_err();
984 assert!(err.to_string().contains("No enterprise profiles"));
985 assert!(err.to_string().contains("cloud: cloud1"));
986 }
987
988 #[test]
989 fn test_database_profile_serialization() {
990 let mut config = Config::default();
991
992 let db_profile = Profile {
993 deployment_type: DeploymentType::Database,
994 credentials: ProfileCredentials::Database {
995 host: "localhost".to_string(),
996 port: 6379,
997 password: Some("secret".to_string()),
998 tls: true,
999 username: "default".to_string(),
1000 database: 0,
1001 },
1002 files_api_key: None,
1003 resilience: None,
1004 tags: vec![],
1005 };
1006
1007 config.set_profile("myredis".to_string(), db_profile);
1008 config.default_database = Some("myredis".to_string());
1009
1010 let serialized = toml::to_string(&config).unwrap();
1011 let deserialized: Config = toml::from_str(&serialized).unwrap();
1012
1013 assert_eq!(config.default_database, deserialized.default_database);
1014 assert_eq!(config.profiles.len(), deserialized.profiles.len());
1015
1016 let profile = deserialized.profiles.get("myredis").unwrap();
1017 assert_eq!(profile.deployment_type, DeploymentType::Database);
1018
1019 let (host, port, password, tls, username, database) =
1020 profile.database_credentials().unwrap();
1021 assert_eq!(host, "localhost");
1022 assert_eq!(port, 6379);
1023 assert_eq!(password, Some("secret"));
1024 assert!(tls);
1025 assert_eq!(username, "default");
1026 assert_eq!(database, 0);
1027 }
1028
1029 #[test]
1030 fn test_database_profile_resolution() {
1031 let mut config = Config::default();
1032
1033 let db_profile = Profile {
1035 deployment_type: DeploymentType::Database,
1036 credentials: ProfileCredentials::Database {
1037 host: "localhost".to_string(),
1038 port: 6379,
1039 password: None,
1040 tls: false,
1041 username: "default".to_string(),
1042 database: 0,
1043 },
1044 files_api_key: None,
1045 resilience: None,
1046 tags: vec![],
1047 };
1048 config.set_profile("db1".to_string(), db_profile);
1049
1050 assert_eq!(config.resolve_database_profile(Some("db1")).unwrap(), "db1");
1052
1053 assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
1055
1056 config.default_database = Some("db1".to_string());
1058 assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
1059 }
1060
1061 #[test]
1062 fn test_database_profile_defaults() {
1063 let toml_content = r#"
1065[profiles.minimal]
1066deployment_type = "database"
1067host = "redis.example.com"
1068port = 12345
1069"#;
1070 let config: Config = toml::from_str(toml_content).unwrap();
1071 let profile = config.profiles.get("minimal").unwrap();
1072
1073 let (host, port, password, tls, username, database) =
1074 profile.database_credentials().unwrap();
1075 assert_eq!(host, "redis.example.com");
1076 assert_eq!(port, 12345);
1077 assert!(password.is_none());
1078 assert!(tls); assert_eq!(username, "default"); assert_eq!(database, 0); }
1082
1083 fn make_cloud_profile() -> Profile {
1086 Profile {
1087 deployment_type: DeploymentType::Cloud,
1088 credentials: ProfileCredentials::Cloud {
1089 api_key: "k".to_string(),
1090 api_secret: "s".to_string(),
1091 api_url: "https://api.redislabs.com/v1".to_string(),
1092 },
1093 files_api_key: None,
1094 resilience: None,
1095 tags: vec![],
1096 }
1097 }
1098
1099 fn make_enterprise_profile() -> Profile {
1100 Profile {
1101 deployment_type: DeploymentType::Enterprise,
1102 credentials: ProfileCredentials::Enterprise {
1103 url: "https://localhost:9443".to_string(),
1104 username: "admin".to_string(),
1105 password: Some("pw".to_string()),
1106 insecure: false,
1107 ca_cert: None,
1108 },
1109 files_api_key: None,
1110 resilience: None,
1111 tags: vec![],
1112 }
1113 }
1114
1115 #[test]
1116 fn test_resolve_profile_deployment_explicit_cloud() {
1117 let mut config = Config::default();
1118 config.set_profile("mycloud".to_string(), make_cloud_profile());
1119 config.set_profile("myent".to_string(), make_enterprise_profile());
1120
1121 assert_eq!(
1122 config.resolve_profile_deployment(Some("mycloud")).unwrap(),
1123 DeploymentType::Cloud
1124 );
1125 }
1126
1127 #[test]
1128 fn test_resolve_profile_deployment_explicit_enterprise() {
1129 let mut config = Config::default();
1130 config.set_profile("mycloud".to_string(), make_cloud_profile());
1131 config.set_profile("myent".to_string(), make_enterprise_profile());
1132
1133 assert_eq!(
1134 config.resolve_profile_deployment(Some("myent")).unwrap(),
1135 DeploymentType::Enterprise
1136 );
1137 }
1138
1139 #[test]
1140 fn test_resolve_profile_deployment_explicit_not_found() {
1141 let config = Config::default();
1142 let err = config
1143 .resolve_profile_deployment(Some("nonexistent"))
1144 .unwrap_err();
1145 assert!(err.to_string().contains("not found"));
1146 }
1147
1148 #[test]
1149 fn test_resolve_profile_deployment_cloud_only() {
1150 let mut config = Config::default();
1151 config.set_profile("c1".to_string(), make_cloud_profile());
1152
1153 assert_eq!(
1154 config.resolve_profile_deployment(None).unwrap(),
1155 DeploymentType::Cloud
1156 );
1157 }
1158
1159 #[test]
1160 fn test_resolve_profile_deployment_enterprise_only() {
1161 let mut config = Config::default();
1162 config.set_profile("e1".to_string(), make_enterprise_profile());
1163
1164 assert_eq!(
1165 config.resolve_profile_deployment(None).unwrap(),
1166 DeploymentType::Enterprise
1167 );
1168 }
1169
1170 #[test]
1171 fn test_resolve_profile_deployment_ambiguous() {
1172 let mut config = Config::default();
1173 config.set_profile("c1".to_string(), make_cloud_profile());
1174 config.set_profile("e1".to_string(), make_enterprise_profile());
1175
1176 let err = config.resolve_profile_deployment(None).unwrap_err();
1177 let msg = err.to_string();
1178 assert!(msg.contains("ambiguous deployment type"));
1179 }
1180
1181 #[test]
1182 fn test_resolve_profile_deployment_no_profiles() {
1183 let config = Config::default();
1184 let err = config.resolve_profile_deployment(None).unwrap_err();
1185 assert!(err.to_string().contains("No cloud or enterprise"));
1186 }
1187
1188 #[test]
1189 fn test_resolve_profile_deployment_ignores_database_profiles() {
1190 let mut config = Config::default();
1191 config.set_profile(
1192 "db1".to_string(),
1193 Profile {
1194 deployment_type: DeploymentType::Database,
1195 credentials: ProfileCredentials::Database {
1196 host: "localhost".to_string(),
1197 port: 6379,
1198 password: None,
1199 tls: false,
1200 username: "default".to_string(),
1201 database: 0,
1202 },
1203 files_api_key: None,
1204 resilience: None,
1205 tags: vec![],
1206 },
1207 );
1208
1209 let err = config.resolve_profile_deployment(None).unwrap_err();
1211 assert!(err.to_string().contains("No cloud or enterprise"));
1212 }
1213}