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::io::Write;
9use std::path::PathBuf;
10
11use serde::{Deserialize, Serialize};
12
13use crate::alias::Alias;
14use crate::error::{Error, Result};
15
16/// Current configuration schema version
17///
18/// IMPORTANT: Bumping this version requires:
19/// 1. Adding a migration in migrations/
20/// 2. Updating migration tests
21/// 3. Marking the change as BREAKING
22pub const SCHEMA_VERSION: u32 = 1;
23
24/// Default output format
25const DEFAULT_OUTPUT: &str = "human";
26
27/// Default color setting
28const DEFAULT_COLOR: &str = "auto";
29
30/// Main configuration structure
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct Config {
33    /// Schema version for migration support
34    pub schema_version: u32,
35
36    /// Default settings
37    #[serde(default)]
38    pub defaults: Defaults,
39
40    /// Configured aliases
41    #[serde(default)]
42    pub aliases: Vec<Alias>,
43}
44
45/// Default settings for CLI behavior
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct Defaults {
48    /// Output format: "human" or "json"
49    #[serde(default = "default_output")]
50    pub output: String,
51
52    /// Color mode: "auto", "always", or "never"
53    #[serde(default = "default_color")]
54    pub color: String,
55
56    /// Show progress bars
57    #[serde(default = "default_true")]
58    pub progress: bool,
59}
60
61fn default_output() -> String {
62    DEFAULT_OUTPUT.to_string()
63}
64
65fn default_color() -> String {
66    DEFAULT_COLOR.to_string()
67}
68
69fn default_true() -> bool {
70    true
71}
72
73impl Default for Defaults {
74    fn default() -> Self {
75        Self {
76            output: default_output(),
77            color: default_color(),
78            progress: true,
79        }
80    }
81}
82
83impl Default for Config {
84    fn default() -> Self {
85        Self {
86            schema_version: SCHEMA_VERSION,
87            defaults: Defaults::default(),
88            aliases: Vec::new(),
89        }
90    }
91}
92
93/// Configuration manager handles loading and saving config
94#[derive(Debug)]
95pub struct ConfigManager {
96    config_path: PathBuf,
97}
98
99impl ConfigManager {
100    /// Create a new ConfigManager with the default config path
101    ///
102    /// The config directory can be overridden by setting the `RC_CONFIG_DIR`
103    /// environment variable. This is useful for testing and containerized deployments.
104    pub fn new() -> Result<Self> {
105        let config_dir = if let Ok(dir) = std::env::var("RC_CONFIG_DIR") {
106            PathBuf::from(dir)
107        } else {
108            dirs::config_dir()
109                .ok_or_else(|| Error::Config("Could not determine config directory".into()))?
110                .join("rc")
111        };
112        let config_path = config_dir.join("config.toml");
113        Ok(Self { config_path })
114    }
115
116    /// Create a ConfigManager with a custom path (useful for testing)
117    pub fn with_path(path: PathBuf) -> Self {
118        Self { config_path: path }
119    }
120
121    /// Get the configuration file path
122    pub fn config_path(&self) -> &PathBuf {
123        &self.config_path
124    }
125
126    /// Load configuration from disk
127    ///
128    /// If the configuration file doesn't exist, returns a default configuration.
129    /// If the schema version doesn't match, attempts migration.
130    pub fn load(&self) -> Result<Config> {
131        if !self.config_path.exists() {
132            return Ok(Config::default());
133        }
134
135        let content = std::fs::read_to_string(&self.config_path)?;
136        let mut config: Config = toml::from_str(&content)?;
137
138        // Check schema version and migrate if necessary
139        if config.schema_version < SCHEMA_VERSION {
140            config = self.migrate(config)?;
141        } else if config.schema_version > SCHEMA_VERSION {
142            return Err(Error::Config(format!(
143                "Configuration file version {} is newer than supported version {}. Please upgrade rc.",
144                config.schema_version, SCHEMA_VERSION
145            )));
146        }
147
148        Ok(config)
149    }
150
151    /// Save configuration to disk
152    ///
153    /// Creates parent directories if they don't exist.
154    /// Sets Unix file permissions to 600 (owner read/write only) before writing.
155    pub fn save(&self, config: &Config) -> Result<()> {
156        // Ensure parent directory exists
157        if let Some(parent) = self.config_path.parent() {
158            std::fs::create_dir_all(parent)?;
159        }
160
161        let content = toml::to_string_pretty(config)?;
162        let mut options = std::fs::OpenOptions::new();
163        options.write(true).create(true).truncate(false);
164
165        #[cfg(unix)]
166        {
167            use std::os::unix::fs::OpenOptionsExt;
168            options.mode(0o600);
169        }
170
171        let mut file = options.open(&self.config_path)?;
172
173        // Existing files keep their mode when opened, so protect the opened file
174        // before truncating or writing credentials, and stop if that fails.
175        #[cfg(unix)]
176        {
177            use std::os::unix::fs::PermissionsExt;
178            file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
179        }
180
181        file.set_len(0)?;
182        file.write_all(content.as_bytes())?;
183
184        Ok(())
185    }
186
187    /// Migrate configuration from older schema version
188    fn migrate(&self, config: Config) -> Result<Config> {
189        let mut config = config;
190
191        // Add migration logic here when schema version is bumped
192        // Example:
193        // if config.schema_version == 1 {
194        //     config = migrate_v1_to_v2(config)?;
195        // }
196
197        config.schema_version = SCHEMA_VERSION;
198        Ok(config)
199    }
200}
201
202impl Default for ConfigManager {
203    fn default() -> Self {
204        Self::new().expect("Failed to create default ConfigManager")
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use tempfile::TempDir;
212
213    fn temp_config_manager() -> (ConfigManager, TempDir) {
214        let temp_dir = TempDir::new().unwrap();
215        let config_path = temp_dir.path().join("config.toml");
216        let manager = ConfigManager::with_path(config_path);
217        (manager, temp_dir)
218    }
219
220    #[test]
221    fn test_default_config() {
222        let config = Config::default();
223        assert_eq!(config.schema_version, SCHEMA_VERSION);
224        assert_eq!(config.defaults.output, "human");
225        assert_eq!(config.defaults.color, "auto");
226        assert!(config.defaults.progress);
227        assert!(config.aliases.is_empty());
228    }
229
230    #[test]
231    fn test_load_nonexistent_returns_default() {
232        let (manager, _temp_dir) = temp_config_manager();
233        let config = manager.load().unwrap();
234        assert_eq!(config.schema_version, SCHEMA_VERSION);
235    }
236
237    #[test]
238    fn test_save_and_load() {
239        let (manager, _temp_dir) = temp_config_manager();
240
241        let mut config = Config::default();
242        config.aliases.push(Alias {
243            name: "test".to_string(),
244            endpoint: "http://localhost:9000".to_string(),
245            access_key: "accesskey".to_string(),
246            secret_key: "secretkey".to_string(),
247            anonymous: false,
248            client_cert: None,
249            client_key: None,
250            region: "us-east-1".to_string(),
251            signature: "v4".to_string(),
252            bucket_lookup: "auto".to_string(),
253            insecure: false,
254            ca_bundle: None,
255            retry: None,
256            timeout: None,
257        });
258
259        manager.save(&config).unwrap();
260        let loaded = manager.load().unwrap();
261
262        #[cfg(unix)]
263        {
264            use std::os::unix::fs::PermissionsExt;
265            let mode = std::fs::metadata(manager.config_path())
266                .unwrap()
267                .permissions()
268                .mode();
269            assert_eq!(mode & 0o777, 0o600);
270        }
271
272        assert_eq!(loaded.aliases.len(), 1);
273        assert_eq!(loaded.aliases[0].name, "test");
274    }
275
276    #[test]
277    fn test_save_replaces_longer_config() {
278        let (manager, _temp_dir) = temp_config_manager();
279        let mut config = Config::default();
280        config.defaults.output = "a".repeat(1024);
281        manager.save(&config).unwrap();
282
283        let config = Config::default();
284        manager.save(&config).unwrap();
285
286        assert_eq!(
287            std::fs::read_to_string(manager.config_path()).unwrap(),
288            toml::to_string_pretty(&config).unwrap()
289        );
290    }
291
292    #[cfg(unix)]
293    #[test]
294    fn test_save_permissions_before_write() {
295        use std::os::unix::fs::PermissionsExt;
296        use std::process::Command;
297
298        const CHILD_CONFIG_PATH: &str = "RC_TEST_SAVE_PERMISSIONS_PATH";
299        if let Some(path) = std::env::var_os(CHILD_CONFIG_PATH) {
300            let manager = ConfigManager::with_path(PathBuf::from(path));
301            manager.save(&Config::default()).unwrap();
302            return;
303        }
304
305        for existing in [false, true] {
306            let (manager, _temp_dir) = temp_config_manager();
307            if existing {
308                std::fs::write(manager.config_path(), "old config").unwrap();
309                std::fs::set_permissions(
310                    manager.config_path(),
311                    std::fs::Permissions::from_mode(0o666),
312                )
313                .unwrap();
314            }
315
316            // Fail the first write in a child process, before a post-write chmod
317            // could run. Keep the umask and file-size limit out of other tests.
318            let output = Command::new("sh")
319                .args([
320                    "-c",
321                    "umask 000 && ulimit -c 0 && ulimit -f 0 && exec \"$@\"",
322                    "sh",
323                ])
324                .arg(std::env::current_exe().unwrap())
325                .args([
326                    "--exact",
327                    "config::tests::test_save_permissions_before_write",
328                ])
329                .env(CHILD_CONFIG_PATH, manager.config_path())
330                .output()
331                .unwrap();
332
333            assert!(
334                !output.status.success(),
335                "the file-size limit must reject writes"
336            );
337            let metadata = std::fs::metadata(manager.config_path()).unwrap();
338            assert_eq!(metadata.len(), 0);
339            assert_eq!(
340                metadata.permissions().mode() & 0o777,
341                0o600,
342                "config must already be private when writing fails (existing: {existing})"
343            );
344        }
345    }
346
347    #[test]
348    fn test_schema_version_too_new() {
349        let (manager, _temp_dir) = temp_config_manager();
350
351        let content = format!(
352            r#"
353            schema_version = {}
354            "#,
355            SCHEMA_VERSION + 1
356        );
357        std::fs::write(manager.config_path(), content).unwrap();
358
359        let result = manager.load();
360        assert!(result.is_err());
361        assert!(
362            result
363                .unwrap_err()
364                .to_string()
365                .contains("newer than supported")
366        );
367    }
368}