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 crate::credential::CredentialStore;
15use crate::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, skip_serializing_if = "Option::is_none")]
29 pub files_api_key: Option<String>,
30 #[serde(default)]
32 pub profiles: HashMap<String, Profile>,
33}
34
35#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct Profile {
38 pub deployment_type: DeploymentType,
40 #[serde(flatten)]
42 pub credentials: ProfileCredentials,
43 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub files_api_key: Option<String>,
47 #[serde(default, skip_serializing_if = "Option::is_none")]
49 pub resilience: Option<crate::ResilienceConfig>,
50}
51
52#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
54#[serde(rename_all = "lowercase")]
55pub enum DeploymentType {
56 Cloud,
57 Enterprise,
58}
59
60#[derive(Debug, Serialize, Deserialize, Clone)]
62#[serde(untagged)]
63pub enum ProfileCredentials {
64 Cloud {
65 api_key: String,
66 api_secret: String,
67 #[serde(default = "default_cloud_url")]
68 api_url: String,
69 },
70 Enterprise {
71 url: String,
72 username: String,
73 password: Option<String>, #[serde(default)]
75 insecure: bool,
76 },
77}
78
79impl std::fmt::Display for DeploymentType {
80 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
81 match self {
82 DeploymentType::Cloud => write!(f, "cloud"),
83 DeploymentType::Enterprise => write!(f, "enterprise"),
84 }
85 }
86}
87
88impl Profile {
89 pub fn cloud_credentials(&self) -> Option<(&str, &str, &str)> {
91 match &self.credentials {
92 ProfileCredentials::Cloud {
93 api_key,
94 api_secret,
95 api_url,
96 } => Some((api_key.as_str(), api_secret.as_str(), api_url.as_str())),
97 _ => None,
98 }
99 }
100
101 pub fn enterprise_credentials(&self) -> Option<(&str, &str, Option<&str>, bool)> {
103 match &self.credentials {
104 ProfileCredentials::Enterprise {
105 url,
106 username,
107 password,
108 insecure,
109 } => Some((
110 url.as_str(),
111 username.as_str(),
112 password.as_deref(),
113 *insecure,
114 )),
115 _ => None,
116 }
117 }
118
119 pub fn has_password(&self) -> bool {
121 matches!(
122 self.credentials,
123 ProfileCredentials::Enterprise {
124 password: Some(_),
125 ..
126 }
127 )
128 }
129
130 pub fn resolve_cloud_credentials(&self) -> Result<Option<(String, String, String)>> {
132 match &self.credentials {
133 ProfileCredentials::Cloud {
134 api_key,
135 api_secret,
136 api_url,
137 } => {
138 let store = CredentialStore::new();
139
140 let resolved_key = store
142 .get_credential(api_key, Some("REDIS_CLOUD_API_KEY"))
143 .map_err(|e| {
144 ConfigError::CredentialError(format!("Failed to resolve API key: {}", e))
145 })?;
146 let resolved_secret = store
147 .get_credential(api_secret, Some("REDIS_CLOUD_API_SECRET"))
148 .map_err(|e| {
149 ConfigError::CredentialError(format!("Failed to resolve API secret: {}", e))
150 })?;
151 let resolved_url = store
152 .get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
153 .map_err(|e| {
154 ConfigError::CredentialError(format!("Failed to resolve API URL: {}", e))
155 })?;
156
157 Ok(Some((resolved_key, resolved_secret, resolved_url)))
158 }
159 _ => Ok(None),
160 }
161 }
162
163 #[allow(clippy::type_complexity)]
165 pub fn resolve_enterprise_credentials(
166 &self,
167 ) -> Result<Option<(String, String, Option<String>, bool)>> {
168 match &self.credentials {
169 ProfileCredentials::Enterprise {
170 url,
171 username,
172 password,
173 insecure,
174 } => {
175 let store = CredentialStore::new();
176
177 let resolved_url = store
179 .get_credential(url, Some("REDIS_ENTERPRISE_URL"))
180 .map_err(|e| {
181 ConfigError::CredentialError(format!("Failed to resolve URL: {}", e))
182 })?;
183 let resolved_username = store
184 .get_credential(username, Some("REDIS_ENTERPRISE_USER"))
185 .map_err(|e| {
186 ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
187 })?;
188 let resolved_password = password
189 .as_ref()
190 .map(|p| {
191 store
192 .get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
193 .map_err(|e| {
194 ConfigError::CredentialError(format!(
195 "Failed to resolve password: {}",
196 e
197 ))
198 })
199 })
200 .transpose()?;
201
202 Ok(Some((
203 resolved_url,
204 resolved_username,
205 resolved_password,
206 *insecure,
207 )))
208 }
209 _ => Ok(None),
210 }
211 }
212}
213
214impl Config {
215 pub fn find_first_profile_of_type(&self, deployment_type: DeploymentType) -> Option<&str> {
217 let mut profiles: Vec<_> = self
218 .profiles
219 .iter()
220 .filter(|(_, p)| p.deployment_type == deployment_type)
221 .map(|(name, _)| name.as_str())
222 .collect();
223 profiles.sort();
224 profiles.first().copied()
225 }
226
227 pub fn get_profiles_of_type(&self, deployment_type: DeploymentType) -> Vec<&str> {
229 let mut profiles: Vec<_> = self
230 .profiles
231 .iter()
232 .filter(|(_, p)| p.deployment_type == deployment_type)
233 .map(|(name, _)| name.as_str())
234 .collect();
235 profiles.sort();
236 profiles
237 }
238
239 pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
241 if let Some(profile_name) = explicit_profile {
242 return Ok(profile_name.to_string());
244 }
245
246 if let Some(ref default) = self.default_enterprise {
247 return Ok(default.clone());
249 }
250
251 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
252 return Ok(profile_name.to_string());
254 }
255
256 let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
258 if !cloud_profiles.is_empty() {
259 Err(ConfigError::NoProfilesOfType {
260 deployment_type: "enterprise".to_string(),
261 suggestion: format!(
262 "Available cloud profiles: {}. Use 'redisctl profile set' to create an enterprise profile.",
263 cloud_profiles.join(", ")
264 ),
265 })
266 } else {
267 Err(ConfigError::NoProfilesOfType {
268 deployment_type: "enterprise".to_string(),
269 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
270 })
271 }
272 }
273
274 pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
276 if let Some(profile_name) = explicit_profile {
277 return Ok(profile_name.to_string());
279 }
280
281 if let Some(ref default) = self.default_cloud {
282 return Ok(default.clone());
284 }
285
286 if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
287 return Ok(profile_name.to_string());
289 }
290
291 let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
293 if !enterprise_profiles.is_empty() {
294 Err(ConfigError::NoProfilesOfType {
295 deployment_type: "cloud".to_string(),
296 suggestion: format!(
297 "Available enterprise profiles: {}. Use 'redisctl profile set' to create a cloud profile.",
298 enterprise_profiles.join(", ")
299 ),
300 })
301 } else {
302 Err(ConfigError::NoProfilesOfType {
303 deployment_type: "cloud".to_string(),
304 suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
305 })
306 }
307 }
308
309 pub fn load() -> Result<Self> {
311 let config_path = Self::config_path()?;
312 Self::load_from_path(&config_path)
313 }
314
315 pub fn load_from_path(config_path: &Path) -> Result<Self> {
317 if !config_path.exists() {
318 return Ok(Config::default());
319 }
320
321 let content = fs::read_to_string(config_path).map_err(|e| ConfigError::LoadError {
322 path: config_path.display().to_string(),
323 source: e,
324 })?;
325
326 let expanded_content = Self::expand_env_vars(&content);
328
329 let config: Config = toml::from_str(&expanded_content)?;
330
331 Ok(config)
332 }
333
334 pub fn save(&self) -> Result<()> {
336 let config_path = Self::config_path()?;
337 self.save_to_path(&config_path)
338 }
339
340 pub fn save_to_path(&self, config_path: &Path) -> Result<()> {
342 if let Some(parent) = config_path.parent() {
344 fs::create_dir_all(parent).map_err(|e| ConfigError::SaveError {
345 path: parent.display().to_string(),
346 source: e,
347 })?;
348 }
349
350 let content = toml::to_string_pretty(self)?;
351
352 fs::write(config_path, content).map_err(|e| ConfigError::SaveError {
353 path: config_path.display().to_string(),
354 source: e,
355 })?;
356
357 Ok(())
358 }
359
360 pub fn set_profile(&mut self, name: String, profile: Profile) {
362 self.profiles.insert(name, profile);
363 }
364
365 pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
367 if self.default_enterprise.as_deref() == Some(name) {
369 self.default_enterprise = None;
370 }
371 if self.default_cloud.as_deref() == Some(name) {
372 self.default_cloud = None;
373 }
374 self.profiles.remove(name)
375 }
376
377 pub fn list_profiles(&self) -> Vec<(&String, &Profile)> {
379 let mut profiles: Vec<_> = self.profiles.iter().collect();
380 profiles.sort_by_key(|(name, _)| *name);
381 profiles
382 }
383
384 pub fn config_path() -> Result<PathBuf> {
393 #[cfg(target_os = "macos")]
395 {
396 if let Some(base_dirs) = BaseDirs::new() {
397 let home_dir = base_dirs.home_dir();
398 let linux_style_path = home_dir
399 .join(".config")
400 .join("redisctl")
401 .join("config.toml");
402
403 if linux_style_path.exists() {
405 return Ok(linux_style_path);
406 }
407
408 if linux_style_path
410 .parent()
411 .map(|p| p.exists())
412 .unwrap_or(false)
413 {
414 return Ok(linux_style_path);
415 }
416 }
417 }
418
419 let proj_dirs =
421 ProjectDirs::from("com", "redis", "redisctl").ok_or(ConfigError::ConfigDirError)?;
422
423 Ok(proj_dirs.config_dir().join("config.toml"))
424 }
425
426 fn expand_env_vars(content: &str) -> String {
438 let expanded =
441 shellexpand::env_with_context_no_errors(content, |var| std::env::var(var).ok());
442 expanded.to_string()
443 }
444}
445
446fn default_cloud_url() -> String {
447 "https://api.redislabs.com/v1".to_string()
448}
449
450#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
455 fn test_config_serialization() {
456 let mut config = Config::default();
457
458 let cloud_profile = Profile {
459 deployment_type: DeploymentType::Cloud,
460 credentials: ProfileCredentials::Cloud {
461 api_key: "test-key".to_string(),
462 api_secret: "test-secret".to_string(),
463 api_url: "https://api.redislabs.com/v1".to_string(),
464 },
465 files_api_key: None,
466 resilience: None,
467 };
468
469 config.set_profile("test".to_string(), cloud_profile);
470 config.default_cloud = Some("test".to_string());
471
472 let serialized = toml::to_string(&config).unwrap();
473 let deserialized: Config = toml::from_str(&serialized).unwrap();
474
475 assert_eq!(config.default_cloud, deserialized.default_cloud);
476 assert_eq!(config.profiles.len(), deserialized.profiles.len());
477 }
478
479 #[test]
480 fn test_profile_credential_access() {
481 let cloud_profile = Profile {
482 deployment_type: DeploymentType::Cloud,
483 credentials: ProfileCredentials::Cloud {
484 api_key: "key".to_string(),
485 api_secret: "secret".to_string(),
486 api_url: "url".to_string(),
487 },
488 files_api_key: None,
489 resilience: None,
490 };
491
492 let (key, secret, url) = cloud_profile.cloud_credentials().unwrap();
493 assert_eq!(key, "key");
494 assert_eq!(secret, "secret");
495 assert_eq!(url, "url");
496 assert!(cloud_profile.enterprise_credentials().is_none());
497 }
498
499 #[test]
500 #[serial_test::serial]
501 fn test_env_var_expansion() {
502 unsafe {
504 std::env::set_var("TEST_API_KEY", "test-key-value");
505 std::env::set_var("TEST_API_SECRET", "test-secret-value");
506 }
507
508 let content = r#"
509[profiles.test]
510deployment_type = "cloud"
511api_key = "${TEST_API_KEY}"
512api_secret = "${TEST_API_SECRET}"
513"#;
514
515 let expanded = Config::expand_env_vars(content);
516 assert!(expanded.contains("test-key-value"));
517 assert!(expanded.contains("test-secret-value"));
518
519 unsafe {
521 std::env::remove_var("TEST_API_KEY");
522 std::env::remove_var("TEST_API_SECRET");
523 }
524 }
525
526 #[test]
527 #[serial_test::serial]
528 fn test_env_var_expansion_with_defaults() {
529 unsafe {
531 std::env::remove_var("NONEXISTENT_VAR"); }
533
534 let content = r#"
535[profiles.test]
536deployment_type = "cloud"
537api_key = "${NONEXISTENT_VAR:-default-key}"
538api_url = "${NONEXISTENT_URL:-https://api.redislabs.com/v1}"
539"#;
540
541 let expanded = Config::expand_env_vars(content);
542 assert!(expanded.contains("default-key"));
543 assert!(expanded.contains("https://api.redislabs.com/v1"));
544 }
545
546 #[test]
547 #[serial_test::serial]
548 fn test_env_var_expansion_mixed() {
549 unsafe {
551 std::env::set_var("TEST_DYNAMIC_KEY", "dynamic-value");
552 }
553
554 let content = r#"
555[profiles.test]
556deployment_type = "cloud"
557api_key = "${TEST_DYNAMIC_KEY}"
558api_secret = "static-secret"
559api_url = "${MISSING_VAR:-https://api.redislabs.com/v1}"
560"#;
561
562 let expanded = Config::expand_env_vars(content);
563 assert!(expanded.contains("dynamic-value"));
564 assert!(expanded.contains("static-secret"));
565 assert!(expanded.contains("https://api.redislabs.com/v1"));
566
567 unsafe {
569 std::env::remove_var("TEST_DYNAMIC_KEY");
570 }
571 }
572
573 #[test]
574 #[serial_test::serial]
575 fn test_full_config_with_env_expansion() {
576 unsafe {
578 std::env::set_var("REDIS_TEST_KEY", "expanded-key");
579 std::env::set_var("REDIS_TEST_SECRET", "expanded-secret");
580 }
581
582 let config_content = r#"
583default_cloud = "test"
584
585[profiles.test]
586deployment_type = "cloud"
587api_key = "${REDIS_TEST_KEY}"
588api_secret = "${REDIS_TEST_SECRET}"
589api_url = "${REDIS_TEST_URL:-https://api.redislabs.com/v1}"
590"#;
591
592 let expanded = Config::expand_env_vars(config_content);
593 let config: Config = toml::from_str(&expanded).unwrap();
594
595 assert_eq!(config.default_cloud, Some("test".to_string()));
596
597 let profile = config.profiles.get("test").unwrap();
598 let (key, secret, url) = profile.cloud_credentials().unwrap();
599 assert_eq!(key, "expanded-key");
600 assert_eq!(secret, "expanded-secret");
601 assert_eq!(url, "https://api.redislabs.com/v1");
602
603 unsafe {
605 std::env::remove_var("REDIS_TEST_KEY");
606 std::env::remove_var("REDIS_TEST_SECRET");
607 }
608 }
609
610 #[test]
611 fn test_enterprise_profile_resolution() {
612 let mut config = Config::default();
613
614 let enterprise_profile = Profile {
616 deployment_type: DeploymentType::Enterprise,
617 credentials: ProfileCredentials::Enterprise {
618 url: "https://localhost:9443".to_string(),
619 username: "admin".to_string(),
620 password: Some("password".to_string()),
621 insecure: false,
622 },
623 files_api_key: None,
624 resilience: None,
625 };
626 config.set_profile("ent1".to_string(), enterprise_profile);
627
628 assert_eq!(
630 config.resolve_enterprise_profile(Some("ent1")).unwrap(),
631 "ent1"
632 );
633
634 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
636
637 config.default_enterprise = Some("ent1".to_string());
639 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
640 }
641
642 #[test]
643 fn test_cloud_profile_resolution() {
644 let mut config = Config::default();
645
646 let cloud_profile = Profile {
648 deployment_type: DeploymentType::Cloud,
649 credentials: ProfileCredentials::Cloud {
650 api_key: "key".to_string(),
651 api_secret: "secret".to_string(),
652 api_url: "https://api.redislabs.com/v1".to_string(),
653 },
654 files_api_key: None,
655 resilience: None,
656 };
657 config.set_profile("cloud1".to_string(), cloud_profile);
658
659 assert_eq!(
661 config.resolve_cloud_profile(Some("cloud1")).unwrap(),
662 "cloud1"
663 );
664
665 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
667
668 config.default_cloud = Some("cloud1".to_string());
670 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
671 }
672
673 #[test]
674 fn test_mixed_profile_resolution() {
675 let mut config = Config::default();
676
677 let cloud_profile = Profile {
679 deployment_type: DeploymentType::Cloud,
680 credentials: ProfileCredentials::Cloud {
681 api_key: "key".to_string(),
682 api_secret: "secret".to_string(),
683 api_url: "https://api.redislabs.com/v1".to_string(),
684 },
685 files_api_key: None,
686 resilience: None,
687 };
688 config.set_profile("cloud1".to_string(), cloud_profile.clone());
689 config.set_profile("cloud2".to_string(), cloud_profile);
690
691 let enterprise_profile = Profile {
693 deployment_type: DeploymentType::Enterprise,
694 credentials: ProfileCredentials::Enterprise {
695 url: "https://localhost:9443".to_string(),
696 username: "admin".to_string(),
697 password: Some("password".to_string()),
698 insecure: false,
699 },
700 files_api_key: None,
701 resilience: None,
702 };
703 config.set_profile("ent1".to_string(), enterprise_profile.clone());
704 config.set_profile("ent2".to_string(), enterprise_profile);
705
706 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
708 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
709
710 config.default_cloud = Some("cloud2".to_string());
712 config.default_enterprise = Some("ent2".to_string());
713
714 assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud2");
716 assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent2");
717 }
718
719 #[test]
720 fn test_no_profile_errors() {
721 let config = Config::default();
722
723 assert!(config.resolve_enterprise_profile(None).is_err());
725 assert!(config.resolve_cloud_profile(None).is_err());
726 }
727
728 #[test]
729 fn test_wrong_profile_type_help() {
730 let mut config = Config::default();
731
732 let cloud_profile = Profile {
734 deployment_type: DeploymentType::Cloud,
735 credentials: ProfileCredentials::Cloud {
736 api_key: "key".to_string(),
737 api_secret: "secret".to_string(),
738 api_url: "https://api.redislabs.com/v1".to_string(),
739 },
740 files_api_key: None,
741 resilience: None,
742 };
743 config.set_profile("cloud1".to_string(), cloud_profile);
744
745 let err = config.resolve_enterprise_profile(None).unwrap_err();
747 assert!(err.to_string().contains("No enterprise profiles"));
748 assert!(err.to_string().contains("Available cloud profiles: cloud1"));
749 }
750}