Skip to main content

gor/
config.rs

1//! Configuration file management for `gor`.
2//!
3//! Reads and writes `~/.config/gor/config.yml` (mode 0600).
4//! Supports host-scoped keys via a top-level `hosts` map.
5
6use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10/// Top-level configuration structure stored in `~/.config/gor/config.yml`.
11#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct GorConfig {
13    /// Global config keys (editor, browser, pager, git_protocol, prompt).
14    #[serde(flatten)]
15    pub global: BTreeMap<String, serde_yaml_ng::Value>,
16    /// Host-scoped configuration overrides.
17    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
18    pub hosts: BTreeMap<String, BTreeMap<String, serde_yaml_ng::Value>>,
19}
20
21/// Supported top-level config keys and their validation rules.
22pub const SUPPORTED_KEYS: &[&str] = &["editor", "browser", "pager", "git_protocol", "prompt"];
23
24/// Returns the path to the config file: `~/.config/gor/config.yml`.
25///
26/// # Errors
27///
28/// Returns an error if the home directory cannot be determined.
29pub fn config_path() -> Result<PathBuf, ConfigError> {
30    let base = dirs::config_dir().ok_or(ConfigError::NoHome)?;
31    Ok(base.join("gor").join("config.yml"))
32}
33
34/// Load the config from disk. Returns a default empty config if the file does not exist.
35///
36/// # Errors
37///
38/// Returns an I/O error if the file exists but cannot be read, or if the YAML is malformed.
39pub fn load() -> Result<GorConfig, ConfigError> {
40    let path = config_path()?;
41    if !path.exists() {
42        return Ok(GorConfig::default());
43    }
44    let contents = std::fs::read_to_string(&path).map_err(|e| ConfigError::Read {
45        path: path.clone(),
46        source: e,
47    })?;
48    if contents.trim().is_empty() {
49        return Ok(GorConfig::default());
50    }
51    serde_yaml_ng::from_str(&contents).map_err(|e| ConfigError::Parse { path, source: e })
52}
53
54/// Save the config to disk, creating parent directories and setting mode 0600.
55///
56/// # Errors
57///
58/// Returns an I/O error if the file cannot be written.
59pub fn save(config: &GorConfig) -> Result<(), ConfigError> {
60    let path = config_path()?;
61    if let Some(parent) = path.parent() {
62        std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
63            path: path.clone(),
64            source: e,
65        })?;
66    }
67    let yaml =
68        serde_yaml_ng::to_string(config).map_err(|e| ConfigError::Serialize { source: e })?;
69    std::fs::write(&path, &yaml).map_err(|e| ConfigError::Write {
70        path: path.clone(),
71        source: e,
72    })?;
73    set_restrictive_perms(&path)?;
74    Ok(())
75}
76
77/// Set file permissions to 0600 (owner read/write only).
78#[cfg(unix)]
79fn set_restrictive_perms(path: &std::path::Path) -> Result<(), ConfigError> {
80    use std::os::unix::fs::PermissionsExt;
81    let mut perms = std::fs::metadata(path)
82        .map_err(|e| ConfigError::Write {
83            path: path.to_path_buf(),
84            source: e,
85        })?
86        .permissions();
87    perms.set_mode(0o600);
88    std::fs::set_permissions(path, perms).map_err(|e| ConfigError::Write {
89        path: path.to_path_buf(),
90        source: e,
91    })?;
92    Ok(())
93}
94
95#[cfg(not(unix))]
96fn set_restrictive_perms(_path: &std::path::Path) -> Result<(), ConfigError> {
97    // On non-Unix platforms, we rely on the OS default permissions.
98    Ok(())
99}
100
101/// Get a config value, checking host-scoped overrides first.
102///
103/// If `host` is provided, checks `hosts.<host>.<key>` first, then falls back to the global key.
104#[must_use]
105pub fn get<'a>(
106    config: &'a GorConfig,
107    key: &str,
108    host: Option<&str>,
109) -> Option<&'a serde_yaml_ng::Value> {
110    if let Some(h) = host {
111        if let Some(host_config) = config.hosts.get(h) {
112            if let Some(value) = host_config.get(key) {
113                return Some(value);
114            }
115        }
116    }
117    config.global.get(key)
118}
119
120/// Set a config value. If `host` is provided, sets it under `hosts.<host>.<key>`.
121pub fn set(config: &mut GorConfig, key: &str, value: serde_yaml_ng::Value, host: Option<&str>) {
122    if let Some(h) = host {
123        config
124            .hosts
125            .entry(h.to_string())
126            .or_default()
127            .insert(key.to_string(), value);
128    } else {
129        config.global.insert(key.to_string(), value);
130    }
131}
132
133/// Validate a config key and value combination.
134///
135/// # Errors
136///
137/// Returns an error string if the key is unknown or the value is invalid.
138pub fn validate(key: &str, value: &str) -> Result<(), String> {
139    if !SUPPORTED_KEYS.contains(&key) {
140        return Err(format!(
141            "unknown config key '{key}'. Supported keys: {}",
142            SUPPORTED_KEYS.join(", ")
143        ));
144    }
145    if key == "git_protocol" && !matches!(value, "https" | "ssh") {
146        return Err(format!(
147            "invalid value '{value}' for git_protocol: must be 'https' or 'ssh'"
148        ));
149    }
150    Ok(())
151}
152
153/// Errors that can occur during config file operations.
154#[derive(Debug, thiserror::Error)]
155pub enum ConfigError {
156    /// Could not determine the home directory.
157    #[error("could not determine config directory")]
158    NoHome,
159    /// Failed to read the config file.
160    #[error("failed to read config file {path}: {source}")]
161    Read {
162        /// Path to the config file.
163        path: PathBuf,
164        /// Underlying I/O error.
165        source: std::io::Error,
166    },
167    /// Failed to parse the config file as YAML.
168    #[error("failed to parse config file {path}: {source}")]
169    Parse {
170        /// Path to the config file.
171        path: PathBuf,
172        /// Underlying YAML parse error.
173        source: serde_yaml_ng::Error,
174    },
175    /// Failed to write the config file.
176    #[error("failed to write config file {path}: {source}")]
177    Write {
178        /// Path to the config file.
179        path: PathBuf,
180        /// Underlying I/O error.
181        source: std::io::Error,
182    },
183    /// Failed to serialize the config to YAML.
184    #[error("failed to serialize config: {source}")]
185    Serialize {
186        /// Underlying YAML serialization error.
187        source: serde_yaml_ng::Error,
188    },
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn default_config_is_empty() {
197        let config = GorConfig::default();
198        assert!(config.global.is_empty());
199        assert!(config.hosts.is_empty());
200    }
201
202    #[test]
203    fn get_global_key() {
204        let mut config = GorConfig::default();
205        config.global.insert(
206            "editor".to_string(),
207            serde_yaml_ng::Value::String("vim".to_string()),
208        );
209        let value = get(&config, "editor", None);
210        assert_eq!(value.and_then(|v| v.as_str()), Some("vim"));
211    }
212
213    #[test]
214    fn get_host_scoped_key_falls_back_to_global() {
215        let mut config = GorConfig::default();
216        config.global.insert(
217            "editor".to_string(),
218            serde_yaml_ng::Value::String("vim".to_string()),
219        );
220        // No host override — should fall back to global.
221        let value = get(&config, "editor", Some("github.com"));
222        assert_eq!(value.and_then(|v| v.as_str()), Some("vim"));
223    }
224
225    #[test]
226    fn get_host_scoped_key_overrides_global() {
227        let mut config = GorConfig::default();
228        config.global.insert(
229            "editor".to_string(),
230            serde_yaml_ng::Value::String("vim".to_string()),
231        );
232        config.hosts.insert(
233            "github.com".to_string(),
234            BTreeMap::from([(
235                "editor".to_string(),
236                serde_yaml_ng::Value::String("code".to_string()),
237            )]),
238        );
239        let value = get(&config, "editor", Some("github.com"));
240        assert_eq!(value.and_then(|v| v.as_str()), Some("code"));
241    }
242
243    #[test]
244    fn set_global_key() {
245        let mut config = GorConfig::default();
246        set(
247            &mut config,
248            "editor",
249            serde_yaml_ng::Value::String("vim".to_string()),
250            None,
251        );
252        assert_eq!(
253            config.global.get("editor").and_then(|v| v.as_str()),
254            Some("vim")
255        );
256    }
257
258    #[test]
259    fn set_host_scoped_key() {
260        let mut config = GorConfig::default();
261        set(
262            &mut config,
263            "editor",
264            serde_yaml_ng::Value::String("code".to_string()),
265            Some("github.com"),
266        );
267        assert_eq!(
268            config
269                .hosts
270                .get("github.com")
271                .and_then(|h| h.get("editor"))
272                .and_then(|v| v.as_str()),
273            Some("code")
274        );
275    }
276
277    #[test]
278    fn validate_known_key() {
279        assert!(validate("editor", "vim").is_ok());
280    }
281
282    #[test]
283    fn validate_unknown_key() {
284        assert!(validate("unknown_key", "value").is_err());
285    }
286
287    #[test]
288    fn validate_git_protocol_https() {
289        assert!(validate("git_protocol", "https").is_ok());
290    }
291
292    #[test]
293    fn validate_git_protocol_ssh() {
294        assert!(validate("git_protocol", "ssh").is_ok());
295    }
296
297    #[test]
298    fn validate_git_protocol_invalid() {
299        assert!(validate("git_protocol", "ftp").is_err());
300    }
301
302    #[test]
303    fn supported_keys_list() {
304        assert!(SUPPORTED_KEYS.contains(&"editor"));
305        assert!(SUPPORTED_KEYS.contains(&"browser"));
306        assert!(SUPPORTED_KEYS.contains(&"pager"));
307        assert!(SUPPORTED_KEYS.contains(&"git_protocol"));
308        assert!(SUPPORTED_KEYS.contains(&"prompt"));
309    }
310}