Skip to main content

rc_core/
config.rs

1//! Configuration management
2//!
3//! This module handles loading, saving, and migrating the rc configuration file.
4//! The configuration file is stored in TOML format at ~/.config/rc/config.toml.
5//!
6//! PROTECTED FILE: Changes to schema_version require migration support.
7
8use std::path::PathBuf;
9
10use serde::{Deserialize, Serialize};
11
12use crate::alias::Alias;
13use crate::error::{Error, Result};
14
15/// Current configuration schema version
16///
17/// IMPORTANT: Bumping this version requires:
18/// 1. Adding a migration in migrations/
19/// 2. Updating migration tests
20/// 3. Marking the change as BREAKING
21pub const SCHEMA_VERSION: u32 = 1;
22
23/// Default output format
24const DEFAULT_OUTPUT: &str = "human";
25
26/// Default color setting
27const DEFAULT_COLOR: &str = "auto";
28
29/// Main configuration structure
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct Config {
32    /// Schema version for migration support
33    pub schema_version: u32,
34
35    /// Default settings
36    #[serde(default)]
37    pub defaults: Defaults,
38
39    /// Configured aliases
40    #[serde(default)]
41    pub aliases: Vec<Alias>,
42}
43
44/// Default settings for CLI behavior
45#[derive(Debug, Clone, Serialize, Deserialize)]
46pub struct Defaults {
47    /// Output format: "human" or "json"
48    #[serde(default = "default_output")]
49    pub output: String,
50
51    /// Color mode: "auto", "always", or "never"
52    #[serde(default = "default_color")]
53    pub color: String,
54
55    /// Show progress bars
56    #[serde(default = "default_true")]
57    pub progress: bool,
58}
59
60fn default_output() -> String {
61    DEFAULT_OUTPUT.to_string()
62}
63
64fn default_color() -> String {
65    DEFAULT_COLOR.to_string()
66}
67
68fn default_true() -> bool {
69    true
70}
71
72impl Default for Defaults {
73    fn default() -> Self {
74        Self {
75            output: default_output(),
76            color: default_color(),
77            progress: true,
78        }
79    }
80}
81
82impl Default for Config {
83    fn default() -> Self {
84        Self {
85            schema_version: SCHEMA_VERSION,
86            defaults: Defaults::default(),
87            aliases: Vec::new(),
88        }
89    }
90}
91
92/// Configuration manager handles loading and saving config
93#[derive(Debug)]
94pub struct ConfigManager {
95    config_path: PathBuf,
96}
97
98impl ConfigManager {
99    /// Create a new ConfigManager with the default config path
100    ///
101    /// The config directory can be overridden by setting the `RC_CONFIG_DIR`
102    /// environment variable. This is useful for testing and containerized deployments.
103    pub fn new() -> Result<Self> {
104        let config_dir = if let Ok(dir) = std::env::var("RC_CONFIG_DIR") {
105            PathBuf::from(dir)
106        } else {
107            dirs::config_dir()
108                .ok_or_else(|| Error::Config("Could not determine config directory".into()))?
109                .join("rc")
110        };
111        let config_path = config_dir.join("config.toml");
112        Ok(Self { config_path })
113    }
114
115    /// Create a ConfigManager with a custom path (useful for testing)
116    pub fn with_path(path: PathBuf) -> Self {
117        Self { config_path: path }
118    }
119
120    /// Get the configuration file path
121    pub fn config_path(&self) -> &PathBuf {
122        &self.config_path
123    }
124
125    /// Load configuration from disk
126    ///
127    /// If the configuration file doesn't exist, returns a default configuration.
128    /// If the schema version doesn't match, attempts migration.
129    pub fn load(&self) -> Result<Config> {
130        if !self.config_path.exists() {
131            return Ok(Config::default());
132        }
133
134        let content = std::fs::read_to_string(&self.config_path)?;
135        let mut config: Config = toml::from_str(&content)?;
136
137        // Check schema version and migrate if necessary
138        if config.schema_version < SCHEMA_VERSION {
139            config = self.migrate(config)?;
140        } else if config.schema_version > SCHEMA_VERSION {
141            return Err(Error::Config(format!(
142                "Configuration file version {} is newer than supported version {}. Please upgrade rc.",
143                config.schema_version, SCHEMA_VERSION
144            )));
145        }
146
147        Ok(config)
148    }
149
150    /// Save configuration to disk
151    ///
152    /// Creates parent directories if they don't exist.
153    /// Sets file permissions to 600 (owner read/write only).
154    pub fn save(&self, config: &Config) -> Result<()> {
155        // Ensure parent directory exists
156        if let Some(parent) = self.config_path.parent() {
157            std::fs::create_dir_all(parent)?;
158        }
159
160        let content = toml::to_string_pretty(config)?;
161        std::fs::write(&self.config_path, content)?;
162
163        // Set restrictive permissions on Unix systems
164        #[cfg(unix)]
165        {
166            use std::os::unix::fs::PermissionsExt;
167            let permissions = std::fs::Permissions::from_mode(0o600);
168            std::fs::set_permissions(&self.config_path, permissions)?;
169        }
170
171        Ok(())
172    }
173
174    /// Migrate configuration from older schema version
175    fn migrate(&self, config: Config) -> Result<Config> {
176        let mut config = config;
177
178        // Add migration logic here when schema version is bumped
179        // Example:
180        // if config.schema_version == 1 {
181        //     config = migrate_v1_to_v2(config)?;
182        // }
183
184        config.schema_version = SCHEMA_VERSION;
185        Ok(config)
186    }
187}
188
189impl Default for ConfigManager {
190    fn default() -> Self {
191        Self::new().expect("Failed to create default ConfigManager")
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::*;
198    use tempfile::TempDir;
199
200    fn temp_config_manager() -> (ConfigManager, TempDir) {
201        let temp_dir = TempDir::new().unwrap();
202        let config_path = temp_dir.path().join("config.toml");
203        let manager = ConfigManager::with_path(config_path);
204        (manager, temp_dir)
205    }
206
207    #[test]
208    fn test_default_config() {
209        let config = Config::default();
210        assert_eq!(config.schema_version, SCHEMA_VERSION);
211        assert_eq!(config.defaults.output, "human");
212        assert_eq!(config.defaults.color, "auto");
213        assert!(config.defaults.progress);
214        assert!(config.aliases.is_empty());
215    }
216
217    #[test]
218    fn test_load_nonexistent_returns_default() {
219        let (manager, _temp_dir) = temp_config_manager();
220        let config = manager.load().unwrap();
221        assert_eq!(config.schema_version, SCHEMA_VERSION);
222    }
223
224    #[test]
225    fn test_save_and_load() {
226        let (manager, _temp_dir) = temp_config_manager();
227
228        let mut config = Config::default();
229        config.aliases.push(Alias {
230            name: "test".to_string(),
231            endpoint: "http://localhost:9000".to_string(),
232            access_key: "accesskey".to_string(),
233            secret_key: "secretkey".to_string(),
234            region: "us-east-1".to_string(),
235            signature: "v4".to_string(),
236            bucket_lookup: "auto".to_string(),
237            insecure: false,
238            ca_bundle: None,
239            retry: None,
240            timeout: None,
241        });
242
243        manager.save(&config).unwrap();
244        let loaded = manager.load().unwrap();
245
246        assert_eq!(loaded.aliases.len(), 1);
247        assert_eq!(loaded.aliases[0].name, "test");
248    }
249
250    #[test]
251    fn test_schema_version_too_new() {
252        let (manager, _temp_dir) = temp_config_manager();
253
254        let content = format!(
255            r#"
256            schema_version = {}
257            "#,
258            SCHEMA_VERSION + 1
259        );
260        std::fs::write(manager.config_path(), content).unwrap();
261
262        let result = manager.load();
263        assert!(result.is_err());
264        assert!(
265            result
266                .unwrap_err()
267                .to_string()
268                .contains("newer than supported")
269        );
270    }
271}