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