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    /// Default profile for database commands
27    #[serde(default, rename = "default_database")]
28    pub default_database: Option<String>,
29    /// Global Files.com API key for support package uploads
30    /// Can be overridden per-profile. Supports keyring: prefix for secure storage.
31    #[serde(default, skip_serializing_if = "Option::is_none")]
32    pub files_api_key: Option<String>,
33    /// Map of profile name -> profile configuration
34    #[serde(default)]
35    pub profiles: HashMap<String, Profile>,
36}
37
38/// Individual profile configuration
39#[derive(Debug, Serialize, Deserialize, Clone)]
40pub struct Profile {
41    /// Type of deployment this profile connects to
42    pub deployment_type: DeploymentType,
43    /// Connection credentials (flattened into the profile)
44    #[serde(flatten)]
45    pub credentials: ProfileCredentials,
46    /// Files.com API key for this profile (overrides global setting)
47    /// Supports keyring: prefix for secure storage.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub files_api_key: Option<String>,
50    /// Resilience configuration for this profile
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub resilience: Option<crate::ResilienceConfig>,
53}
54
55/// Supported deployment types
56#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
57#[serde(rename_all = "lowercase")]
58pub enum DeploymentType {
59    Cloud,
60    Enterprise,
61    Database,
62}
63
64/// Connection credentials for different deployment types
65#[derive(Debug, Serialize, Deserialize, Clone)]
66#[serde(untagged)]
67pub enum ProfileCredentials {
68    Cloud {
69        api_key: String,
70        api_secret: String,
71        #[serde(default = "default_cloud_url")]
72        api_url: String,
73    },
74    Enterprise {
75        url: String,
76        username: String,
77        password: Option<String>, // Optional for interactive prompting
78        #[serde(default)]
79        insecure: bool,
80    },
81    Database {
82        host: String,
83        port: u16,
84        #[serde(default)]
85        password: Option<String>,
86        #[serde(default = "default_tls")]
87        tls: bool,
88        #[serde(default = "default_username")]
89        username: String,
90        #[serde(default)]
91        database: u8,
92    },
93}
94
95impl std::fmt::Display for DeploymentType {
96    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
97        match self {
98            DeploymentType::Cloud => write!(f, "cloud"),
99            DeploymentType::Enterprise => write!(f, "enterprise"),
100            DeploymentType::Database => write!(f, "database"),
101        }
102    }
103}
104
105fn default_tls() -> bool {
106    true
107}
108
109fn default_username() -> String {
110    "default".to_string()
111}
112
113impl Profile {
114    /// Returns Cloud credentials if this is a Cloud profile
115    pub fn cloud_credentials(&self) -> Option<(&str, &str, &str)> {
116        match &self.credentials {
117            ProfileCredentials::Cloud {
118                api_key,
119                api_secret,
120                api_url,
121            } => Some((api_key.as_str(), api_secret.as_str(), api_url.as_str())),
122            _ => None,
123        }
124    }
125
126    /// Returns Enterprise credentials if this is an Enterprise profile
127    pub fn enterprise_credentials(&self) -> Option<(&str, &str, Option<&str>, bool)> {
128        match &self.credentials {
129            ProfileCredentials::Enterprise {
130                url,
131                username,
132                password,
133                insecure,
134            } => Some((
135                url.as_str(),
136                username.as_str(),
137                password.as_deref(),
138                *insecure,
139            )),
140            _ => None,
141        }
142    }
143
144    /// Check if this profile has a stored password
145    pub fn has_password(&self) -> bool {
146        matches!(
147            self.credentials,
148            ProfileCredentials::Enterprise {
149                password: Some(_),
150                ..
151            } | ProfileCredentials::Database {
152                password: Some(_),
153                ..
154            }
155        )
156    }
157
158    /// Get resolved Cloud credentials (with keyring support)
159    pub fn resolve_cloud_credentials(&self) -> Result<Option<(String, String, String)>> {
160        match &self.credentials {
161            ProfileCredentials::Cloud {
162                api_key,
163                api_secret,
164                api_url,
165            } => {
166                let store = CredentialStore::new();
167
168                // Resolve each credential with environment variable fallback
169                let resolved_key = store
170                    .get_credential(api_key, Some("REDIS_CLOUD_API_KEY"))
171                    .map_err(|e| {
172                        ConfigError::CredentialError(format!("Failed to resolve API key: {}", e))
173                    })?;
174                let resolved_secret = store
175                    .get_credential(api_secret, Some("REDIS_CLOUD_API_SECRET"))
176                    .map_err(|e| {
177                        ConfigError::CredentialError(format!("Failed to resolve API secret: {}", e))
178                    })?;
179                let resolved_url = store
180                    .get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
181                    .map_err(|e| {
182                        ConfigError::CredentialError(format!("Failed to resolve API URL: {}", e))
183                    })?;
184
185                Ok(Some((resolved_key, resolved_secret, resolved_url)))
186            }
187            _ => Ok(None),
188        }
189    }
190
191    /// Get resolved Enterprise credentials (with keyring support)
192    #[allow(clippy::type_complexity)]
193    pub fn resolve_enterprise_credentials(
194        &self,
195    ) -> Result<Option<(String, String, Option<String>, bool)>> {
196        match &self.credentials {
197            ProfileCredentials::Enterprise {
198                url,
199                username,
200                password,
201                insecure,
202            } => {
203                let store = CredentialStore::new();
204
205                // Resolve each credential with environment variable fallback
206                let resolved_url = store
207                    .get_credential(url, Some("REDIS_ENTERPRISE_URL"))
208                    .map_err(|e| {
209                        ConfigError::CredentialError(format!("Failed to resolve URL: {}", e))
210                    })?;
211                let resolved_username = store
212                    .get_credential(username, Some("REDIS_ENTERPRISE_USER"))
213                    .map_err(|e| {
214                        ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
215                    })?;
216                let resolved_password = password
217                    .as_ref()
218                    .map(|p| {
219                        store
220                            .get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
221                            .map_err(|e| {
222                                ConfigError::CredentialError(format!(
223                                    "Failed to resolve password: {}",
224                                    e
225                                ))
226                            })
227                    })
228                    .transpose()?;
229
230                Ok(Some((
231                    resolved_url,
232                    resolved_username,
233                    resolved_password,
234                    *insecure,
235                )))
236            }
237            _ => Ok(None),
238        }
239    }
240
241    /// Returns Database credentials if this is a Database profile
242    #[allow(clippy::type_complexity)]
243    pub fn database_credentials(&self) -> Option<(&str, u16, Option<&str>, bool, &str, u8)> {
244        match &self.credentials {
245            ProfileCredentials::Database {
246                host,
247                port,
248                password,
249                tls,
250                username,
251                database,
252            } => Some((
253                host.as_str(),
254                *port,
255                password.as_deref(),
256                *tls,
257                username.as_str(),
258                *database,
259            )),
260            _ => None,
261        }
262    }
263
264    /// Get resolved Database credentials (with keyring support)
265    #[allow(clippy::type_complexity)]
266    pub fn resolve_database_credentials(
267        &self,
268    ) -> Result<Option<(String, u16, Option<String>, bool, String, u8)>> {
269        match &self.credentials {
270            ProfileCredentials::Database {
271                host,
272                port,
273                password,
274                tls,
275                username,
276                database,
277            } => {
278                let store = CredentialStore::new();
279
280                // Resolve each credential with environment variable fallback
281                let resolved_host =
282                    store
283                        .get_credential(host, Some("REDIS_HOST"))
284                        .map_err(|e| {
285                            ConfigError::CredentialError(format!("Failed to resolve host: {}", e))
286                        })?;
287                let resolved_username = store
288                    .get_credential(username, Some("REDIS_USERNAME"))
289                    .map_err(|e| {
290                        ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
291                    })?;
292                let resolved_password = password
293                    .as_ref()
294                    .map(|p| {
295                        store
296                            .get_credential(p, Some("REDIS_PASSWORD"))
297                            .map_err(|e| {
298                                ConfigError::CredentialError(format!(
299                                    "Failed to resolve password: {}",
300                                    e
301                                ))
302                            })
303                    })
304                    .transpose()?;
305
306                Ok(Some((
307                    resolved_host,
308                    *port,
309                    resolved_password,
310                    *tls,
311                    resolved_username,
312                    *database,
313                )))
314            }
315            _ => Ok(None),
316        }
317    }
318}
319
320impl Config {
321    /// Get the first profile of the specified deployment type (sorted alphabetically by name)
322    pub fn find_first_profile_of_type(&self, deployment_type: DeploymentType) -> Option<&str> {
323        let mut profiles: Vec<_> = self
324            .profiles
325            .iter()
326            .filter(|(_, p)| p.deployment_type == deployment_type)
327            .map(|(name, _)| name.as_str())
328            .collect();
329        profiles.sort();
330        profiles.first().copied()
331    }
332
333    /// Get all profiles of the specified deployment type
334    pub fn get_profiles_of_type(&self, deployment_type: DeploymentType) -> Vec<&str> {
335        let mut profiles: Vec<_> = self
336            .profiles
337            .iter()
338            .filter(|(_, p)| p.deployment_type == deployment_type)
339            .map(|(name, _)| name.as_str())
340            .collect();
341        profiles.sort();
342        profiles
343    }
344
345    /// Resolve the profile to use for enterprise commands
346    pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
347        if let Some(profile_name) = explicit_profile {
348            // Explicitly specified profile
349            return Ok(profile_name.to_string());
350        }
351
352        if let Some(ref default) = self.default_enterprise {
353            // Type-specific default
354            return Ok(default.clone());
355        }
356
357        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
358            // First enterprise profile
359            return Ok(profile_name.to_string());
360        }
361
362        // No enterprise profiles available - suggest other profile types
363        let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
364        let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
365        if !cloud_profiles.is_empty() || !database_profiles.is_empty() {
366            let mut suggestions = Vec::new();
367            if !cloud_profiles.is_empty() {
368                suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
369            }
370            if !database_profiles.is_empty() {
371                suggestions.push(format!("database: {}", database_profiles.join(", ")));
372            }
373            Err(ConfigError::NoProfilesOfType {
374                deployment_type: "enterprise".to_string(),
375                suggestion: format!(
376                    "Available profiles: {}. Use 'redisctl profile set' to create an enterprise profile.",
377                    suggestions.join("; ")
378                ),
379            })
380        } else {
381            Err(ConfigError::NoProfilesOfType {
382                deployment_type: "enterprise".to_string(),
383                suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
384            })
385        }
386    }
387
388    /// Resolve the profile to use for cloud commands
389    pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
390        if let Some(profile_name) = explicit_profile {
391            // Explicitly specified profile
392            return Ok(profile_name.to_string());
393        }
394
395        if let Some(ref default) = self.default_cloud {
396            // Type-specific default
397            return Ok(default.clone());
398        }
399
400        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
401            // First cloud profile
402            return Ok(profile_name.to_string());
403        }
404
405        // No cloud profiles available - suggest other profile types
406        let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
407        let database_profiles = self.get_profiles_of_type(DeploymentType::Database);
408        if !enterprise_profiles.is_empty() || !database_profiles.is_empty() {
409            let mut suggestions = Vec::new();
410            if !enterprise_profiles.is_empty() {
411                suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
412            }
413            if !database_profiles.is_empty() {
414                suggestions.push(format!("database: {}", database_profiles.join(", ")));
415            }
416            Err(ConfigError::NoProfilesOfType {
417                deployment_type: "cloud".to_string(),
418                suggestion: format!(
419                    "Available profiles: {}. Use 'redisctl profile set' to create a cloud profile.",
420                    suggestions.join("; ")
421                ),
422            })
423        } else {
424            Err(ConfigError::NoProfilesOfType {
425                deployment_type: "cloud".to_string(),
426                suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
427            })
428        }
429    }
430
431    /// Resolve the profile to use for database commands
432    pub fn resolve_database_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
433        if let Some(profile_name) = explicit_profile {
434            // Explicitly specified profile
435            return Ok(profile_name.to_string());
436        }
437
438        if let Some(ref default) = self.default_database {
439            // Type-specific default
440            return Ok(default.clone());
441        }
442
443        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Database) {
444            // First database profile
445            return Ok(profile_name.to_string());
446        }
447
448        // No database profiles available - suggest other profile types
449        let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
450        let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
451        if !cloud_profiles.is_empty() || !enterprise_profiles.is_empty() {
452            let mut suggestions = Vec::new();
453            if !cloud_profiles.is_empty() {
454                suggestions.push(format!("cloud: {}", cloud_profiles.join(", ")));
455            }
456            if !enterprise_profiles.is_empty() {
457                suggestions.push(format!("enterprise: {}", enterprise_profiles.join(", ")));
458            }
459            Err(ConfigError::NoProfilesOfType {
460                deployment_type: "database".to_string(),
461                suggestion: format!(
462                    "Available profiles: {}. Use 'redisctl profile set' to create a database profile.",
463                    suggestions.join("; ")
464                ),
465            })
466        } else {
467            Err(ConfigError::NoProfilesOfType {
468                deployment_type: "database".to_string(),
469                suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
470            })
471        }
472    }
473
474    /// Load configuration from the standard location
475    pub fn load() -> Result<Self> {
476        let config_path = Self::config_path()?;
477        Self::load_from_path(&config_path)
478    }
479
480    /// Load configuration from a specific path
481    pub fn load_from_path(config_path: &Path) -> Result<Self> {
482        if !config_path.exists() {
483            return Ok(Config::default());
484        }
485
486        let content = fs::read_to_string(config_path).map_err(|e| ConfigError::LoadError {
487            path: config_path.display().to_string(),
488            source: e,
489        })?;
490
491        // Expand environment variables in the config content
492        let expanded_content = Self::expand_env_vars(&content);
493
494        let config: Config = toml::from_str(&expanded_content)?;
495
496        Ok(config)
497    }
498
499    /// Save configuration to the standard location
500    pub fn save(&self) -> Result<()> {
501        let config_path = Self::config_path()?;
502        self.save_to_path(&config_path)
503    }
504
505    /// Save configuration to a specific path
506    pub fn save_to_path(&self, config_path: &Path) -> Result<()> {
507        // Create parent directories if they don't exist
508        if let Some(parent) = config_path.parent() {
509            fs::create_dir_all(parent).map_err(|e| ConfigError::SaveError {
510                path: parent.display().to_string(),
511                source: e,
512            })?;
513        }
514
515        let content = toml::to_string_pretty(self)?;
516
517        fs::write(config_path, content).map_err(|e| ConfigError::SaveError {
518            path: config_path.display().to_string(),
519            source: e,
520        })?;
521
522        Ok(())
523    }
524
525    /// Set or update a profile
526    pub fn set_profile(&mut self, name: String, profile: Profile) {
527        self.profiles.insert(name, profile);
528    }
529
530    /// Remove a profile by name
531    pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
532        // Clear type-specific defaults if this profile was set as default
533        if self.default_enterprise.as_deref() == Some(name) {
534            self.default_enterprise = None;
535        }
536        if self.default_cloud.as_deref() == Some(name) {
537            self.default_cloud = None;
538        }
539        if self.default_database.as_deref() == Some(name) {
540            self.default_database = None;
541        }
542        self.profiles.remove(name)
543    }
544
545    /// List all profiles sorted by name
546    pub fn list_profiles(&self) -> Vec<(&String, &Profile)> {
547        let mut profiles: Vec<_> = self.profiles.iter().collect();
548        profiles.sort_by_key(|(name, _)| *name);
549        profiles
550    }
551
552    /// Get the path to the configuration file
553    ///
554    /// On macOS, this supports both the standard macOS path and Linux-style ~/.config path:
555    /// 1. Check ~/.config/redisctl/config.toml (Linux-style, preferred for consistency)
556    /// 2. Fall back to ~/Library/Application Support/com.redis.redisctl/config.toml (macOS standard)
557    ///
558    /// On Linux: ~/.config/redisctl/config.toml
559    /// On Windows: %APPDATA%\redis\redisctl\config.toml
560    pub fn config_path() -> Result<PathBuf> {
561        // On macOS, check for Linux-style path first for cross-platform consistency
562        #[cfg(target_os = "macos")]
563        {
564            if let Some(base_dirs) = BaseDirs::new() {
565                let home_dir = base_dirs.home_dir();
566                let linux_style_path = home_dir
567                    .join(".config")
568                    .join("redisctl")
569                    .join("config.toml");
570
571                // If Linux-style config exists, use it
572                if linux_style_path.exists() {
573                    return Ok(linux_style_path);
574                }
575
576                // Also check if the config directory exists (user might have created it)
577                if linux_style_path
578                    .parent()
579                    .map(|p| p.exists())
580                    .unwrap_or(false)
581                {
582                    return Ok(linux_style_path);
583                }
584            }
585        }
586
587        // Use platform-specific standard path
588        let proj_dirs =
589            ProjectDirs::from("com", "redis", "redisctl").ok_or(ConfigError::ConfigDirError)?;
590
591        Ok(proj_dirs.config_dir().join("config.toml"))
592    }
593
594    /// Expand environment variables in configuration content
595    ///
596    /// Supports ${VAR} and ${VAR:-default} syntax for environment variable expansion.
597    /// This allows configs to reference environment variables while maintaining
598    /// static fallback values.
599    ///
600    /// Example:
601    /// ```toml
602    /// api_key = "${REDIS_CLOUD_API_KEY}"
603    /// api_url = "${REDIS_CLOUD_API_URL:-https://api.redislabs.com/v1}"
604    /// ```
605    fn expand_env_vars(content: &str) -> String {
606        // Use shellexpand::env_with_context_no_errors which returns unexpanded vars as-is
607        // This prevents errors when env vars for unused profiles aren't set
608        let expanded =
609            shellexpand::env_with_context_no_errors(content, |var| std::env::var(var).ok());
610        expanded.to_string()
611    }
612}
613
614fn default_cloud_url() -> String {
615    "https://api.redislabs.com/v1".to_string()
616}
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621
622    #[test]
623    fn test_config_serialization() {
624        let mut config = Config::default();
625
626        let cloud_profile = Profile {
627            deployment_type: DeploymentType::Cloud,
628            credentials: ProfileCredentials::Cloud {
629                api_key: "test-key".to_string(),
630                api_secret: "test-secret".to_string(),
631                api_url: "https://api.redislabs.com/v1".to_string(),
632            },
633            files_api_key: None,
634            resilience: None,
635        };
636
637        config.set_profile("test".to_string(), cloud_profile);
638        config.default_cloud = Some("test".to_string());
639
640        let serialized = toml::to_string(&config).unwrap();
641        let deserialized: Config = toml::from_str(&serialized).unwrap();
642
643        assert_eq!(config.default_cloud, deserialized.default_cloud);
644        assert_eq!(config.profiles.len(), deserialized.profiles.len());
645    }
646
647    #[test]
648    fn test_profile_credential_access() {
649        let cloud_profile = Profile {
650            deployment_type: DeploymentType::Cloud,
651            credentials: ProfileCredentials::Cloud {
652                api_key: "key".to_string(),
653                api_secret: "secret".to_string(),
654                api_url: "url".to_string(),
655            },
656            files_api_key: None,
657            resilience: None,
658        };
659
660        let (key, secret, url) = cloud_profile.cloud_credentials().unwrap();
661        assert_eq!(key, "key");
662        assert_eq!(secret, "secret");
663        assert_eq!(url, "url");
664        assert!(cloud_profile.enterprise_credentials().is_none());
665    }
666
667    #[test]
668    #[serial_test::serial]
669    fn test_env_var_expansion() {
670        // Test basic environment variable expansion
671        unsafe {
672            std::env::set_var("TEST_API_KEY", "test-key-value");
673            std::env::set_var("TEST_API_SECRET", "test-secret-value");
674        }
675
676        let content = r#"
677[profiles.test]
678deployment_type = "cloud"
679api_key = "${TEST_API_KEY}"
680api_secret = "${TEST_API_SECRET}"
681"#;
682
683        let expanded = Config::expand_env_vars(content);
684        assert!(expanded.contains("test-key-value"));
685        assert!(expanded.contains("test-secret-value"));
686
687        // Clean up
688        unsafe {
689            std::env::remove_var("TEST_API_KEY");
690            std::env::remove_var("TEST_API_SECRET");
691        }
692    }
693
694    #[test]
695    #[serial_test::serial]
696    fn test_env_var_expansion_with_defaults() {
697        // Test environment variable expansion with defaults
698        unsafe {
699            std::env::remove_var("NONEXISTENT_VAR"); // Ensure it doesn't exist
700        }
701
702        let content = r#"
703[profiles.test]
704deployment_type = "cloud"
705api_key = "${NONEXISTENT_VAR:-default-key}"
706api_url = "${NONEXISTENT_URL:-https://api.redislabs.com/v1}"
707"#;
708
709        let expanded = Config::expand_env_vars(content);
710        assert!(expanded.contains("default-key"));
711        assert!(expanded.contains("https://api.redislabs.com/v1"));
712    }
713
714    #[test]
715    #[serial_test::serial]
716    fn test_env_var_expansion_mixed() {
717        // Test mixed static and dynamic values
718        unsafe {
719            std::env::set_var("TEST_DYNAMIC_KEY", "dynamic-value");
720        }
721
722        let content = r#"
723[profiles.test]
724deployment_type = "cloud"
725api_key = "${TEST_DYNAMIC_KEY}"
726api_secret = "static-secret"
727api_url = "${MISSING_VAR:-https://api.redislabs.com/v1}"
728"#;
729
730        let expanded = Config::expand_env_vars(content);
731        assert!(expanded.contains("dynamic-value"));
732        assert!(expanded.contains("static-secret"));
733        assert!(expanded.contains("https://api.redislabs.com/v1"));
734
735        // Clean up
736        unsafe {
737            std::env::remove_var("TEST_DYNAMIC_KEY");
738        }
739    }
740
741    #[test]
742    #[serial_test::serial]
743    fn test_full_config_with_env_expansion() {
744        // Test complete config parsing with environment variables
745        unsafe {
746            std::env::set_var("REDIS_TEST_KEY", "expanded-key");
747            std::env::set_var("REDIS_TEST_SECRET", "expanded-secret");
748        }
749
750        let config_content = r#"
751default_cloud = "test"
752
753[profiles.test]
754deployment_type = "cloud"
755api_key = "${REDIS_TEST_KEY}"
756api_secret = "${REDIS_TEST_SECRET}"
757api_url = "${REDIS_TEST_URL:-https://api.redislabs.com/v1}"
758"#;
759
760        let expanded = Config::expand_env_vars(config_content);
761        let config: Config = toml::from_str(&expanded).unwrap();
762
763        assert_eq!(config.default_cloud, Some("test".to_string()));
764
765        let profile = config.profiles.get("test").unwrap();
766        let (key, secret, url) = profile.cloud_credentials().unwrap();
767        assert_eq!(key, "expanded-key");
768        assert_eq!(secret, "expanded-secret");
769        assert_eq!(url, "https://api.redislabs.com/v1");
770
771        // Clean up
772        unsafe {
773            std::env::remove_var("REDIS_TEST_KEY");
774            std::env::remove_var("REDIS_TEST_SECRET");
775        }
776    }
777
778    #[test]
779    fn test_enterprise_profile_resolution() {
780        let mut config = Config::default();
781
782        // Add an enterprise profile
783        let enterprise_profile = Profile {
784            deployment_type: DeploymentType::Enterprise,
785            credentials: ProfileCredentials::Enterprise {
786                url: "https://localhost:9443".to_string(),
787                username: "admin".to_string(),
788                password: Some("password".to_string()),
789                insecure: false,
790            },
791            files_api_key: None,
792            resilience: None,
793        };
794        config.set_profile("ent1".to_string(), enterprise_profile);
795
796        // Test explicit profile
797        assert_eq!(
798            config.resolve_enterprise_profile(Some("ent1")).unwrap(),
799            "ent1"
800        );
801
802        // Test first enterprise profile (no default set)
803        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
804
805        // Set default enterprise
806        config.default_enterprise = Some("ent1".to_string());
807        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
808    }
809
810    #[test]
811    fn test_cloud_profile_resolution() {
812        let mut config = Config::default();
813
814        // Add a cloud profile
815        let cloud_profile = Profile {
816            deployment_type: DeploymentType::Cloud,
817            credentials: ProfileCredentials::Cloud {
818                api_key: "key".to_string(),
819                api_secret: "secret".to_string(),
820                api_url: "https://api.redislabs.com/v1".to_string(),
821            },
822            files_api_key: None,
823            resilience: None,
824        };
825        config.set_profile("cloud1".to_string(), cloud_profile);
826
827        // Test explicit profile
828        assert_eq!(
829            config.resolve_cloud_profile(Some("cloud1")).unwrap(),
830            "cloud1"
831        );
832
833        // Test first cloud profile (no default set)
834        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
835
836        // Set default cloud
837        config.default_cloud = Some("cloud1".to_string());
838        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
839    }
840
841    #[test]
842    fn test_mixed_profile_resolution() {
843        let mut config = Config::default();
844
845        // Add a cloud profile
846        let cloud_profile = Profile {
847            deployment_type: DeploymentType::Cloud,
848            credentials: ProfileCredentials::Cloud {
849                api_key: "key".to_string(),
850                api_secret: "secret".to_string(),
851                api_url: "https://api.redislabs.com/v1".to_string(),
852            },
853            files_api_key: None,
854            resilience: None,
855        };
856        config.set_profile("cloud1".to_string(), cloud_profile.clone());
857        config.set_profile("cloud2".to_string(), cloud_profile);
858
859        // Add enterprise profiles
860        let enterprise_profile = Profile {
861            deployment_type: DeploymentType::Enterprise,
862            credentials: ProfileCredentials::Enterprise {
863                url: "https://localhost:9443".to_string(),
864                username: "admin".to_string(),
865                password: Some("password".to_string()),
866                insecure: false,
867            },
868            files_api_key: None,
869            resilience: None,
870        };
871        config.set_profile("ent1".to_string(), enterprise_profile.clone());
872        config.set_profile("ent2".to_string(), enterprise_profile);
873
874        // Without defaults, should use first of each type
875        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
876        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
877
878        // Set type-specific defaults
879        config.default_cloud = Some("cloud2".to_string());
880        config.default_enterprise = Some("ent2".to_string());
881
882        // Should now use the type-specific defaults
883        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud2");
884        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent2");
885    }
886
887    #[test]
888    fn test_no_profile_errors() {
889        let config = Config::default();
890
891        // No profiles at all
892        assert!(config.resolve_enterprise_profile(None).is_err());
893        assert!(config.resolve_cloud_profile(None).is_err());
894    }
895
896    #[test]
897    fn test_wrong_profile_type_help() {
898        let mut config = Config::default();
899
900        // Only add cloud profiles
901        let cloud_profile = Profile {
902            deployment_type: DeploymentType::Cloud,
903            credentials: ProfileCredentials::Cloud {
904                api_key: "key".to_string(),
905                api_secret: "secret".to_string(),
906                api_url: "https://api.redislabs.com/v1".to_string(),
907            },
908            files_api_key: None,
909            resilience: None,
910        };
911        config.set_profile("cloud1".to_string(), cloud_profile);
912
913        // Try to resolve enterprise profile - should get helpful error
914        let err = config.resolve_enterprise_profile(None).unwrap_err();
915        assert!(err.to_string().contains("No enterprise profiles"));
916        assert!(err.to_string().contains("cloud: cloud1"));
917    }
918
919    #[test]
920    fn test_database_profile_serialization() {
921        let mut config = Config::default();
922
923        let db_profile = Profile {
924            deployment_type: DeploymentType::Database,
925            credentials: ProfileCredentials::Database {
926                host: "localhost".to_string(),
927                port: 6379,
928                password: Some("secret".to_string()),
929                tls: true,
930                username: "default".to_string(),
931                database: 0,
932            },
933            files_api_key: None,
934            resilience: None,
935        };
936
937        config.set_profile("myredis".to_string(), db_profile);
938        config.default_database = Some("myredis".to_string());
939
940        let serialized = toml::to_string(&config).unwrap();
941        let deserialized: Config = toml::from_str(&serialized).unwrap();
942
943        assert_eq!(config.default_database, deserialized.default_database);
944        assert_eq!(config.profiles.len(), deserialized.profiles.len());
945
946        let profile = deserialized.profiles.get("myredis").unwrap();
947        assert_eq!(profile.deployment_type, DeploymentType::Database);
948
949        let (host, port, password, tls, username, database) =
950            profile.database_credentials().unwrap();
951        assert_eq!(host, "localhost");
952        assert_eq!(port, 6379);
953        assert_eq!(password, Some("secret"));
954        assert!(tls);
955        assert_eq!(username, "default");
956        assert_eq!(database, 0);
957    }
958
959    #[test]
960    fn test_database_profile_resolution() {
961        let mut config = Config::default();
962
963        // Add a database profile
964        let db_profile = Profile {
965            deployment_type: DeploymentType::Database,
966            credentials: ProfileCredentials::Database {
967                host: "localhost".to_string(),
968                port: 6379,
969                password: None,
970                tls: false,
971                username: "default".to_string(),
972                database: 0,
973            },
974            files_api_key: None,
975            resilience: None,
976        };
977        config.set_profile("db1".to_string(), db_profile);
978
979        // Test explicit profile
980        assert_eq!(config.resolve_database_profile(Some("db1")).unwrap(), "db1");
981
982        // Test first database profile (no default set)
983        assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
984
985        // Set default database
986        config.default_database = Some("db1".to_string());
987        assert_eq!(config.resolve_database_profile(None).unwrap(), "db1");
988    }
989
990    #[test]
991    fn test_database_profile_defaults() {
992        // Test that TLS defaults to true and username defaults to "default"
993        let toml_content = r#"
994[profiles.minimal]
995deployment_type = "database"
996host = "redis.example.com"
997port = 12345
998"#;
999        let config: Config = toml::from_str(toml_content).unwrap();
1000        let profile = config.profiles.get("minimal").unwrap();
1001
1002        let (host, port, password, tls, username, database) =
1003            profile.database_credentials().unwrap();
1004        assert_eq!(host, "redis.example.com");
1005        assert_eq!(port, 12345);
1006        assert!(password.is_none());
1007        assert!(tls); // defaults to true
1008        assert_eq!(username, "default"); // defaults to "default"
1009        assert_eq!(database, 0); // defaults to 0
1010    }
1011}