Skip to main content

gog_core/
config.rs

1// gog-core config module
2// Ported from internal/config/{config.go,paths.go,clients.go,credentials.go}
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7use serde::{Deserialize, Serialize};
8use thiserror::Error;
9
10// ---------------------------------------------------------------------------
11// Constants
12// ---------------------------------------------------------------------------
13
14pub const APP_NAME: &str = "gogcli";
15pub const DEFAULT_CLIENT_NAME: &str = "default";
16
17// ---------------------------------------------------------------------------
18// Error type
19// ---------------------------------------------------------------------------
20
21#[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
48// ---------------------------------------------------------------------------
49// ConfigFile struct
50// ---------------------------------------------------------------------------
51
52fn 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// ---------------------------------------------------------------------------
79// ClientCredentials struct
80// ---------------------------------------------------------------------------
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct ClientCredentials {
84    pub client_id: String,
85    pub client_secret: String,
86}
87
88// Google OAuth JSON structure: { "installed": { ... } } or { "web": { ... } }
89#[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
101// ---------------------------------------------------------------------------
102// Path helpers
103// ---------------------------------------------------------------------------
104
105/// Returns `$XDG_CONFIG_HOME/gogcli` or `~/.config/gogcli`.
106pub 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
112/// Returns `config_dir()/config.json`.
113pub fn config_path() -> Result<PathBuf, ConfigError> {
114    Ok(config_dir()?.join("config.json"))
115}
116
117/// Creates the config directory if it doesn't exist, returns the path.
118pub 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
125/// Returns `config_dir()/keyring`.
126pub fn keyring_dir() -> Result<PathBuf, ConfigError> {
127    Ok(config_dir()?.join("keyring"))
128}
129
130/// Creates the keyring directory if it doesn't exist, returns the path.
131pub 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
138// ---------------------------------------------------------------------------
139// Client name normalization
140// ---------------------------------------------------------------------------
141
142/// Normalizes a client name to lowercase, allowing alphanumeric, `-`, `_`, `.`.
143/// Empty input returns an error.
144pub 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
158/// Like `normalize_client_name`, but empty/whitespace returns `"default"`.
159pub 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
166// ---------------------------------------------------------------------------
167// Credentials path
168// ---------------------------------------------------------------------------
169
170/// Returns the path to the credentials file for the given client name.
171/// `"default"` → `credentials.json`, others → `credentials-{name}.json`.
172pub 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
182// ---------------------------------------------------------------------------
183// Config read / write
184// ---------------------------------------------------------------------------
185
186/// Reads the JSON5 config file. Returns default if the file does not exist.
187pub 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
201/// Atomically writes the config file (write tmp, rename).
202pub 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
217// ---------------------------------------------------------------------------
218// Credentials read
219// ---------------------------------------------------------------------------
220
221/// Reads Google OAuth credentials for the given client.
222/// The file may contain a nested `installed` or `web` object (standard Google format),
223/// or a flat `{ client_id, client_secret }`.
224pub 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    // Try nested Google format first.
235    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    // Fall back to flat format.
248    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
259// ---------------------------------------------------------------------------
260// Path expansion
261// ---------------------------------------------------------------------------
262
263/// Expands `~` at the start of a path to the user's home directory.
264/// An empty string returns an empty `PathBuf`.
265pub 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// ---------------------------------------------------------------------------
283// Tests
284// ---------------------------------------------------------------------------
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    // -- normalize_client_name_or_default -----------------------------------
290
291    #[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    // -- expand_path --------------------------------------------------------
313
314    #[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    // -- ConfigFile ---------------------------------------------------------
341
342    #[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        // None fields and empty maps should not appear in the output.
374        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    // -- config_dir ---------------------------------------------------------
382
383    #[test]
384    fn test_config_dir_returns_path() {
385        let dir = config_dir().unwrap();
386        assert!(dir.ends_with("gogcli"));
387    }
388
389    // -- client_credentials_path_for ----------------------------------------
390
391    #[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    // -- read_config (file-system, uses temp dir via env override) ----------
416
417    #[test]
418    fn test_read_config_missing_returns_default() {
419        // With a non-existent XDG_CONFIG_HOME the dirs crate still resolves
420        // a path; if the file simply doesn't exist, read_config returns Default.
421        // We only verify the behaviour when we can control the env.
422        // For a portable check: call read_config and either get Ok(default)
423        // or get Ok(_) — we should not get a parse error.
424        let result = read_config();
425        assert!(result.is_ok(), "read_config returned error: {result:?}");
426    }
427
428    // -- write_config + read_config round-trip using a temp dir ---------------
429
430    /// Reads a ConfigFile from an arbitrary path using the same JSON5 logic as
431    /// `read_config`, but against a caller-supplied path. Used for isolated
432    /// temp-dir tests on macOS where `dirs::config_dir()` ignores env vars.
433    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    /// Writes a ConfigFile atomically to an arbitrary path.
449    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        // JSON5 allows single-line and block comments.
488        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}