Skip to main content

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