use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GorConfig {
#[serde(flatten)]
pub global: BTreeMap<String, serde_yaml_ng::Value>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub hosts: BTreeMap<String, BTreeMap<String, serde_yaml_ng::Value>>,
}
pub const SUPPORTED_KEYS: &[&str] = &["editor", "browser", "pager", "git_protocol", "prompt"];
pub fn config_path() -> Result<PathBuf, ConfigError> {
let base = dirs::config_dir().ok_or(ConfigError::NoHome)?;
Ok(base.join("gor").join("config.yml"))
}
pub fn load() -> Result<GorConfig, ConfigError> {
let path = config_path()?;
if !path.exists() {
return Ok(GorConfig::default());
}
let contents = std::fs::read_to_string(&path).map_err(|e| ConfigError::Read {
path: path.clone(),
source: e,
})?;
if contents.trim().is_empty() {
return Ok(GorConfig::default());
}
serde_yaml_ng::from_str(&contents).map_err(|e| ConfigError::Parse { path, source: e })
}
pub fn save(config: &GorConfig) -> Result<(), ConfigError> {
let path = config_path()?;
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| ConfigError::Write {
path: path.clone(),
source: e,
})?;
}
let yaml =
serde_yaml_ng::to_string(config).map_err(|e| ConfigError::Serialize { source: e })?;
std::fs::write(&path, &yaml).map_err(|e| ConfigError::Write {
path: path.clone(),
source: e,
})?;
set_restrictive_perms(&path)?;
Ok(())
}
#[cfg(unix)]
fn set_restrictive_perms(path: &std::path::Path) -> Result<(), ConfigError> {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path)
.map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: e,
})?
.permissions();
perms.set_mode(0o600);
std::fs::set_permissions(path, perms).map_err(|e| ConfigError::Write {
path: path.to_path_buf(),
source: e,
})?;
Ok(())
}
#[cfg(not(unix))]
fn set_restrictive_perms(_path: &std::path::Path) -> Result<(), ConfigError> {
Ok(())
}
#[must_use]
pub fn get<'a>(
config: &'a GorConfig,
key: &str,
host: Option<&str>,
) -> Option<&'a serde_yaml_ng::Value> {
if let Some(h) = host {
if let Some(host_config) = config.hosts.get(h) {
if let Some(value) = host_config.get(key) {
return Some(value);
}
}
}
config.global.get(key)
}
pub fn set(config: &mut GorConfig, key: &str, value: serde_yaml_ng::Value, host: Option<&str>) {
if let Some(h) = host {
config
.hosts
.entry(h.to_string())
.or_default()
.insert(key.to_string(), value);
} else {
config.global.insert(key.to_string(), value);
}
}
pub fn validate(key: &str, value: &str) -> Result<(), String> {
if !SUPPORTED_KEYS.contains(&key) {
return Err(format!(
"unknown config key '{key}'. Supported keys: {}",
SUPPORTED_KEYS.join(", ")
));
}
if key == "git_protocol" && !matches!(value, "https" | "ssh") {
return Err(format!(
"invalid value '{value}' for git_protocol: must be 'https' or 'ssh'"
));
}
Ok(())
}
#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
#[error("could not determine config directory")]
NoHome,
#[error("failed to read config file {path}: {source}")]
Read {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to parse config file {path}: {source}")]
Parse {
path: PathBuf,
source: serde_yaml_ng::Error,
},
#[error("failed to write config file {path}: {source}")]
Write {
path: PathBuf,
source: std::io::Error,
},
#[error("failed to serialize config: {source}")]
Serialize {
source: serde_yaml_ng::Error,
},
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_config_is_empty() {
let config = GorConfig::default();
assert!(config.global.is_empty());
assert!(config.hosts.is_empty());
}
#[test]
fn get_global_key() {
let mut config = GorConfig::default();
config.global.insert(
"editor".to_string(),
serde_yaml_ng::Value::String("vim".to_string()),
);
let value = get(&config, "editor", None);
assert_eq!(value.and_then(|v| v.as_str()), Some("vim"));
}
#[test]
fn get_host_scoped_key_falls_back_to_global() {
let mut config = GorConfig::default();
config.global.insert(
"editor".to_string(),
serde_yaml_ng::Value::String("vim".to_string()),
);
let value = get(&config, "editor", Some("github.com"));
assert_eq!(value.and_then(|v| v.as_str()), Some("vim"));
}
#[test]
fn get_host_scoped_key_overrides_global() {
let mut config = GorConfig::default();
config.global.insert(
"editor".to_string(),
serde_yaml_ng::Value::String("vim".to_string()),
);
config.hosts.insert(
"github.com".to_string(),
BTreeMap::from([(
"editor".to_string(),
serde_yaml_ng::Value::String("code".to_string()),
)]),
);
let value = get(&config, "editor", Some("github.com"));
assert_eq!(value.and_then(|v| v.as_str()), Some("code"));
}
#[test]
fn set_global_key() {
let mut config = GorConfig::default();
set(
&mut config,
"editor",
serde_yaml_ng::Value::String("vim".to_string()),
None,
);
assert_eq!(
config.global.get("editor").and_then(|v| v.as_str()),
Some("vim")
);
}
#[test]
fn set_host_scoped_key() {
let mut config = GorConfig::default();
set(
&mut config,
"editor",
serde_yaml_ng::Value::String("code".to_string()),
Some("github.com"),
);
assert_eq!(
config
.hosts
.get("github.com")
.and_then(|h| h.get("editor"))
.and_then(|v| v.as_str()),
Some("code")
);
}
#[test]
fn validate_known_key() {
assert!(validate("editor", "vim").is_ok());
}
#[test]
fn validate_unknown_key() {
assert!(validate("unknown_key", "value").is_err());
}
#[test]
fn validate_git_protocol_https() {
assert!(validate("git_protocol", "https").is_ok());
}
#[test]
fn validate_git_protocol_ssh() {
assert!(validate("git_protocol", "ssh").is_ok());
}
#[test]
fn validate_git_protocol_invalid() {
assert!(validate("git_protocol", "ftp").is_err());
}
#[test]
fn supported_keys_list() {
assert!(SUPPORTED_KEYS.contains(&"editor"));
assert!(SUPPORTED_KEYS.contains(&"browser"));
assert!(SUPPORTED_KEYS.contains(&"pager"));
assert!(SUPPORTED_KEYS.contains(&"git_protocol"));
assert!(SUPPORTED_KEYS.contains(&"prompt"));
}
}