redisctl_config/
config.rs

1//! Configuration management for Redis CLI tools
2//!
3//! Handles configuration loading from files, environment variables, and command-line arguments.
4//! Configuration is stored in TOML format with support for multiple named profiles.
5
6#[cfg(target_os = "macos")]
7use directories::BaseDirs;
8use directories::ProjectDirs;
9use serde::{Deserialize, Serialize};
10use std::collections::HashMap;
11use std::fs;
12use std::path::PathBuf;
13
14use crate::credential::CredentialStore;
15use crate::error::{ConfigError, Result};
16
17/// Main configuration structure
18#[derive(Debug, Serialize, Deserialize, Default, Clone)]
19pub struct Config {
20    /// Default profile for enterprise commands
21    #[serde(default, rename = "default_enterprise")]
22    pub default_enterprise: Option<String>,
23    /// Default profile for cloud commands
24    #[serde(default, rename = "default_cloud")]
25    pub default_cloud: Option<String>,
26    /// Global Files.com API key for support package uploads
27    /// Can be overridden per-profile. Supports keyring: prefix for secure storage.
28    #[serde(default, skip_serializing_if = "Option::is_none")]
29    pub files_api_key: Option<String>,
30    /// Map of profile name -> profile configuration
31    #[serde(default)]
32    pub profiles: HashMap<String, Profile>,
33}
34
35/// Individual profile configuration
36#[derive(Debug, Serialize, Deserialize, Clone)]
37pub struct Profile {
38    /// Type of deployment this profile connects to
39    pub deployment_type: DeploymentType,
40    /// Connection credentials (flattened into the profile)
41    #[serde(flatten)]
42    pub credentials: ProfileCredentials,
43    /// Files.com API key for this profile (overrides global setting)
44    /// Supports keyring: prefix for secure storage.
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub files_api_key: Option<String>,
47}
48
49/// Supported deployment types
50#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, clap::ValueEnum)]
51#[serde(rename_all = "lowercase")]
52pub enum DeploymentType {
53    Cloud,
54    Enterprise,
55}
56
57/// Connection credentials for different deployment types
58#[derive(Debug, Serialize, Deserialize, Clone)]
59#[serde(untagged)]
60pub enum ProfileCredentials {
61    Cloud {
62        api_key: String,
63        api_secret: String,
64        #[serde(default = "default_cloud_url")]
65        api_url: String,
66    },
67    Enterprise {
68        url: String,
69        username: String,
70        password: Option<String>, // Optional for interactive prompting
71        #[serde(default)]
72        insecure: bool,
73    },
74}
75
76impl std::fmt::Display for DeploymentType {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        match self {
79            DeploymentType::Cloud => write!(f, "cloud"),
80            DeploymentType::Enterprise => write!(f, "enterprise"),
81        }
82    }
83}
84
85impl Profile {
86    /// Returns Cloud credentials if this is a Cloud profile
87    pub fn cloud_credentials(&self) -> Option<(&str, &str, &str)> {
88        match &self.credentials {
89            ProfileCredentials::Cloud {
90                api_key,
91                api_secret,
92                api_url,
93            } => Some((api_key.as_str(), api_secret.as_str(), api_url.as_str())),
94            _ => None,
95        }
96    }
97
98    /// Returns Enterprise credentials if this is an Enterprise profile
99    pub fn enterprise_credentials(&self) -> Option<(&str, &str, Option<&str>, bool)> {
100        match &self.credentials {
101            ProfileCredentials::Enterprise {
102                url,
103                username,
104                password,
105                insecure,
106            } => Some((
107                url.as_str(),
108                username.as_str(),
109                password.as_deref(),
110                *insecure,
111            )),
112            _ => None,
113        }
114    }
115
116    /// Check if this profile has a stored password
117    pub fn has_password(&self) -> bool {
118        matches!(
119            self.credentials,
120            ProfileCredentials::Enterprise {
121                password: Some(_),
122                ..
123            }
124        )
125    }
126
127    /// Get resolved Cloud credentials (with keyring support)
128    pub fn resolve_cloud_credentials(&self) -> Result<Option<(String, String, String)>> {
129        match &self.credentials {
130            ProfileCredentials::Cloud {
131                api_key,
132                api_secret,
133                api_url,
134            } => {
135                let store = CredentialStore::new();
136
137                // Resolve each credential with environment variable fallback
138                let resolved_key = store
139                    .get_credential(api_key, Some("REDIS_CLOUD_API_KEY"))
140                    .map_err(|e| {
141                        ConfigError::CredentialError(format!("Failed to resolve API key: {}", e))
142                    })?;
143                let resolved_secret = store
144                    .get_credential(api_secret, Some("REDIS_CLOUD_API_SECRET"))
145                    .map_err(|e| {
146                        ConfigError::CredentialError(format!("Failed to resolve API secret: {}", e))
147                    })?;
148                let resolved_url = store
149                    .get_credential(api_url, Some("REDIS_CLOUD_API_URL"))
150                    .map_err(|e| {
151                        ConfigError::CredentialError(format!("Failed to resolve API URL: {}", e))
152                    })?;
153
154                Ok(Some((resolved_key, resolved_secret, resolved_url)))
155            }
156            _ => Ok(None),
157        }
158    }
159
160    /// Get resolved Enterprise credentials (with keyring support)
161    #[allow(clippy::type_complexity)]
162    pub fn resolve_enterprise_credentials(
163        &self,
164    ) -> Result<Option<(String, String, Option<String>, bool)>> {
165        match &self.credentials {
166            ProfileCredentials::Enterprise {
167                url,
168                username,
169                password,
170                insecure,
171            } => {
172                let store = CredentialStore::new();
173
174                // Resolve each credential with environment variable fallback
175                let resolved_url = store
176                    .get_credential(url, Some("REDIS_ENTERPRISE_URL"))
177                    .map_err(|e| {
178                        ConfigError::CredentialError(format!("Failed to resolve URL: {}", e))
179                    })?;
180                let resolved_username = store
181                    .get_credential(username, Some("REDIS_ENTERPRISE_USER"))
182                    .map_err(|e| {
183                        ConfigError::CredentialError(format!("Failed to resolve username: {}", e))
184                    })?;
185                let resolved_password = password
186                    .as_ref()
187                    .map(|p| {
188                        store
189                            .get_credential(p, Some("REDIS_ENTERPRISE_PASSWORD"))
190                            .map_err(|e| {
191                                ConfigError::CredentialError(format!(
192                                    "Failed to resolve password: {}",
193                                    e
194                                ))
195                            })
196                    })
197                    .transpose()?;
198
199                Ok(Some((
200                    resolved_url,
201                    resolved_username,
202                    resolved_password,
203                    *insecure,
204                )))
205            }
206            _ => Ok(None),
207        }
208    }
209}
210
211impl Config {
212    /// Get the first profile of the specified deployment type (sorted alphabetically by name)
213    pub fn find_first_profile_of_type(&self, deployment_type: DeploymentType) -> Option<&str> {
214        let mut profiles: Vec<_> = self
215            .profiles
216            .iter()
217            .filter(|(_, p)| p.deployment_type == deployment_type)
218            .map(|(name, _)| name.as_str())
219            .collect();
220        profiles.sort();
221        profiles.first().copied()
222    }
223
224    /// Get all profiles of the specified deployment type
225    pub fn get_profiles_of_type(&self, deployment_type: DeploymentType) -> Vec<&str> {
226        let mut profiles: Vec<_> = self
227            .profiles
228            .iter()
229            .filter(|(_, p)| p.deployment_type == deployment_type)
230            .map(|(name, _)| name.as_str())
231            .collect();
232        profiles.sort();
233        profiles
234    }
235
236    /// Resolve the profile to use for enterprise commands
237    pub fn resolve_enterprise_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
238        if let Some(profile_name) = explicit_profile {
239            // Explicitly specified profile
240            return Ok(profile_name.to_string());
241        }
242
243        if let Some(ref default) = self.default_enterprise {
244            // Type-specific default
245            return Ok(default.clone());
246        }
247
248        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Enterprise) {
249            // First enterprise profile
250            return Ok(profile_name.to_string());
251        }
252
253        // No enterprise profiles available
254        let cloud_profiles = self.get_profiles_of_type(DeploymentType::Cloud);
255        if !cloud_profiles.is_empty() {
256            Err(ConfigError::NoProfilesOfType {
257                deployment_type: "enterprise".to_string(),
258                suggestion: format!(
259                    "Available cloud profiles: {}. Use 'redisctl profile set' to create an enterprise profile.",
260                    cloud_profiles.join(", ")
261                ),
262            })
263        } else {
264            Err(ConfigError::NoProfilesOfType {
265                deployment_type: "enterprise".to_string(),
266                suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
267            })
268        }
269    }
270
271    /// Resolve the profile to use for cloud commands
272    pub fn resolve_cloud_profile(&self, explicit_profile: Option<&str>) -> Result<String> {
273        if let Some(profile_name) = explicit_profile {
274            // Explicitly specified profile
275            return Ok(profile_name.to_string());
276        }
277
278        if let Some(ref default) = self.default_cloud {
279            // Type-specific default
280            return Ok(default.clone());
281        }
282
283        if let Some(profile_name) = self.find_first_profile_of_type(DeploymentType::Cloud) {
284            // First cloud profile
285            return Ok(profile_name.to_string());
286        }
287
288        // No cloud profiles available
289        let enterprise_profiles = self.get_profiles_of_type(DeploymentType::Enterprise);
290        if !enterprise_profiles.is_empty() {
291            Err(ConfigError::NoProfilesOfType {
292                deployment_type: "cloud".to_string(),
293                suggestion: format!(
294                    "Available enterprise profiles: {}. Use 'redisctl profile set' to create a cloud profile.",
295                    enterprise_profiles.join(", ")
296                ),
297            })
298        } else {
299            Err(ConfigError::NoProfilesOfType {
300                deployment_type: "cloud".to_string(),
301                suggestion: "Use 'redisctl profile set' to create a profile.".to_string(),
302            })
303        }
304    }
305
306    /// Load configuration from the standard location
307    pub fn load() -> Result<Self> {
308        let config_path = Self::config_path()?;
309
310        if !config_path.exists() {
311            return Ok(Config::default());
312        }
313
314        let content = fs::read_to_string(&config_path).map_err(|e| ConfigError::LoadError {
315            path: config_path.display().to_string(),
316            source: e,
317        })?;
318
319        // Expand environment variables in the config content
320        let expanded_content = Self::expand_env_vars(&content);
321
322        let config: Config = toml::from_str(&expanded_content)?;
323
324        Ok(config)
325    }
326
327    /// Save configuration to the standard location
328    pub fn save(&self) -> Result<()> {
329        let config_path = Self::config_path()?;
330
331        // Create parent directories if they don't exist
332        if let Some(parent) = config_path.parent() {
333            fs::create_dir_all(parent).map_err(|e| ConfigError::SaveError {
334                path: parent.display().to_string(),
335                source: e,
336            })?;
337        }
338
339        let content = toml::to_string_pretty(self)?;
340
341        fs::write(&config_path, content).map_err(|e| ConfigError::SaveError {
342            path: config_path.display().to_string(),
343            source: e,
344        })?;
345
346        Ok(())
347    }
348
349    /// Set or update a profile
350    pub fn set_profile(&mut self, name: String, profile: Profile) {
351        self.profiles.insert(name, profile);
352    }
353
354    /// Remove a profile by name
355    pub fn remove_profile(&mut self, name: &str) -> Option<Profile> {
356        // Clear type-specific defaults if this profile was set as default
357        if self.default_enterprise.as_deref() == Some(name) {
358            self.default_enterprise = None;
359        }
360        if self.default_cloud.as_deref() == Some(name) {
361            self.default_cloud = None;
362        }
363        self.profiles.remove(name)
364    }
365
366    /// List all profiles sorted by name
367    pub fn list_profiles(&self) -> Vec<(&String, &Profile)> {
368        let mut profiles: Vec<_> = self.profiles.iter().collect();
369        profiles.sort_by_key(|(name, _)| *name);
370        profiles
371    }
372
373    /// Get the path to the configuration file
374    ///
375    /// On macOS, this supports both the standard macOS path and Linux-style ~/.config path:
376    /// 1. Check ~/.config/redisctl/config.toml (Linux-style, preferred for consistency)
377    /// 2. Fall back to ~/Library/Application Support/com.redis.redisctl/config.toml (macOS standard)
378    ///
379    /// On Linux: ~/.config/redisctl/config.toml
380    /// On Windows: %APPDATA%\redis\redisctl\config.toml
381    pub fn config_path() -> Result<PathBuf> {
382        // On macOS, check for Linux-style path first for cross-platform consistency
383        #[cfg(target_os = "macos")]
384        {
385            if let Some(base_dirs) = BaseDirs::new() {
386                let home_dir = base_dirs.home_dir();
387                let linux_style_path = home_dir
388                    .join(".config")
389                    .join("redisctl")
390                    .join("config.toml");
391
392                // If Linux-style config exists, use it
393                if linux_style_path.exists() {
394                    return Ok(linux_style_path);
395                }
396
397                // Also check if the config directory exists (user might have created it)
398                if linux_style_path
399                    .parent()
400                    .map(|p| p.exists())
401                    .unwrap_or(false)
402                {
403                    return Ok(linux_style_path);
404                }
405            }
406        }
407
408        // Use platform-specific standard path
409        let proj_dirs =
410            ProjectDirs::from("com", "redis", "redisctl").ok_or(ConfigError::ConfigDirError)?;
411
412        Ok(proj_dirs.config_dir().join("config.toml"))
413    }
414
415    /// Expand environment variables in configuration content
416    ///
417    /// Supports ${VAR} and ${VAR:-default} syntax for environment variable expansion.
418    /// This allows configs to reference environment variables while maintaining
419    /// static fallback values.
420    ///
421    /// Example:
422    /// ```toml
423    /// api_key = "${REDIS_CLOUD_API_KEY}"
424    /// api_url = "${REDIS_CLOUD_API_URL:-https://api.redislabs.com/v1}"
425    /// ```
426    fn expand_env_vars(content: &str) -> String {
427        // Use shellexpand::env_with_context_no_errors which returns unexpanded vars as-is
428        // This prevents errors when env vars for unused profiles aren't set
429        let expanded =
430            shellexpand::env_with_context_no_errors(content, |var| std::env::var(var).ok());
431        expanded.to_string()
432    }
433}
434
435fn default_cloud_url() -> String {
436    "https://api.redislabs.com/v1".to_string()
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    #[test]
444    fn test_config_serialization() {
445        let mut config = Config::default();
446
447        let cloud_profile = Profile {
448            deployment_type: DeploymentType::Cloud,
449            credentials: ProfileCredentials::Cloud {
450                api_key: "test-key".to_string(),
451                api_secret: "test-secret".to_string(),
452                api_url: "https://api.redislabs.com/v1".to_string(),
453            },
454            files_api_key: None,
455        };
456
457        config.set_profile("test".to_string(), cloud_profile);
458        config.default_cloud = Some("test".to_string());
459
460        let serialized = toml::to_string(&config).unwrap();
461        let deserialized: Config = toml::from_str(&serialized).unwrap();
462
463        assert_eq!(config.default_cloud, deserialized.default_cloud);
464        assert_eq!(config.profiles.len(), deserialized.profiles.len());
465    }
466
467    #[test]
468    fn test_profile_credential_access() {
469        let cloud_profile = Profile {
470            deployment_type: DeploymentType::Cloud,
471            credentials: ProfileCredentials::Cloud {
472                api_key: "key".to_string(),
473                api_secret: "secret".to_string(),
474                api_url: "url".to_string(),
475            },
476            files_api_key: None,
477        };
478
479        let (key, secret, url) = cloud_profile.cloud_credentials().unwrap();
480        assert_eq!(key, "key");
481        assert_eq!(secret, "secret");
482        assert_eq!(url, "url");
483        assert!(cloud_profile.enterprise_credentials().is_none());
484    }
485
486    #[test]
487    #[serial_test::serial]
488    fn test_env_var_expansion() {
489        // Test basic environment variable expansion
490        unsafe {
491            std::env::set_var("TEST_API_KEY", "test-key-value");
492            std::env::set_var("TEST_API_SECRET", "test-secret-value");
493        }
494
495        let content = r#"
496[profiles.test]
497deployment_type = "cloud"
498api_key = "${TEST_API_KEY}"
499api_secret = "${TEST_API_SECRET}"
500"#;
501
502        let expanded = Config::expand_env_vars(content);
503        assert!(expanded.contains("test-key-value"));
504        assert!(expanded.contains("test-secret-value"));
505
506        // Clean up
507        unsafe {
508            std::env::remove_var("TEST_API_KEY");
509            std::env::remove_var("TEST_API_SECRET");
510        }
511    }
512
513    #[test]
514    #[serial_test::serial]
515    fn test_env_var_expansion_with_defaults() {
516        // Test environment variable expansion with defaults
517        unsafe {
518            std::env::remove_var("NONEXISTENT_VAR"); // Ensure it doesn't exist
519        }
520
521        let content = r#"
522[profiles.test]
523deployment_type = "cloud"
524api_key = "${NONEXISTENT_VAR:-default-key}"
525api_url = "${NONEXISTENT_URL:-https://api.redislabs.com/v1}"
526"#;
527
528        let expanded = Config::expand_env_vars(content);
529        assert!(expanded.contains("default-key"));
530        assert!(expanded.contains("https://api.redislabs.com/v1"));
531    }
532
533    #[test]
534    #[serial_test::serial]
535    fn test_env_var_expansion_mixed() {
536        // Test mixed static and dynamic values
537        unsafe {
538            std::env::set_var("TEST_DYNAMIC_KEY", "dynamic-value");
539        }
540
541        let content = r#"
542[profiles.test]
543deployment_type = "cloud"
544api_key = "${TEST_DYNAMIC_KEY}"
545api_secret = "static-secret"
546api_url = "${MISSING_VAR:-https://api.redislabs.com/v1}"
547"#;
548
549        let expanded = Config::expand_env_vars(content);
550        assert!(expanded.contains("dynamic-value"));
551        assert!(expanded.contains("static-secret"));
552        assert!(expanded.contains("https://api.redislabs.com/v1"));
553
554        // Clean up
555        unsafe {
556            std::env::remove_var("TEST_DYNAMIC_KEY");
557        }
558    }
559
560    #[test]
561    #[serial_test::serial]
562    fn test_full_config_with_env_expansion() {
563        // Test complete config parsing with environment variables
564        unsafe {
565            std::env::set_var("REDIS_TEST_KEY", "expanded-key");
566            std::env::set_var("REDIS_TEST_SECRET", "expanded-secret");
567        }
568
569        let config_content = r#"
570default_cloud = "test"
571
572[profiles.test]
573deployment_type = "cloud"
574api_key = "${REDIS_TEST_KEY}"
575api_secret = "${REDIS_TEST_SECRET}"
576api_url = "${REDIS_TEST_URL:-https://api.redislabs.com/v1}"
577"#;
578
579        let expanded = Config::expand_env_vars(config_content);
580        let config: Config = toml::from_str(&expanded).unwrap();
581
582        assert_eq!(config.default_cloud, Some("test".to_string()));
583
584        let profile = config.profiles.get("test").unwrap();
585        let (key, secret, url) = profile.cloud_credentials().unwrap();
586        assert_eq!(key, "expanded-key");
587        assert_eq!(secret, "expanded-secret");
588        assert_eq!(url, "https://api.redislabs.com/v1");
589
590        // Clean up
591        unsafe {
592            std::env::remove_var("REDIS_TEST_KEY");
593            std::env::remove_var("REDIS_TEST_SECRET");
594        }
595    }
596
597    #[test]
598    fn test_enterprise_profile_resolution() {
599        let mut config = Config::default();
600
601        // Add an enterprise profile
602        let enterprise_profile = Profile {
603            deployment_type: DeploymentType::Enterprise,
604            credentials: ProfileCredentials::Enterprise {
605                url: "https://localhost:9443".to_string(),
606                username: "admin".to_string(),
607                password: Some("password".to_string()),
608                insecure: false,
609            },
610            files_api_key: None,
611        };
612        config.set_profile("ent1".to_string(), enterprise_profile);
613
614        // Test explicit profile
615        assert_eq!(
616            config.resolve_enterprise_profile(Some("ent1")).unwrap(),
617            "ent1"
618        );
619
620        // Test first enterprise profile (no default set)
621        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
622
623        // Set default enterprise
624        config.default_enterprise = Some("ent1".to_string());
625        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
626    }
627
628    #[test]
629    fn test_cloud_profile_resolution() {
630        let mut config = Config::default();
631
632        // Add a cloud profile
633        let cloud_profile = Profile {
634            deployment_type: DeploymentType::Cloud,
635            credentials: ProfileCredentials::Cloud {
636                api_key: "key".to_string(),
637                api_secret: "secret".to_string(),
638                api_url: "https://api.redislabs.com/v1".to_string(),
639            },
640            files_api_key: None,
641        };
642        config.set_profile("cloud1".to_string(), cloud_profile);
643
644        // Test explicit profile
645        assert_eq!(
646            config.resolve_cloud_profile(Some("cloud1")).unwrap(),
647            "cloud1"
648        );
649
650        // Test first cloud profile (no default set)
651        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
652
653        // Set default cloud
654        config.default_cloud = Some("cloud1".to_string());
655        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
656    }
657
658    #[test]
659    fn test_mixed_profile_resolution() {
660        let mut config = Config::default();
661
662        // Add a cloud profile
663        let cloud_profile = Profile {
664            deployment_type: DeploymentType::Cloud,
665            credentials: ProfileCredentials::Cloud {
666                api_key: "key".to_string(),
667                api_secret: "secret".to_string(),
668                api_url: "https://api.redislabs.com/v1".to_string(),
669            },
670            files_api_key: None,
671        };
672        config.set_profile("cloud1".to_string(), cloud_profile.clone());
673        config.set_profile("cloud2".to_string(), cloud_profile);
674
675        // Add enterprise profiles
676        let enterprise_profile = Profile {
677            deployment_type: DeploymentType::Enterprise,
678            credentials: ProfileCredentials::Enterprise {
679                url: "https://localhost:9443".to_string(),
680                username: "admin".to_string(),
681                password: Some("password".to_string()),
682                insecure: false,
683            },
684            files_api_key: None,
685        };
686        config.set_profile("ent1".to_string(), enterprise_profile.clone());
687        config.set_profile("ent2".to_string(), enterprise_profile);
688
689        // Without defaults, should use first of each type
690        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud1");
691        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent1");
692
693        // Set type-specific defaults
694        config.default_cloud = Some("cloud2".to_string());
695        config.default_enterprise = Some("ent2".to_string());
696
697        // Should now use the type-specific defaults
698        assert_eq!(config.resolve_cloud_profile(None).unwrap(), "cloud2");
699        assert_eq!(config.resolve_enterprise_profile(None).unwrap(), "ent2");
700    }
701
702    #[test]
703    fn test_no_profile_errors() {
704        let config = Config::default();
705
706        // No profiles at all
707        assert!(config.resolve_enterprise_profile(None).is_err());
708        assert!(config.resolve_cloud_profile(None).is_err());
709    }
710
711    #[test]
712    fn test_wrong_profile_type_help() {
713        let mut config = Config::default();
714
715        // Only add cloud profiles
716        let cloud_profile = Profile {
717            deployment_type: DeploymentType::Cloud,
718            credentials: ProfileCredentials::Cloud {
719                api_key: "key".to_string(),
720                api_secret: "secret".to_string(),
721                api_url: "https://api.redislabs.com/v1".to_string(),
722            },
723            files_api_key: None,
724        };
725        config.set_profile("cloud1".to_string(), cloud_profile);
726
727        // Try to resolve enterprise profile - should get helpful error
728        let err = config.resolve_enterprise_profile(None).unwrap_err();
729        assert!(err.to_string().contains("No enterprise profiles"));
730        assert!(err.to_string().contains("Available cloud profiles: cloud1"));
731    }
732}