1use std::collections::HashMap;
5use std::path::PathBuf;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10pub const APP_NAME: &str = "gogcli";
15pub const DEFAULT_CLIENT_NAME: &str = "default";
16
17#[derive(Debug, Error)]
22pub enum ConfigError {
23 #[error("cannot resolve config directory: {0}")]
24 ConfigDir(String),
25
26 #[error("read config: {0}")]
27 ReadConfig(std::io::Error),
28
29 #[error("parse config {path}: {message}")]
30 ParseConfig { path: PathBuf, message: String },
31
32 #[error("write config: {0}")]
33 WriteConfig(String),
34
35 #[error("invalid client name: {0}")]
36 InvalidClientName(String),
37
38 #[error("oauth credentials missing: {path}")]
39 CredentialsMissing { path: PathBuf },
40}
41
42impl From<std::io::Error> for ConfigError {
43 fn from(e: std::io::Error) -> Self {
44 ConfigError::ReadConfig(e)
45 }
46}
47
48fn is_none<T>(v: &Option<T>) -> bool {
53 v.is_none()
54}
55
56fn is_empty_map(m: &HashMap<String, String>) -> bool {
57 m.is_empty()
58}
59
60#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
61pub struct ConfigFile {
62 #[serde(skip_serializing_if = "is_none")]
63 pub keyring_backend: Option<String>,
64
65 #[serde(skip_serializing_if = "is_none")]
66 pub default_timezone: Option<String>,
67
68 #[serde(default, skip_serializing_if = "is_empty_map")]
69 pub account_aliases: HashMap<String, String>,
70
71 #[serde(default, skip_serializing_if = "is_empty_map")]
72 pub account_clients: HashMap<String, String>,
73
74 #[serde(default, skip_serializing_if = "is_empty_map")]
75 pub client_domains: HashMap<String, String>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ClientCredentials {
84 pub client_id: String,
85 pub client_secret: String,
86}
87
88#[derive(Debug, Deserialize)]
90struct GoogleOAuthInner {
91 client_id: String,
92 client_secret: String,
93}
94
95#[derive(Debug, Deserialize)]
96struct GoogleCredentialsFile {
97 installed: Option<GoogleOAuthInner>,
98 web: Option<GoogleOAuthInner>,
99}
100
101pub fn config_dir() -> Result<PathBuf, ConfigError> {
107 let base = dirs::config_dir()
108 .ok_or_else(|| ConfigError::ConfigDir("cannot determine user config directory".into()))?;
109 Ok(base.join(APP_NAME))
110}
111
112pub fn config_path() -> Result<PathBuf, ConfigError> {
114 Ok(config_dir()?.join("config.json"))
115}
116
117pub fn ensure_dir() -> Result<PathBuf, ConfigError> {
119 let dir = config_dir()?;
120 std::fs::create_dir_all(&dir)
121 .map_err(|e| ConfigError::WriteConfig(format!("ensure config dir: {e}")))?;
122 Ok(dir)
123}
124
125pub fn keyring_dir() -> Result<PathBuf, ConfigError> {
127 Ok(config_dir()?.join("keyring"))
128}
129
130pub fn ensure_keyring_dir() -> Result<PathBuf, ConfigError> {
132 let dir = keyring_dir()?;
133 std::fs::create_dir_all(&dir)
134 .map_err(|e| ConfigError::WriteConfig(format!("ensure keyring dir: {e}")))?;
135 Ok(dir)
136}
137
138pub fn normalize_client_name(raw: &str) -> Result<String, ConfigError> {
145 let name = raw.trim().to_lowercase();
146 if name.is_empty() {
147 return Err(ConfigError::InvalidClientName("empty".into()));
148 }
149 for ch in name.chars() {
150 if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' || ch == '.' {
151 continue;
152 }
153 return Err(ConfigError::InvalidClientName(format!("{:?}", raw)));
154 }
155 Ok(name)
156}
157
158pub fn normalize_client_name_or_default(name: &str) -> Result<String, ConfigError> {
160 if name.trim().is_empty() {
161 return Ok(DEFAULT_CLIENT_NAME.to_string());
162 }
163 normalize_client_name(name)
164}
165
166pub fn client_credentials_path_for(client: &str) -> Result<PathBuf, ConfigError> {
173 let dir = config_dir()?;
174 let normalized = normalize_client_name_or_default(client)?;
175 if normalized == DEFAULT_CLIENT_NAME {
176 Ok(dir.join("credentials.json"))
177 } else {
178 Ok(dir.join(format!("credentials-{normalized}.json")))
179 }
180}
181
182pub fn read_config() -> Result<ConfigFile, ConfigError> {
188 let path = config_path()?;
189 let bytes = match std::fs::read(&path) {
190 Ok(b) => b,
191 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(ConfigFile::default()),
192 Err(e) => return Err(ConfigError::ReadConfig(e)),
193 };
194 let text = String::from_utf8_lossy(&bytes);
195 json5::from_str(&text).map_err(|e| ConfigError::ParseConfig {
196 path,
197 message: e.to_string(),
198 })
199}
200
201pub fn write_config(cfg: &ConfigFile) -> Result<(), ConfigError> {
203 ensure_dir()?;
204 let path = config_path()?;
205 let mut json = serde_json::to_string_pretty(cfg)
206 .map_err(|e| ConfigError::WriteConfig(format!("encode config: {e}")))?;
207 json.push('\n');
208
209 let tmp = path.with_extension("json.tmp");
210 std::fs::write(&tmp, json.as_bytes())
211 .map_err(|e| ConfigError::WriteConfig(format!("write config tmp: {e}")))?;
212 std::fs::rename(&tmp, &path)
213 .map_err(|e| ConfigError::WriteConfig(format!("commit config: {e}")))?;
214 Ok(())
215}
216
217pub fn read_client_credentials_for(client: &str) -> Result<ClientCredentials, ConfigError> {
225 let path = client_credentials_path_for(client)?;
226 let bytes = match std::fs::read(&path) {
227 Ok(b) => b,
228 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
229 return Err(ConfigError::CredentialsMissing { path });
230 }
231 Err(e) => return Err(ConfigError::ReadConfig(e)),
232 };
233
234 if let Ok(gcf) = serde_json::from_slice::<GoogleCredentialsFile>(&bytes) {
236 let inner = gcf.installed.or(gcf.web);
237 if let Some(inner) = inner {
238 if !inner.client_id.is_empty() && !inner.client_secret.is_empty() {
239 return Ok(ClientCredentials {
240 client_id: inner.client_id,
241 client_secret: inner.client_secret,
242 });
243 }
244 }
245 }
246
247 let creds: ClientCredentials = serde_json::from_slice(&bytes)
249 .map_err(|e| ConfigError::ParseConfig { path: path.clone(), message: e.to_string() })?;
250 if creds.client_id.is_empty() || creds.client_secret.is_empty() {
251 return Err(ConfigError::ParseConfig {
252 path,
253 message: "missing client_id or client_secret".into(),
254 });
255 }
256 Ok(creds)
257}
258
259pub fn expand_path(path: &str) -> Result<PathBuf, ConfigError> {
266 if path.is_empty() {
267 return Ok(PathBuf::new());
268 }
269 if path == "~" {
270 let home = dirs::home_dir()
271 .ok_or_else(|| ConfigError::ConfigDir("cannot determine home directory".into()))?;
272 return Ok(home);
273 }
274 if let Some(rest) = path.strip_prefix("~/") {
275 let home = dirs::home_dir()
276 .ok_or_else(|| ConfigError::ConfigDir("cannot determine home directory".into()))?;
277 return Ok(home.join(rest));
278 }
279 Ok(PathBuf::from(path))
280}
281
282#[cfg(test)]
287mod tests {
288 use super::*;
289 #[test]
292 fn test_normalize_client_default() {
293 assert_eq!(normalize_client_name_or_default("").unwrap(), "default");
294 assert_eq!(normalize_client_name_or_default(" ").unwrap(), "default");
295 assert_eq!(normalize_client_name_or_default("\t").unwrap(), "default");
296 }
297
298 #[test]
299 fn test_normalize_client_valid() {
300 assert_eq!(normalize_client_name_or_default("work").unwrap(), "work");
301 assert_eq!(normalize_client_name_or_default("My-Client").unwrap(), "my-client");
302 assert_eq!(normalize_client_name_or_default("FOO_BAR.baz").unwrap(), "foo_bar.baz");
303 }
304
305 #[test]
306 fn test_normalize_client_invalid() {
307 assert!(normalize_client_name_or_default("bad name!").is_err());
308 assert!(normalize_client_name_or_default("hello world").is_err());
309 assert!(normalize_client_name_or_default("a@b").is_err());
310 }
311
312 #[test]
315 fn test_expand_path_tilde() {
316 let result = expand_path("~/foo/bar").unwrap();
317 let home = dirs::home_dir().unwrap();
318 assert_eq!(result, home.join("foo/bar"));
319 }
320
321 #[test]
322 fn test_expand_path_absolute() {
323 let result = expand_path("/usr/bin").unwrap();
324 assert_eq!(result, PathBuf::from("/usr/bin"));
325 }
326
327 #[test]
328 fn test_expand_path_empty() {
329 let result = expand_path("").unwrap();
330 assert_eq!(result, PathBuf::new());
331 }
332
333 #[test]
334 fn test_expand_path_tilde_only() {
335 let result = expand_path("~").unwrap();
336 let home = dirs::home_dir().unwrap();
337 assert_eq!(result, home);
338 }
339
340 #[test]
343 fn test_config_file_default() {
344 let cfg = ConfigFile::default();
345 assert!(cfg.keyring_backend.is_none());
346 assert!(cfg.default_timezone.is_none());
347 assert!(cfg.account_aliases.is_empty());
348 assert!(cfg.account_clients.is_empty());
349 assert!(cfg.client_domains.is_empty());
350 }
351
352 #[test]
353 fn test_config_file_roundtrip() {
354 let mut cfg = ConfigFile::default();
355 cfg.keyring_backend = Some("secret-service".to_string());
356 cfg.default_timezone = Some("America/New_York".to_string());
357 cfg.account_aliases
358 .insert("me@example.com".to_string(), "personal".to_string());
359 cfg.account_clients
360 .insert("work@corp.com".to_string(), "work".to_string());
361 cfg.client_domains
362 .insert("corp.com".to_string(), "work".to_string());
363
364 let json = serde_json::to_string_pretty(&cfg).unwrap();
365 let decoded: ConfigFile = serde_json::from_str(&json).unwrap();
366 assert_eq!(cfg, decoded);
367 }
368
369 #[test]
370 fn test_config_file_skip_empty_on_serialize() {
371 let cfg = ConfigFile::default();
372 let json = serde_json::to_string(&cfg).unwrap();
373 assert!(!json.contains("keyring_backend"));
375 assert!(!json.contains("default_timezone"));
376 assert!(!json.contains("account_aliases"));
377 assert!(!json.contains("account_clients"));
378 assert!(!json.contains("client_domains"));
379 }
380
381 #[test]
384 fn test_config_dir_returns_path() {
385 let dir = config_dir().unwrap();
386 assert!(dir.ends_with("gogcli"));
387 }
388
389 #[test]
392 fn test_credentials_path_default() {
393 let path = client_credentials_path_for("").unwrap();
394 assert!(path.ends_with("credentials.json"));
395 assert!(!path.to_string_lossy().contains("credentials-"));
396 }
397
398 #[test]
399 fn test_credentials_path_named() {
400 let path = client_credentials_path_for("work").unwrap();
401 assert!(path.ends_with("credentials-work.json"));
402 }
403
404 #[test]
405 fn test_credentials_path_default_explicit() {
406 let path = client_credentials_path_for("default").unwrap();
407 assert!(path.ends_with("credentials.json"));
408 }
409
410 #[test]
411 fn test_credentials_path_invalid_name() {
412 assert!(client_credentials_path_for("bad name!").is_err());
413 }
414
415 #[test]
418 fn test_read_config_missing_returns_default() {
419 let result = read_config();
425 assert!(result.is_ok(), "read_config returned error: {result:?}");
426 }
427
428 fn read_config_from(path: &std::path::Path) -> Result<ConfigFile, ConfigError> {
434 let bytes = match std::fs::read(path) {
435 Ok(b) => b,
436 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
437 return Ok(ConfigFile::default())
438 }
439 Err(e) => return Err(ConfigError::ReadConfig(e)),
440 };
441 let text = String::from_utf8_lossy(&bytes);
442 json5::from_str(&text).map_err(|e| ConfigError::ParseConfig {
443 path: path.to_path_buf(),
444 message: e.to_string(),
445 })
446 }
447
448 fn write_config_to(cfg: &ConfigFile, path: &std::path::Path) -> Result<(), ConfigError> {
450 if let Some(parent) = path.parent() {
451 std::fs::create_dir_all(parent)
452 .map_err(|e| ConfigError::WriteConfig(format!("ensure dir: {e}")))?;
453 }
454 let mut json = serde_json::to_string_pretty(cfg)
455 .map_err(|e| ConfigError::WriteConfig(format!("encode: {e}")))?;
456 json.push('\n');
457 let tmp = path.with_extension("json.tmp");
458 std::fs::write(&tmp, json.as_bytes())
459 .map_err(|e| ConfigError::WriteConfig(format!("write tmp: {e}")))?;
460 std::fs::rename(&tmp, path)
461 .map_err(|e| ConfigError::WriteConfig(format!("rename: {e}")))?;
462 Ok(())
463 }
464
465 #[test]
466 fn test_write_read_roundtrip() {
467 let tmp = tempfile::tempdir().unwrap();
468 let config_path = tmp.path().join("gogcli").join("config.json");
469
470 let mut cfg = ConfigFile::default();
471 cfg.keyring_backend = Some("file".to_string());
472 cfg.account_aliases
473 .insert("me@example.com".to_string(), "me".to_string());
474
475 write_config_to(&cfg, &config_path).expect("write_config_to failed");
476 assert!(config_path.exists(), "config.json not created");
477
478 let read_back = read_config_from(&config_path).expect("read_config_from failed");
479 assert_eq!(read_back.keyring_backend, cfg.keyring_backend);
480 assert_eq!(read_back.account_aliases, cfg.account_aliases);
481 }
482
483 #[test]
484 fn test_read_config_json5_comments() {
485 let tmp = tempfile::tempdir().unwrap();
486 let config_path = tmp.path().join("config.json");
487 std::fs::write(
489 &config_path,
490 r#"
491// this is a comment
492{
493 keyring_backend: "secret-service", // inline comment
494 default_timezone: "UTC",
495}
496"#,
497 )
498 .unwrap();
499 let cfg = read_config_from(&config_path).expect("json5 parse failed");
500 assert_eq!(cfg.keyring_backend.as_deref(), Some("secret-service"));
501 assert_eq!(cfg.default_timezone.as_deref(), Some("UTC"));
502 }
503}