1use serde::{Deserialize, Serialize};
7use std::collections::BTreeMap;
8use std::path::PathBuf;
9
10#[derive(Debug, Clone, Serialize, Deserialize, Default)]
12pub struct GorConfig {
13 #[serde(flatten)]
15 pub global: BTreeMap<String, serde_yaml_ng::Value>,
16 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
18 pub hosts: BTreeMap<String, BTreeMap<String, serde_yaml_ng::Value>>,
19}
20
21pub const SUPPORTED_KEYS: &[&str] = &["editor", "browser", "pager", "git_protocol", "prompt"];
23
24pub 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
34pub 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
54pub 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#[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 Ok(())
99}
100
101#[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
120pub 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
133pub 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#[derive(Debug, thiserror::Error)]
155pub enum ConfigError {
156 #[error("could not determine config directory")]
158 NoHome,
159 #[error("failed to read config file {path}: {source}")]
161 Read {
162 path: PathBuf,
164 source: std::io::Error,
166 },
167 #[error("failed to parse config file {path}: {source}")]
169 Parse {
170 path: PathBuf,
172 source: serde_yaml_ng::Error,
174 },
175 #[error("failed to write config file {path}: {source}")]
177 Write {
178 path: PathBuf,
180 source: std::io::Error,
182 },
183 #[error("failed to serialize config: {source}")]
185 Serialize {
186 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 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}