redisctl_config/
config.rs

1//! Configuration management for Redis CLI tools
2//!
3//! Handles configuration loading from files, environment variables, and command-line arguments.
4//! Configuration is stored in TOML format with support for multiple named profiles.
5
6#[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/// Main configuration structure
18#[derive(Debug, Serialize, Deserialize, Default, Clone)]
19pub struct Config {
20    /// Default profile for enterprise commands
21    #[serde(default, rename = "default_enterprise")]
22    pub default_enterprise: Option<String>,
23    /// Default profile for cloud commands
24    #[serde(default, rename = "default_cloud")]
25    pub default_cloud: Option<String>,
26    /// Global Files.com API key for support package uploads
27    /// Can be overridden per-profile. Supports keyring: prefix for secure storage.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub files_api_key: Option<String>,
30    /// Map of profile name -> profile configuration
31    #[serde(default)]
32    pub profiles: HashMap<String, Profile>,
33}
34
35/// Individual profile configuration
36#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct Profile {
38    /// Type of deployment this profile connects to
39    pub deployment_type: DeploymentType,
40    /// Connection credentials (flattened into the profile)
41    #[serde(flatten)]
42    pub credentials: ProfileCredentials,
43    /// Files.com API key for this profile (overrides global setting)
44    /// Supports keyring: prefix for secure storage.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub files_api_key: Option<String>,
47    /// Resilience configuration for this profile
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub resilience: Option<crate::ResilienceConfig>,
50}
51
52/// Supported deployment types
53#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
54#[serde(rename_all = "lowercase")]
55pub enum DeploymentType {
56    Cloud,
57    Enterprise,
58}
59
60/// Connection credentials for different deployment types
61#[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>, // Optional for interactive prompting
74        #[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    /// Returns Cloud credentials if this is a Cloud profile
90    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    /// Returns Enterprise credentials if this is an Enterprise profile
102    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    /// Check if this profile has a stored password
120    pub fn has_password(&self) -> bool {
121        matches!(
122            self.credentials,
123            ProfileCredentials::Enterprise {
124                password: Some(_),
125                ..
126            }
127        )
128    }
129
130    /// Get resolved Cloud credentials (with keyring support)
131    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                // Resolve each credential with environment variable fallback
141                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    /// Get resolved Enterprise credentials (with keyring support)
164    #[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                // Resolve each credential with environment variable fallback
178                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    /// Get the first profile of the specified deployment type (sorted alphabetically by name)
216    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    /// Get all profiles of the specified deployment type
228    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    /// Resolve the profile to use for enterprise commands
240    pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
241        if let Some(profile_name) = explicit_profile {
242            // Explicitly specified profile
243            return Ok(profile_name.to_string());
244        }
245
246        if let Some(ref default) = self.default_enterprise {
247            // Type-specific default
248            return Ok(default.clone());
249        }
250
251        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
252            // First enterprise profile
253            return Ok(profile_name.to_string());
254        }
255
256        // No enterprise profiles available
257        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    /// Resolve the profile to use for cloud commands
275    pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
276        if let Some(profile_name) = explicit_profile {
277            // Explicitly specified profile
278            return Ok(profile_name.to_string());
279        }
280
281        if let Some(ref default) = self.default_cloud {
282            // Type-specific default
283            return Ok(default.clone());
284        }
285
286        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
287            // First cloud profile
288            return Ok(profile_name.to_string());
289        }
290
291        // No cloud profiles available
292        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    /// Load configuration from the standard location
310    pub fn load() -> Result<Self> {
311        let config_path = Self::config_path()?;
312        Self::load_from_path(&config_path)
313    }
314
315    /// Load configuration from a specific path
316    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        // Expand environment variables in the config content
327        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    /// Save configuration to the standard location
335    pub fn save(&self) -> Result<()> {
336        let config_path = Self::config_path()?;
337        self.save_to_path(&config_path)
338    }
339
340    /// Save configuration to a specific path
341    pub fn save_to_path(&self, config_path: &Path) -> Result<()> {
342        // Create parent directories if they don't exist
343        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    /// Set or update a profile
361    pub fn set_profile(&mut self, name: String, profile: Profile) {
362        self.profiles.insert(name, profile);
363    }
364
365    /// Remove a profile by name
366    pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
367        // Clear type-specific defaults if this profile was set as default
368        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    /// List all profiles sorted by name
378    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    /// Get the path to the configuration file
385    ///
386    /// On macOS, this supports both the standard macOS path and Linux-style ~/.config path:
387    /// 1. Check ~/.config/redisctl/config.toml (Linux-style, preferred for consistency)
388    /// 2. Fall back to ~/Library/Application Support/com.redis.redisctl/config.toml (macOS standard)
389    ///
390    /// On Linux: ~/.config/redisctl/config.toml
391    /// On Windows: %APPDATA%\redis\redisctl\config.toml
392    pub fn config_path() -> Result<PathBuf> {
393        // On macOS, check for Linux-style path first for cross-platform consistency
394        #[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 config exists, use it
404                if linux_style_path.exists() {
405                    return Ok(linux_style_path);
406                }
407
408                // Also check if the config directory exists (user might have created it)
409                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        // Use platform-specific standard path
420        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    /// Expand environment variables in configuration content
427    ///
428    /// Supports ${VAR} and ${VAR:-default} syntax for environment variable expansion.
429    /// This allows configs to reference environment variables while maintaining
430    /// static fallback values.
431    ///
432    /// Example:
433    /// ```toml
434    /// api_key = "${REDIS_CLOUD_API_KEY}"
435    /// api_url = "${REDIS_CLOUD_API_URL:-https://api.redislabs.com/v1}"
436    /// ```
437    fn expand_env_vars(content: &str) -> String {
438        // Use shellexpand::env_with_context_no_errors which returns unexpanded vars as-is
439        // This prevents errors when env vars for unused profiles aren't set
440        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        // Test basic environment variable expansion
503        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        // Clean up
520        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        // Test environment variable expansion with defaults
530        unsafe {
531            std::env::remove_var("NONEXISTENT_VAR"); // Ensure it doesn't exist
532        }
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        // Test mixed static and dynamic values
550        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        // Clean up
568        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        // Test complete config parsing with environment variables
577        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        // Clean up
604        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        // Add an enterprise profile
615        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        // Test explicit profile
629        assert_eq!(
630            config.resolve_enterprise_profile(Some("ent1")).unwrap(),
631            "ent1"
632        );
633
634        // Test first enterprise profile (no default set)
635        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
636
637        // Set default enterprise
638        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        // Add a cloud profile
647        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        // Test explicit profile
660        assert_eq!(
661            config.resolve_cloud_profile(Some("cloud1")).unwrap(),
662            "cloud1"
663        );
664
665        // Test first cloud profile (no default set)
666        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
667
668        // Set default cloud
669        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        // Add a cloud profile
678        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        // Add enterprise profiles
692        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        // Without defaults, should use first of each type
707        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
708        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
709
710        // Set type-specific defaults
711        config.default_cloud = Some("cloud2".to_string());
712        config.default_enterprise = Some("ent2".to_string());
713
714        // Should now use the type-specific defaults
715        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        // No profiles at all
724        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        // Only add cloud profiles
733        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        // Try to resolve enterprise profile - should get helpful error
746        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}