Skip to main content

romm_cli/
config.rs

1//! Configuration and authentication for the ROMM client.
2//!
3//! This module is deliberately independent of any particular frontend:
4//! both the TUI and the command-line subcommands share the same `Config`
5//! and `AuthConfig` types.
6//!
7//! ## Configuration precedence
8//!
9//! Call [`load_config`] to read config:
10//!
11//! 1. Variables already set in the process environment (highest priority), including `API_TOKEN` and
12//!    paths `ROMM_TOKEN_FILE` / `API_TOKEN_FILE` (file contents used as bearer token when `API_TOKEN` is unset).
13//! 2. User `config.json` (see [`user_config_json_path`]) — fills any field **not** already set from the environment.
14//!
15//! There is **no** automatic loading of a `.env` file; set variables in your shell or process manager,
16//! or rely on `config.json` written by `romm-cli init` / the TUI setup wizard.
17//!
18//! After env + JSON merge, secrets that are still placeholders (including [`KEYRING_SECRET_PLACEHOLDER`])
19//! are resolved via the OS keyring (`keyring` crate, service name `romm-cli`). On Windows the stored
20//! credential target is typically `API_TOKEN.romm-cli`, `API_PASSWORD.romm-cli`, or `API_KEY.romm-cli`.
21//! Missing entries are silent; other keyring errors are logged at warn (never with secret values).
22//! On save, a successful store is followed by read-back verification before writing the sentinel to JSON.
23//!
24//! ## `load_config` vs `config.json`
25//!
26//! [`load_config`] merges sources **per field**: process environment wins over values from
27//! `config.json` for `API_BASE_URL`, `ROMM_ROMS_DIR`/`ROMM_DOWNLOAD_DIR`, `API_USE_HTTPS`, and auth-related
28//! fields. The keyring is used only to replace placeholder or sentinel secret strings after that merge.
29
30use std::fs;
31use std::path::PathBuf;
32
33use anyhow::{anyhow, Context, Result};
34use keyring_core::{Entry, Error as KeyringError, Result as KeyringResult};
35
36use serde::{Deserialize, Serialize};
37
38// ---------------------------------------------------------------------------
39// Types
40// ---------------------------------------------------------------------------
41
42/// Supported authentication modes for the RomM API.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub enum AuthConfig {
45    /// Basic authentication with username and password.
46    Basic {
47        /// The RomM username.
48        username: String,
49        /// The RomM password.
50        password: String,
51    },
52    /// Bearer token authentication (the standard for most API interactions).
53    Bearer {
54        /// The raw bearer token string.
55        token: String,
56    },
57    /// API Key authentication via a custom HTTP header.
58    ApiKey {
59        /// Name of the HTTP header (e.g., "X-Api-Key").
60        header: String,
61        /// The API key value.
62        key: String,
63    },
64}
65
66/// Default checked state for categories in the TUI extras picker (when each row exists).
67#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
68pub struct ExtrasDefaults {
69    /// Pre-check related ROM rows (updates/DLC) when opening the extras picker.
70    pub include_related_roms: bool,
71    /// Pre-check cover when `url_cover` is set.
72    pub include_cover: bool,
73    /// Pre-check manual when `url_manual` is set.
74    pub include_manual: bool,
75}
76
77impl Default for ExtrasDefaults {
78    fn default() -> Self {
79        Self {
80            include_related_roms: true,
81            include_cover: true,
82            include_manual: true,
83        }
84    }
85}
86
87/// Save sync preferences shared by CLI/TUI frontends.
88#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
89pub struct SaveSyncConfig {
90    /// Local folder used by TUI save download/sync flows.
91    #[serde(default)]
92    pub save_dir: Option<String>,
93    /// RomM sync device id used by manual push-pull.
94    #[serde(default)]
95    pub device_id: Option<String>,
96}
97
98/// High-level configuration for the `romm-cli` application.
99///
100/// This struct holds the connection details and authentication settings
101/// required to communicate with a RomM server.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103pub struct Config {
104    /// The base URL (origin) of the RomM server (e.g., "<https://romm.example.com>").
105    pub base_url: String,
106    /// Local directory where ROMs should be downloaded.
107    pub download_dir: String,
108    /// Whether to force HTTPS for API calls.
109    pub use_https: bool,
110    /// Active authentication configuration, if any.
111    pub auth: Option<AuthConfig>,
112    /// TUI extras picker: which categories start checked when rows exist.
113    #[serde(default)]
114    pub extras_defaults: ExtrasDefaults,
115    /// TUI save-management settings.
116    #[serde(default)]
117    pub save_sync: SaveSyncConfig,
118}
119
120pub fn resolved_save_dir(config: &Config) -> PathBuf {
121    config
122        .save_sync
123        .save_dir
124        .as_deref()
125        .map(str::trim)
126        .filter(|s| !s.is_empty())
127        .map(PathBuf::from)
128        .unwrap_or_else(|| PathBuf::from(&config.download_dir).join("saves"))
129}
130
131fn is_placeholder(value: &str) -> bool {
132    value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
133}
134
135/// Written to `config.json` when the real secret is stored in the OS keyring (`persist_user_config`).
136pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
137
138/// Returns true if `s` is the sentinel written to disk when the secret lives in the keyring.
139pub fn is_keyring_placeholder(s: &str) -> bool {
140    s == KEYRING_SECRET_PLACEHOLDER
141}
142
143/// Normalizes a RomM site URL by trimming whitespace, trailing slashes, and removing the `/api` suffix.
144///
145/// # Examples
146///
147/// ```
148/// # use romm_cli::config::normalize_romm_origin;
149/// assert_eq!(normalize_romm_origin("http://localhost:8080/api/"), "http://localhost:8080");
150/// assert_eq!(normalize_romm_origin(" https://romm.example.com "), "https://romm.example.com");
151/// ```
152pub fn normalize_romm_origin(url: &str) -> String {
153    let mut s = url.trim().trim_end_matches('/').to_string();
154    if s.ends_with("/api") {
155        s.truncate(s.len() - 4);
156    }
157    s.trim_end_matches('/').to_string()
158}
159
160// ---------------------------------------------------------------------------
161// Keyring helpers
162// ---------------------------------------------------------------------------
163
164const KEYRING_SERVICE: &str = "romm-cli";
165
166/// Store a secret in the OS keyring under the `romm-cli` service name.
167///
168/// This is used to securely persist passwords, tokens, and API keys without
169/// writing them in plaintext to `config.json`.
170pub fn keyring_store(key: &str, value: &str) -> Result<()> {
171    let entry =
172        Entry::new(KEYRING_SERVICE, key).map_err(|e| anyhow!("keyring entry error: {e}"))?;
173    entry
174        .set_password(value)
175        .map_err(|e| anyhow!("keyring set error: {e}"))
176}
177
178/// Map `get_password` result: [`keyring::Error::NoEntry`] is normal when no credential exists (no log).
179/// Other errors are logged (never logs secret bytes).
180fn keyring_get_password_result(key: &str, result: KeyringResult<String>) -> Option<String> {
181    match result {
182        Ok(s) => Some(s),
183        Err(KeyringError::NoEntry) => None,
184        Err(e) => {
185            tracing::warn!("keyring get_password for key {key}: {e}");
186            None
187        }
188    }
189}
190
191/// Retrieve a secret from the OS keyring, returning `None` if not found or on error.
192///
193/// Unexpected errors are logged at the `warn` level.
194pub(crate) fn keyring_get(key: &str) -> Option<String> {
195    let entry = match Entry::new(KEYRING_SERVICE, key) {
196        Ok(e) => e,
197        Err(e) => {
198            tracing::warn!("keyring Entry::new for key {key}: {e}");
199            return None;
200        }
201    };
202    keyring_get_password_result(key, entry.get_password())
203}
204
205/// After a successful `set_password`, confirm read-back matches `expected`.
206/// If not, the caller should keep plaintext in JSON to avoid data loss.
207fn keyring_verify_read_back_matches(key: &str, expected: &str) -> bool {
208    let entry = match Entry::new(KEYRING_SERVICE, key) {
209        Ok(e) => e,
210        Err(e) => {
211            tracing::warn!(
212                "keyring verify: Entry::new for key {key} after successful store: {e}; writing plaintext to config.json"
213            );
214            return false;
215        }
216    };
217    match entry.get_password() {
218        Ok(read) if read == expected => true,
219        Ok(_) => {
220            tracing::warn!(
221                "keyring verify: read-back for key {key} did not match; writing plaintext to config.json"
222            );
223            false
224        }
225        Err(e) => {
226            tracing::warn!(
227                "keyring verify: get_password for key {key} after successful store: {e}; writing plaintext to config.json"
228            );
229            false
230        }
231    }
232}
233
234// ---------------------------------------------------------------------------
235// Paths
236// ---------------------------------------------------------------------------
237
238/// Directory for user-level config (`romm-cli` under the OS config dir).
239pub fn user_config_dir() -> Option<PathBuf> {
240    if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
241        return Some(PathBuf::from(dir));
242    }
243    dirs::config_dir().map(|d| d.join("romm-cli"))
244}
245
246/// Path to the user-level `config.json` file (`.../romm-cli/config.json`).
247pub fn user_config_json_path() -> Option<PathBuf> {
248    user_config_dir().map(|d| d.join("config.json"))
249}
250
251/// Reads `config.json` from disk only (no env merge, no keyring resolution).
252/// Used by the TUI setup wizard to detect `<stored-in-keyring>` placeholders.
253pub fn read_user_config_json_from_disk() -> Option<Config> {
254    let path = user_config_json_path()?;
255    let content = std::fs::read_to_string(path).ok()?;
256    serde_json::from_str(&content).ok()
257}
258
259/// Auth to pass to [`persist_user_config`] when saving non-auth fields (e.g. TUI Settings).
260///
261/// Prefer the in-memory [`Config::auth`]. If it is `None` (e.g. [`load_config`] could not read the
262/// token from the keyring), reuse `auth` from [`read_user_config_json_from_disk`] so we do not
263/// overwrite `config.json` with `"auth": null` while the file still held a bearer sentinel.
264pub fn auth_for_persist_merge(in_memory: Option<AuthConfig>) -> Option<AuthConfig> {
265    in_memory.or_else(|| read_user_config_json_from_disk().and_then(|c| c.auth))
266}
267
268/// Where the OpenAPI spec is cached (`.../romm-cli/openapi.json`).
269///
270/// Override with `ROMM_OPENAPI_PATH` (absolute or relative path).
271pub fn openapi_cache_path() -> Result<PathBuf> {
272    if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
273        return Ok(PathBuf::from(p));
274    }
275    let dir = user_config_dir().ok_or_else(|| {
276        anyhow!("Could not resolve config directory. Set ROMM_OPENAPI_PATH to store openapi.json.")
277    })?;
278    Ok(dir.join("openapi.json"))
279}
280
281// ---------------------------------------------------------------------------
282// Loading
283// ---------------------------------------------------------------------------
284
285fn env_nonempty(key: &str) -> Option<String> {
286    std::env::var(key).ok().filter(|s| !s.trim().is_empty())
287}
288
289/// Returns true if the application should check for updates on startup.
290///
291/// This is controlled by the `ROMM_CHECK_UPDATES` environment variable.
292/// Defaults to `true`.
293pub fn should_check_updates() -> bool {
294    match std::env::var("ROMM_CHECK_UPDATES") {
295        Ok(value) => {
296            let normalized = value.trim().to_ascii_lowercase();
297            !matches!(normalized.as_str(), "0" | "false" | "no" | "off")
298        }
299        Err(_) => true,
300    }
301}
302
303/// Max bytes read from bearer token files (`ROMM_TOKEN_FILE` / `API_TOKEN_FILE`).
304const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
305
306/// Bearer token: `API_TOKEN` env, else UTF-8 file at `ROMM_TOKEN_FILE` or `API_TOKEN_FILE` path.
307fn token_from_env_or_file() -> Result<Option<String>> {
308    if let Some(t) = env_nonempty("API_TOKEN") {
309        return Ok(Some(t));
310    }
311    let path = env_nonempty("ROMM_TOKEN_FILE").or_else(|| env_nonempty("API_TOKEN_FILE"));
312    let Some(path) = path else {
313        return Ok(None);
314    };
315    let path = path.trim();
316    let bytes = fs::read(path).with_context(|| format!("read bearer token file {path}"))?;
317    if bytes.len() > MAX_TOKEN_FILE_BYTES {
318        return Err(anyhow!(
319            "bearer token file exceeds max size of {} bytes",
320            MAX_TOKEN_FILE_BYTES
321        ));
322    }
323    let s = String::from_utf8(bytes)
324        .map_err(|e| anyhow!("bearer token file must be valid UTF-8: {e}"))?;
325    let t = s.trim();
326    if t.is_empty() {
327        return Err(anyhow!(
328            "bearer token file is empty after trimming whitespace"
329        ));
330    }
331    Ok(Some(t.to_string()))
332}
333
334/// Returns true when [`load_config`] has no resolved [`AuthConfig::Bearer`] (etc.) but `config.json`
335/// on disk still contains [`KEYRING_SECRET_PLACEHOLDER`] (OS keyring could not supply the secret).
336pub fn disk_has_unresolved_keyring_sentinel(config: &Config) -> bool {
337    if config.auth.is_some() {
338        return false;
339    }
340    let Some(disk) = read_user_config_json_from_disk() else {
341        return false;
342    };
343    match &disk.auth {
344        Some(AuthConfig::Bearer { token }) => is_keyring_placeholder(token),
345        Some(AuthConfig::Basic { password, .. }) => is_keyring_placeholder(password),
346        Some(AuthConfig::ApiKey { key, .. }) => is_keyring_placeholder(key),
347        None => false,
348    }
349}
350
351/// Loads the merged configuration from the process environment, `config.json`, and the OS keyring.
352///
353/// This function handles the precedence of configuration sources:
354/// 1. Environment variables (highest priority).
355/// 2. `config.json` file.
356/// 3. OS Keyring (for secrets).
357///
358/// # Errors
359///
360/// Returns an error if `API_BASE_URL` is not set or if there are issues reading token files.
361pub fn load_config() -> Result<Config> {
362    // 1. Load from JSON first (if it exists)
363    let mut json_config = None;
364    if let Some(path) = user_config_json_path() {
365        if path.is_file() {
366            if let Ok(content) = std::fs::read_to_string(&path) {
367                if let Ok(config) = serde_json::from_str::<Config>(&content) {
368                    json_config = Some(config);
369                }
370            }
371        }
372    }
373
374    // 2. Resolve base_url
375    let base_raw = env_nonempty("API_BASE_URL")
376        .or_else(|| json_config.as_ref().map(|c| c.base_url.clone()))
377        .ok_or_else(|| {
378            anyhow!(
379                "API_BASE_URL is not set. Set it in the environment, a config.json file, or run: romm-cli init"
380            )
381        })?;
382    let mut base_url = normalize_romm_origin(&base_raw);
383
384    // 3. Resolve ROM storage directory
385    let download_dir = env_nonempty("ROMM_ROMS_DIR")
386        .or_else(|| env_nonempty("ROMM_DOWNLOAD_DIR"))
387        .or_else(|| json_config.as_ref().map(|c| c.download_dir.clone()))
388        .unwrap_or_else(|| {
389            dirs::download_dir()
390                .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
391                .join("romm-cli")
392                .display()
393                .to_string()
394        });
395
396    // 4. Resolve use_https
397    let use_https = if let Ok(s) = std::env::var("API_USE_HTTPS") {
398        s.to_lowercase() == "true"
399    } else if let Some(c) = &json_config {
400        c.use_https
401    } else {
402        true
403    };
404
405    if use_https && base_url.starts_with("http://") {
406        base_url = base_url.replace("http://", "https://");
407    }
408
409    // 5. Resolve Auth
410    let mut username = env_nonempty("API_USERNAME");
411    let mut password = env_nonempty("API_PASSWORD");
412    let mut token = token_from_env_or_file()?;
413    let mut api_key = env_nonempty("API_KEY");
414    let mut api_key_header = env_nonempty("API_KEY_HEADER");
415
416    if let Some(c) = &json_config {
417        if let Some(auth) = &c.auth {
418            match auth {
419                AuthConfig::Basic {
420                    username: u,
421                    password: p,
422                } => {
423                    if username.is_none() {
424                        username = Some(u.clone());
425                    }
426                    if password.is_none() {
427                        password = Some(p.clone());
428                    }
429                }
430                AuthConfig::Bearer { token: t } => {
431                    if token.is_none() {
432                        token = Some(t.clone());
433                    }
434                }
435                AuthConfig::ApiKey { header: h, key: k } => {
436                    if api_key_header.is_none() {
437                        api_key_header = Some(h.clone());
438                    }
439                    if api_key.is_none() {
440                        api_key = Some(k.clone());
441                    }
442                }
443            }
444        }
445    }
446
447    // Resolve placeholders from keyring (including disk sentinel `<stored-in-keyring>`).
448    if let Some(p) = &password {
449        if is_placeholder(p) || is_keyring_placeholder(p) {
450            if let Some(k) = keyring_get("API_PASSWORD") {
451                password = Some(k);
452            }
453        }
454    } else {
455        password = keyring_get("API_PASSWORD");
456    }
457
458    if let Some(t) = &token {
459        if is_placeholder(t) || is_keyring_placeholder(t) {
460            if let Some(k) = keyring_get("API_TOKEN") {
461                token = Some(k);
462            }
463        }
464    } else {
465        token = keyring_get("API_TOKEN");
466    }
467
468    if let Some(k) = &api_key {
469        if is_placeholder(k) || is_keyring_placeholder(k) {
470            if let Some(kr) = keyring_get("API_KEY") {
471                api_key = Some(kr);
472            }
473        }
474    } else {
475        api_key = keyring_get("API_KEY");
476    }
477
478    if let Some(ref p) = password {
479        if is_keyring_placeholder(p) {
480            tracing::warn!(
481                "Could not read API_PASSWORD from the OS keyring; value is still <stored-in-keyring>. \
482                 On Windows, look for a Generic credential with target API_PASSWORD.romm-cli."
483            );
484        }
485    }
486    if let Some(ref t) = token {
487        if is_keyring_placeholder(t) {
488            tracing::warn!(
489                "Could not read API_TOKEN from the OS keyring; value is still <stored-in-keyring>. \
490                 On Windows, look for a Generic credential with target API_TOKEN.romm-cli."
491            );
492        }
493    }
494    if let Some(ref k) = api_key {
495        if is_keyring_placeholder(k) {
496            tracing::warn!(
497                "Could not read API_KEY from the OS keyring; value is still <stored-in-keyring>. \
498                 On Windows, look for a Generic credential with target API_KEY.romm-cli."
499            );
500        }
501    }
502
503    let auth = if let (Some(user), Some(pass)) = (username, password) {
504        if !is_placeholder(&pass) && !is_keyring_placeholder(&pass) {
505            Some(AuthConfig::Basic {
506                username: user,
507                password: pass,
508            })
509        } else {
510            None
511        }
512    } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
513        if !is_placeholder(&key) && !is_keyring_placeholder(&key) {
514            Some(AuthConfig::ApiKey { header, key })
515        } else {
516            None
517        }
518    } else if let Some(tok) = token {
519        if !is_placeholder(&tok) && !is_keyring_placeholder(&tok) {
520            Some(AuthConfig::Bearer { token: tok })
521        } else {
522            None
523        }
524    } else {
525        None
526    };
527
528    let extras_defaults = json_config
529        .as_ref()
530        .map(|c| c.extras_defaults.clone())
531        .unwrap_or_default();
532    let save_sync = json_config
533        .as_ref()
534        .map(|c| c.save_sync.clone())
535        .unwrap_or_default();
536
537    Ok(Config {
538        base_url,
539        download_dir,
540        use_https,
541        auth,
542        extras_defaults,
543        save_sync,
544    })
545}
546
547/// Persists the user configuration to `config.json` and stores secrets in the OS keyring.
548///
549/// This function will:
550/// 1. Create the configuration directory if it doesn't exist.
551/// 2. Store secrets (password, token, API key) in the OS keyring.
552/// 3. Write non-secret configuration to `config.json`.
553/// 4. On Unix, set file permissions to 0600 (owner read/write only).
554///
555/// If a secret cannot be stored in the keyring, it is written in plaintext to `config.json`
556/// as a fallback, and a warning is logged.
557pub fn persist_user_config(config: &Config) -> Result<()> {
558    let Some(path) = user_config_json_path() else {
559        return Err(anyhow!(
560            "Could not determine config directory (no HOME / APPDATA?)."
561        ));
562    };
563    let dir = path
564        .parent()
565        .ok_or_else(|| anyhow!("invalid config path"))?;
566    std::fs::create_dir_all(dir).with_context(|| format!("create {}", dir.display()))?;
567
568    let mut config_to_save = config.clone();
569
570    match &mut config_to_save.auth {
571        None => {}
572        Some(AuthConfig::Basic { password, .. }) => {
573            if is_keyring_placeholder(password) {
574                tracing::debug!(
575                    "skip keyring store for API_PASSWORD: value is keyring sentinel; leaving disk sentinel unchanged"
576                );
577            } else if let Err(e) = keyring_store("API_PASSWORD", password) {
578                tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to config.json");
579            } else if keyring_verify_read_back_matches("API_PASSWORD", password.as_str()) {
580                *password = KEYRING_SECRET_PLACEHOLDER.to_string();
581            }
582        }
583        Some(AuthConfig::Bearer { token }) => {
584            if is_keyring_placeholder(token) {
585                tracing::debug!(
586                    "skip keyring store for API_TOKEN: value is keyring sentinel; leaving disk sentinel unchanged"
587                );
588            } else if let Err(e) = keyring_store("API_TOKEN", token) {
589                tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to config.json");
590            } else if keyring_verify_read_back_matches("API_TOKEN", token.as_str()) {
591                *token = KEYRING_SECRET_PLACEHOLDER.to_string();
592            }
593        }
594        Some(AuthConfig::ApiKey { key, .. }) => {
595            if is_keyring_placeholder(key) {
596                tracing::debug!(
597                    "skip keyring store for API_KEY: value is keyring sentinel; leaving disk sentinel unchanged"
598                );
599            } else if let Err(e) = keyring_store("API_KEY", key) {
600                tracing::warn!("keyring store API_KEY: {e}; writing plaintext to config.json");
601            } else if keyring_verify_read_back_matches("API_KEY", key.as_str()) {
602                *key = KEYRING_SECRET_PLACEHOLDER.to_string();
603            }
604        }
605    }
606
607    let content = serde_json::to_string_pretty(&config_to_save)?;
608    {
609        use std::io::Write;
610        let mut f =
611            std::fs::File::create(&path).with_context(|| format!("write {}", path.display()))?;
612        f.write_all(content.as_bytes())?;
613    }
614
615    #[cfg(unix)]
616    {
617        use std::os::unix::fs::PermissionsExt;
618        let mut perms = std::fs::metadata(&path)?.permissions();
619        perms.set_mode(0o600);
620        std::fs::set_permissions(&path, perms)?;
621    }
622
623    Ok(())
624}
625
626/// Deletes the config.json file and clears the secrets from the OS keyring.
627pub fn reset_all_settings() -> Result<()> {
628    if let Some(path) = user_config_json_path() {
629        if path.exists() {
630            let _ = std::fs::remove_file(&path);
631        }
632    }
633    for key in ["API_PASSWORD", "API_TOKEN", "API_KEY"] {
634        if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
635            let _ = entry.delete_credential();
636        }
637    }
638    Ok(())
639}
640
641#[cfg(test)]
642pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
643    use std::sync::{Mutex, OnceLock};
644    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
645    LOCK.get_or_init(|| Mutex::new(()))
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use std::sync::MutexGuard;
652
653    #[test]
654    fn keyring_get_password_result_ok() {
655        assert_eq!(
656            super::keyring_get_password_result("API_TOKEN", Ok("secret".into())),
657            Some("secret".into())
658        );
659    }
660
661    #[test]
662    fn keyring_get_password_result_no_entry_is_none() {
663        assert_eq!(
664            super::keyring_get_password_result("API_TOKEN", Err(KeyringError::NoEntry)),
665            None
666        );
667    }
668
669    struct TestEnv {
670        _guard: MutexGuard<'static, ()>,
671        config_dir: PathBuf,
672    }
673
674    impl TestEnv {
675        fn new() -> Self {
676            let guard = super::test_env_lock()
677                .lock()
678                .unwrap_or_else(|e| e.into_inner());
679            clear_auth_env();
680
681            let ts = std::time::SystemTime::now()
682                .duration_since(std::time::UNIX_EPOCH)
683                .unwrap()
684                .as_nanos();
685            let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
686            std::fs::create_dir_all(&config_dir).unwrap();
687            std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
688
689            Self {
690                _guard: guard,
691                config_dir,
692            }
693        }
694    }
695
696    impl Drop for TestEnv {
697        fn drop(&mut self) {
698            clear_auth_env();
699            std::env::remove_var("ROMM_TEST_CONFIG_DIR");
700            let _ = std::fs::remove_dir_all(&self.config_dir);
701        }
702    }
703
704    fn clear_auth_env() {
705        for key in [
706            "API_BASE_URL",
707            "ROMM_ROMS_DIR",
708            "API_USERNAME",
709            "API_PASSWORD",
710            "API_TOKEN",
711            "ROMM_TOKEN_FILE",
712            "API_TOKEN_FILE",
713            "API_KEY",
714            "API_KEY_HEADER",
715            "API_USE_HTTPS",
716            "ROMM_TEST_CONFIG_DIR",
717        ] {
718            std::env::remove_var(key);
719        }
720    }
721
722    #[test]
723    fn prefers_basic_auth_over_other_modes() {
724        let _env = TestEnv::new();
725        std::env::set_var("API_BASE_URL", "http://example.test");
726        std::env::set_var("API_USERNAME", "user");
727        std::env::set_var("API_PASSWORD", "pass");
728        std::env::set_var("API_TOKEN", "token");
729        std::env::set_var("API_KEY", "apikey");
730        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
731
732        let cfg = load_config().expect("config should load");
733        match cfg.auth {
734            Some(AuthConfig::Basic { username, password }) => {
735                assert_eq!(username, "user");
736                assert_eq!(password, "pass");
737            }
738            _ => panic!("expected basic auth"),
739        }
740    }
741
742    #[test]
743    fn uses_api_key_header_when_token_missing() {
744        let _env = TestEnv::new();
745        std::env::set_var("API_BASE_URL", "http://example.test");
746        std::env::set_var("API_KEY", "real-key");
747        std::env::set_var("API_KEY_HEADER", "X-Api-Key");
748
749        let cfg = load_config().expect("config should load");
750        match cfg.auth {
751            Some(AuthConfig::ApiKey { header, key }) => {
752                assert_eq!(header, "X-Api-Key");
753                assert_eq!(key, "real-key");
754            }
755            _ => panic!("expected api key auth"),
756        }
757    }
758
759    #[test]
760    fn normalizes_api_base_url_and_enforces_https_by_default() {
761        let _env = TestEnv::new();
762        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
763        let cfg = load_config().expect("config");
764        // Upgraded to https by default
765        assert_eq!(cfg.base_url, "https://romm.example");
766    }
767
768    #[test]
769    fn does_not_enforce_https_if_toggle_is_false() {
770        let _env = TestEnv::new();
771        std::env::set_var("API_BASE_URL", "http://romm.example/api/");
772        std::env::set_var("API_USE_HTTPS", "false");
773        let cfg = load_config().expect("config");
774        assert_eq!(cfg.base_url, "http://romm.example");
775    }
776
777    #[test]
778    fn normalize_romm_origin_trims_and_strips_api_suffix() {
779        assert_eq!(
780            normalize_romm_origin("http://localhost:8080/api/"),
781            "http://localhost:8080"
782        );
783        assert_eq!(
784            normalize_romm_origin("https://x.example"),
785            "https://x.example"
786        );
787    }
788
789    #[test]
790    fn empty_api_username_does_not_enable_basic() {
791        let _env = TestEnv::new();
792        std::env::set_var("API_BASE_URL", "http://example.test");
793        std::env::set_var("API_USERNAME", "");
794        std::env::set_var("API_PASSWORD", "secret");
795
796        let cfg = load_config().expect("config should load");
797        assert!(
798            cfg.auth.is_none(),
799            "empty API_USERNAME should not pair with password for Basic"
800        );
801    }
802
803    #[test]
804    fn ignores_placeholder_bearer_token() {
805        let _env = TestEnv::new();
806        std::env::set_var("API_BASE_URL", "http://example.test");
807        std::env::set_var("API_TOKEN", "your-bearer-token-here");
808
809        let cfg = load_config().expect("config should load");
810        assert!(cfg.auth.is_none(), "placeholder token should be ignored");
811    }
812
813    #[test]
814    fn loads_from_user_json_file() {
815        let env = TestEnv::new();
816        let config_json = r#"{
817            "base_url": "http://from-json-file.test",
818            "download_dir": "/tmp/downloads",
819            "use_https": false,
820            "auth": null
821        }"#;
822
823        std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
824
825        let cfg = load_config().expect("load from user config.json");
826        assert_eq!(cfg.base_url, "http://from-json-file.test");
827        assert_eq!(cfg.download_dir, "/tmp/downloads");
828        assert!(!cfg.use_https);
829    }
830
831    #[test]
832    fn extras_defaults_default_to_all_true_when_missing_from_json() {
833        let config_json = r#"{
834            "base_url": "http://from-json-file.test",
835            "download_dir": "/tmp/downloads",
836            "use_https": false,
837            "auth": null
838        }"#;
839        let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
840        assert!(cfg.extras_defaults.include_related_roms);
841        assert!(cfg.extras_defaults.include_cover);
842        assert!(cfg.extras_defaults.include_manual);
843    }
844
845    #[test]
846    fn save_sync_defaults_when_missing_from_legacy_json() {
847        let config_json = r#"{
848            "base_url": "http://from-json-file.test",
849            "download_dir": "/tmp/downloads",
850            "use_https": false,
851            "auth": null
852        }"#;
853        let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
854        assert_eq!(cfg.save_sync, SaveSyncConfig::default());
855    }
856
857    #[test]
858    fn resolved_save_dir_falls_back_to_download_dir_saves() {
859        let cfg = Config {
860            base_url: "http://example.test".into(),
861            download_dir: "/roms".into(),
862            use_https: false,
863            auth: None,
864            extras_defaults: ExtrasDefaults::default(),
865            save_sync: SaveSyncConfig::default(),
866        };
867        assert_eq!(
868            resolved_save_dir(&cfg),
869            PathBuf::from("/roms").join("saves")
870        );
871    }
872
873    #[test]
874    fn roms_dir_env_takes_precedence_over_legacy_download_dir_env() {
875        let _env = TestEnv::new();
876        std::env::set_var("API_BASE_URL", "http://example.test");
877        std::env::set_var("ROMM_ROMS_DIR", "/preferred-roms");
878        std::env::set_var("ROMM_DOWNLOAD_DIR", "/legacy-downloads");
879
880        let cfg = load_config().expect("config should load");
881        assert_eq!(cfg.download_dir, "/preferred-roms");
882    }
883
884    #[test]
885    fn auth_for_persist_merge_prefers_in_memory() {
886        let env = TestEnv::new();
887        let on_disk = r#"{
888            "base_url": "http://disk.test",
889            "download_dir": "/tmp",
890            "use_https": false,
891            "auth": { "Bearer": { "token": "from-disk" } }
892        }"#;
893        std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
894
895        let mem = Some(AuthConfig::Bearer {
896            token: "from-memory".into(),
897        });
898        let merged = auth_for_persist_merge(mem.clone());
899        assert_eq!(format!("{:?}", merged), format!("{:?}", mem));
900    }
901
902    #[test]
903    fn auth_for_persist_merge_falls_back_to_disk_when_memory_empty() {
904        let env = TestEnv::new();
905        let on_disk = r#"{
906            "base_url": "http://disk.test",
907            "download_dir": "/tmp",
908            "use_https": false,
909            "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
910        }"#;
911        std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
912
913        let merged = auth_for_persist_merge(None);
914        match merged {
915            Some(AuthConfig::Bearer { token }) => {
916                assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
917            }
918            _ => panic!("expected bearer auth from disk"),
919        }
920    }
921
922    #[test]
923    fn bearer_keyring_sentinel_without_keyring_entry_yields_no_auth() {
924        let env = TestEnv::new();
925        std::env::set_var("API_BASE_URL", "http://example.test");
926        let config_json = r#"{
927            "base_url": "http://example.test",
928            "download_dir": "/tmp",
929            "use_https": false,
930            "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
931        }"#;
932        std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
933
934        let cfg = load_config().expect("load");
935        assert!(
936            cfg.auth.is_none(),
937            "unresolved keyring sentinel must not become Bearer auth in Config"
938        );
939        assert!(disk_has_unresolved_keyring_sentinel(&cfg));
940    }
941
942    #[test]
943    fn bearer_token_from_romm_token_file() {
944        let env = TestEnv::new();
945        let token_path = env.config_dir.join("secret.token");
946        std::fs::write(&token_path, "  tok-from-file\n").unwrap();
947        std::env::set_var("API_BASE_URL", "http://example.test");
948        std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
949
950        let cfg = load_config().expect("load");
951        match cfg.auth {
952            Some(AuthConfig::Bearer { token }) => assert_eq!(token, "tok-from-file"),
953            _ => panic!("expected bearer from token file"),
954        }
955    }
956
957    #[test]
958    fn api_token_env_wins_over_token_file() {
959        let env = TestEnv::new();
960        let token_path = env.config_dir.join("secret.token");
961        std::fs::write(&token_path, "from-file").unwrap();
962        std::env::set_var("API_BASE_URL", "http://example.test");
963        std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
964        std::env::set_var("API_TOKEN", "from-env");
965
966        let cfg = load_config().expect("load");
967        match cfg.auth {
968            Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-env"),
969            _ => panic!("expected env API_TOKEN to win"),
970        }
971    }
972
973    #[test]
974    fn romm_token_file_overrides_json_bearer() {
975        let env = TestEnv::new();
976        let token_path = env.config_dir.join("secret.token");
977        std::fs::write(&token_path, "from-file").unwrap();
978        std::env::set_var("API_BASE_URL", "http://example.test");
979        std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
980        let config_json = r#"{
981            "base_url": "http://example.test",
982            "download_dir": "/tmp",
983            "use_https": false,
984            "auth": { "Bearer": { "token": "from-json" } }
985        }"#;
986        std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
987
988        let cfg = load_config().expect("load");
989        match cfg.auth {
990            Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-file"),
991            _ => panic!("expected token file to override json"),
992        }
993    }
994
995    #[test]
996    fn romm_token_file_missing_errors() {
997        let env = TestEnv::new();
998        let missing = env.config_dir.join("this-token-file-does-not-exist");
999        std::env::set_var("API_BASE_URL", "http://example.test");
1000        std::env::set_var("ROMM_TOKEN_FILE", missing.to_str().unwrap());
1001
1002        let err = load_config().expect_err("missing token file should error");
1003        let msg = format!("{err:#}");
1004        assert!(
1005            msg.contains("read bearer token file"),
1006            "unexpected error: {msg}"
1007        );
1008    }
1009
1010    #[test]
1011    fn romm_token_file_empty_errors() {
1012        let env = TestEnv::new();
1013        let token_path = env.config_dir.join("empty.token");
1014        std::fs::write(&token_path, "   \n\t  ").unwrap();
1015        std::env::set_var("API_BASE_URL", "http://example.test");
1016        std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1017
1018        let err = load_config().expect_err("empty token file should error");
1019        assert!(
1020            format!("{err:#}").contains("empty"),
1021            "unexpected error: {err:#}"
1022        );
1023    }
1024
1025    #[test]
1026    fn romm_token_file_too_large_errors() {
1027        let env = TestEnv::new();
1028        let token_path = env.config_dir.join("huge.token");
1029        std::fs::write(&token_path, vec![b'a'; MAX_TOKEN_FILE_BYTES + 1]).unwrap();
1030        std::env::set_var("API_BASE_URL", "http://example.test");
1031        std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1032
1033        let err = load_config().expect_err("oversized token file should error");
1034        assert!(
1035            format!("{err:#}").contains("max size"),
1036            "unexpected error: {err:#}"
1037        );
1038    }
1039
1040    /// When auth is merged from disk as [`KEYRING_SECRET_PLACEHOLDER`], persist must not call
1041    /// `keyring_store` with that literal (would overwrite the real vault entry). JSON should still
1042    /// contain the sentinel and updated non-auth fields.
1043    #[test]
1044    fn persist_user_config_preserves_sentinel_secrets_in_json() {
1045        let env = TestEnv::new();
1046        let path = env.config_dir.join("config.json");
1047
1048        persist_user_config(&Config {
1049            base_url: "https://updated.example".into(),
1050            download_dir: "/var/romm-dl".into(),
1051            use_https: true,
1052            auth: Some(AuthConfig::Bearer {
1053                token: KEYRING_SECRET_PLACEHOLDER.to_string(),
1054            }),
1055            extras_defaults: ExtrasDefaults::default(),
1056            save_sync: SaveSyncConfig::default(),
1057        })
1058        .expect("persist bearer sentinel");
1059
1060        let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1061        assert_eq!(cfg.base_url, "https://updated.example");
1062        assert_eq!(cfg.download_dir, "/var/romm-dl");
1063        assert!(cfg.use_https);
1064        match cfg.auth {
1065            Some(AuthConfig::Bearer { token }) => {
1066                assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1067            }
1068            _ => panic!("expected bearer sentinel preserved in config.json"),
1069        }
1070
1071        persist_user_config(&Config {
1072            base_url: "https://apikey.example".into(),
1073            download_dir: "/dl".into(),
1074            use_https: false,
1075            auth: Some(AuthConfig::ApiKey {
1076                header: "X-Api-Key".into(),
1077                key: KEYRING_SECRET_PLACEHOLDER.to_string(),
1078            }),
1079            extras_defaults: ExtrasDefaults::default(),
1080            save_sync: SaveSyncConfig::default(),
1081        })
1082        .expect("persist api key sentinel");
1083
1084        let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1085        assert_eq!(cfg.base_url, "https://apikey.example");
1086        match cfg.auth {
1087            Some(AuthConfig::ApiKey { header, key }) => {
1088                assert_eq!(header, "X-Api-Key");
1089                assert_eq!(key, KEYRING_SECRET_PLACEHOLDER);
1090            }
1091            _ => panic!("expected api key sentinel preserved"),
1092        }
1093
1094        persist_user_config(&Config {
1095            base_url: "https://basic.example".into(),
1096            download_dir: "/dl".into(),
1097            use_https: true,
1098            auth: Some(AuthConfig::Basic {
1099                username: "alice".into(),
1100                password: KEYRING_SECRET_PLACEHOLDER.to_string(),
1101            }),
1102            extras_defaults: ExtrasDefaults::default(),
1103            save_sync: SaveSyncConfig::default(),
1104        })
1105        .expect("persist basic password sentinel");
1106
1107        let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1108        assert_eq!(cfg.base_url, "https://basic.example");
1109        match cfg.auth {
1110            Some(AuthConfig::Basic { username, password }) => {
1111                assert_eq!(username, "alice");
1112                assert_eq!(password, KEYRING_SECRET_PLACEHOLDER);
1113            }
1114            _ => panic!("expected basic password sentinel preserved"),
1115        }
1116    }
1117
1118    #[test]
1119    fn should_check_updates_defaults_true_and_honors_false_values() {
1120        let _env = TestEnv::new();
1121        std::env::remove_var("ROMM_CHECK_UPDATES");
1122        assert!(should_check_updates());
1123
1124        for value in ["false", "FALSE", "0", "no", "off"] {
1125            std::env::set_var("ROMM_CHECK_UPDATES", value);
1126            assert!(
1127                !should_check_updates(),
1128                "expected ROMM_CHECK_UPDATES={value} to disable checks"
1129            );
1130        }
1131
1132        std::env::set_var("ROMM_CHECK_UPDATES", "true");
1133        assert!(should_check_updates());
1134    }
1135}