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