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