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