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