Skip to main content

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