1use 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#[derive(Clone, Serialize, Deserialize)]
66pub enum AuthConfig {
67 Basic {
69 username: String,
71 password: String,
73 },
74 Bearer {
76 token: String,
78 },
79 ApiKey {
81 header: String,
83 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
109pub struct ExtrasDefaults {
110 pub include_related_roms: bool,
112 pub include_cover: bool,
114 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
128pub const LIBRARY_LEFT_PANEL_PERCENT_DEFAULT: u16 = 30;
130pub const LIBRARY_LEFT_PANEL_PERCENT_MIN: u16 = 15;
132pub const LIBRARY_LEFT_PANEL_PERCENT_MAX: u16 = 50;
134
135pub const GAME_DETAIL_COVER_PANEL_WIDTH_DEFAULT: u16 = 42;
137pub const GAME_DETAIL_COVER_PANEL_WIDTH_MIN: u16 = 20;
139pub 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152pub struct TuiLayoutConfig {
153 #[serde(default = "default_library_left_panel_percent")]
155 pub library_left_panel_percent: u16,
156 #[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 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#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
188#[serde(rename_all = "lowercase")]
189enum LegacyRomsLayoutMode {
190 #[default]
191 Auto,
192 Manual,
193}
194
195#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
200pub struct RomsLayoutConfig {
201 #[serde(default, skip_serializing, rename = "mode")]
203 _legacy_mode: Option<LegacyRomsLayoutMode>,
204 #[serde(default)]
206 pub platform_dirs: HashMap<u64, String>,
207}
208
209#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
214pub struct SaveSyncConfig {
215 #[serde(default)]
217 pub save_dir: Option<String>,
218 #[serde(default)]
220 pub device_id: Option<String>,
221 #[serde(default)]
223 pub platform_dirs: HashMap<u64, String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
231pub struct Config {
232 pub base_url: String,
234 pub download_dir: String,
236 pub use_https: bool,
238 pub auth: Option<AuthConfig>,
240 #[serde(default)]
242 pub extras_defaults: ExtrasDefaults,
243 #[serde(default)]
245 pub save_sync: SaveSyncConfig,
246 #[serde(default)]
248 pub roms_layout: RomsLayoutConfig,
249 #[serde(default = "default_theme_id")]
251 pub theme: String,
252 #[serde(default)]
254 pub tui_layout: TuiLayoutConfig,
255}
256
257pub 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
275pub 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
292pub 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
304pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
306
307pub fn is_keyring_placeholder(s: &str) -> bool {
309 s == KEYRING_SECRET_PLACEHOLDER
310}
311
312pub 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
329const KEYRING_SERVICE: &str = "romm-cli";
334
335pub 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
352fn 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
365pub(crate) 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
379fn 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
408pub 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
420pub fn user_config_json_path() -> Option<PathBuf> {
422 user_config_dir().map(|d| d.join("config.json"))
423}
424
425pub 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
433pub 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
442pub 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
453fn env_nonempty(key: &str) -> Option<String> {
458 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
459}
460
461pub 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
475const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
477
478fn 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
509pub 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
526pub fn load_config() -> Result<Config, ConfigError> {
537 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 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 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 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 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 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
735pub 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
827pub 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#[cfg(test)]
843pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
844 use std::sync::{Mutex, OnceLock};
845 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
846 LOCK.get_or_init(|| Mutex::new(()))
847}
848
849#[cfg(test)]
850mod tests {
851 use super::*;
852 use std::sync::MutexGuard;
853
854 #[test]
855 fn keyring_get_password_result_ok() {
856 assert_eq!(
857 super::keyring_get_password_result("API_TOKEN", Ok("secret".into())),
858 Some("secret".into())
859 );
860 }
861
862 #[test]
863 fn keyring_get_password_result_no_entry_is_none() {
864 assert_eq!(
865 super::keyring_get_password_result("API_TOKEN", Err(KeyringError::NoEntry)),
866 None
867 );
868 }
869
870 #[test]
871 fn auth_config_debug_redacts_secrets() {
872 let basic = AuthConfig::Basic {
873 username: "alice".to_string(),
874 password: "sekrit".to_string(),
875 };
876 let bearer = AuthConfig::Bearer {
877 token: "tok123".to_string(),
878 };
879 let api_key = AuthConfig::ApiKey {
880 header: "X-Api-Key".to_string(),
881 key: "key456".to_string(),
882 };
883 let basic_dbg = format!("{basic:?}");
884 let bearer_dbg = format!("{bearer:?}");
885 let api_key_dbg = format!("{api_key:?}");
886 assert!(!basic_dbg.contains("sekrit"));
887 assert!(basic_dbg.contains("alice"));
888 assert!(!bearer_dbg.contains("tok123"));
889 assert!(!api_key_dbg.contains("key456"));
890 assert!(api_key_dbg.contains("X-Api-Key"));
891 }
892
893 struct TestEnv {
894 _guard: MutexGuard<'static, ()>,
895 config_dir: PathBuf,
896 }
897
898 impl TestEnv {
899 fn new() -> Self {
900 let guard = super::test_env_lock()
901 .lock()
902 .unwrap_or_else(|e| e.into_inner());
903 clear_auth_env();
904
905 let ts = std::time::SystemTime::now()
906 .duration_since(std::time::UNIX_EPOCH)
907 .unwrap()
908 .as_nanos();
909 let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
910 std::fs::create_dir_all(&config_dir).unwrap();
911 std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
912
913 Self {
914 _guard: guard,
915 config_dir,
916 }
917 }
918 }
919
920 impl Drop for TestEnv {
921 fn drop(&mut self) {
922 clear_auth_env();
923 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
924 let _ = std::fs::remove_dir_all(&self.config_dir);
925 }
926 }
927
928 #[test]
929 fn config_theme_defaults_to_terminal() {
930 let cfg: Config = serde_json::from_str(
931 r#"{"base_url":"http://x","download_dir":"/tmp","use_https":false}"#,
932 )
933 .unwrap();
934 assert_eq!(cfg.theme, "terminal");
935 }
936
937 #[test]
938 fn config_theme_round_trip() {
939 let json =
940 r#"{"base_url":"http://x","download_dir":"/tmp","use_https":false,"theme":"dracula"}"#;
941 let cfg: Config = serde_json::from_str(json).unwrap();
942 assert_eq!(cfg.theme, "dracula");
943 }
944
945 #[test]
946 fn config_tui_layout_defaults_when_missing() {
947 let cfg: Config = serde_json::from_str(
948 r#"{"base_url":"http://x","download_dir":"/tmp","use_https":false}"#,
949 )
950 .unwrap();
951 assert_eq!(cfg.tui_layout, TuiLayoutConfig::default());
952 }
953
954 #[test]
955 fn config_tui_layout_normalizes_out_of_range_values() {
956 let cfg = TuiLayoutConfig {
957 library_left_panel_percent: 5,
958 game_detail_cover_panel_width: 999,
959 }
960 .normalized();
961 assert_eq!(
962 cfg.library_left_panel_percent,
963 LIBRARY_LEFT_PANEL_PERCENT_MIN
964 );
965 assert_eq!(
966 cfg.game_detail_cover_panel_width,
967 GAME_DETAIL_COVER_PANEL_WIDTH_MAX
968 );
969 }
970
971 fn clear_auth_env() {
972 for key in [
973 "API_BASE_URL",
974 "ROMM_ROMS_DIR",
975 "API_USERNAME",
976 "API_PASSWORD",
977 "API_TOKEN",
978 "ROMM_TOKEN_FILE",
979 "API_TOKEN_FILE",
980 "API_KEY",
981 "API_KEY_HEADER",
982 "API_USE_HTTPS",
983 "ROMM_THEME",
984 "ROMM_TEST_CONFIG_DIR",
985 ] {
986 std::env::remove_var(key);
987 }
988 }
989
990 #[test]
991 fn prefers_basic_auth_over_other_modes() {
992 let _env = TestEnv::new();
993 std::env::set_var("API_BASE_URL", "http://example.test");
994 std::env::set_var("API_USERNAME", "user");
995 std::env::set_var("API_PASSWORD", "pass");
996 std::env::set_var("API_TOKEN", "token");
997 std::env::set_var("API_KEY", "apikey");
998 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
999
1000 let cfg = load_config().expect("config should load");
1001 match cfg.auth {
1002 Some(AuthConfig::Basic { username, password }) => {
1003 assert_eq!(username, "user");
1004 assert_eq!(password, "pass");
1005 }
1006 _ => panic!("expected basic auth"),
1007 }
1008 }
1009
1010 #[test]
1011 fn uses_api_key_header_when_token_missing() {
1012 let _env = TestEnv::new();
1013 std::env::set_var("API_BASE_URL", "http://example.test");
1014 std::env::set_var("API_KEY", "real-key");
1015 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
1016
1017 let cfg = load_config().expect("config should load");
1018 match cfg.auth {
1019 Some(AuthConfig::ApiKey { header, key }) => {
1020 assert_eq!(header, "X-Api-Key");
1021 assert_eq!(key, "real-key");
1022 }
1023 _ => panic!("expected api key auth"),
1024 }
1025 }
1026
1027 #[test]
1028 fn normalizes_api_base_url_and_enforces_https_by_default() {
1029 let _env = TestEnv::new();
1030 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
1031 let cfg = load_config().expect("config");
1032 assert_eq!(cfg.base_url, "https://romm.example");
1034 }
1035
1036 #[test]
1037 fn does_not_enforce_https_if_toggle_is_false() {
1038 let _env = TestEnv::new();
1039 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
1040 std::env::set_var("API_USE_HTTPS", "false");
1041 let cfg = load_config().expect("config");
1042 assert_eq!(cfg.base_url, "http://romm.example");
1043 }
1044
1045 #[test]
1046 fn normalize_romm_origin_trims_and_strips_api_suffix() {
1047 assert_eq!(
1048 normalize_romm_origin("http://localhost:8080/api/"),
1049 "http://localhost:8080"
1050 );
1051 assert_eq!(
1052 normalize_romm_origin("https://x.example"),
1053 "https://x.example"
1054 );
1055 }
1056
1057 #[test]
1058 fn empty_api_username_does_not_enable_basic() {
1059 let _env = TestEnv::new();
1060 std::env::set_var("API_BASE_URL", "http://example.test");
1061 std::env::set_var("API_USERNAME", "");
1062 std::env::set_var("API_PASSWORD", "secret");
1063
1064 let cfg = load_config().expect("config should load");
1065 assert!(
1066 cfg.auth.is_none(),
1067 "empty API_USERNAME should not pair with password for Basic"
1068 );
1069 }
1070
1071 #[test]
1072 fn ignores_placeholder_bearer_token() {
1073 let _env = TestEnv::new();
1074 std::env::set_var("API_BASE_URL", "http://example.test");
1075 std::env::set_var("API_TOKEN", "your-bearer-token-here");
1076
1077 let cfg = load_config().expect("config should load");
1078 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
1079 }
1080
1081 #[test]
1082 fn loads_from_user_json_file() {
1083 let env = TestEnv::new();
1084 let config_json = r#"{
1085 "base_url": "http://from-json-file.test",
1086 "download_dir": "/tmp/downloads",
1087 "use_https": false,
1088 "auth": null
1089 }"#;
1090
1091 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
1092
1093 let cfg = load_config().expect("load from user config.json");
1094 assert_eq!(cfg.base_url, "http://from-json-file.test");
1095 assert_eq!(cfg.download_dir, "/tmp/downloads");
1096 assert!(!cfg.use_https);
1097 }
1098
1099 #[test]
1100 fn extras_defaults_default_to_all_true_when_missing_from_json() {
1101 let config_json = r#"{
1102 "base_url": "http://from-json-file.test",
1103 "download_dir": "/tmp/downloads",
1104 "use_https": false,
1105 "auth": null
1106 }"#;
1107 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1108 assert!(cfg.extras_defaults.include_related_roms);
1109 assert!(cfg.extras_defaults.include_cover);
1110 assert!(cfg.extras_defaults.include_manual);
1111 }
1112
1113 #[test]
1114 fn save_sync_defaults_when_missing_from_legacy_json() {
1115 let config_json = r#"{
1116 "base_url": "http://from-json-file.test",
1117 "download_dir": "/tmp/downloads",
1118 "use_https": false,
1119 "auth": null
1120 }"#;
1121 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1122 assert_eq!(cfg.save_sync, SaveSyncConfig::default());
1123 }
1124
1125 #[test]
1126 fn roms_layout_defaults_when_missing_from_legacy_json() {
1127 let config_json = r#"{
1128 "base_url": "http://from-json-file.test",
1129 "download_dir": "/tmp/downloads",
1130 "use_https": false,
1131 "auth": null
1132 }"#;
1133 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1134 assert_eq!(cfg.roms_layout, RomsLayoutConfig::default());
1135 }
1136
1137 #[test]
1138 fn roms_layout_deserializes_legacy_mode_with_platform_dirs() {
1139 let config_json = r#"{
1140 "base_url": "http://example.test",
1141 "download_dir": "/tmp/downloads",
1142 "use_https": false,
1143 "auth": null,
1144 "roms_layout": {
1145 "mode": "manual",
1146 "platform_dirs": {
1147 "7": "D:\\Roms\\Switch",
1148 "3": "/roms/nes"
1149 }
1150 }
1151 }"#;
1152 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1153 assert_eq!(
1154 cfg.roms_layout.platform_dirs.get(&7).map(String::as_str),
1155 Some("D:\\Roms\\Switch")
1156 );
1157 assert_eq!(
1158 cfg.roms_layout.platform_dirs.get(&3).map(String::as_str),
1159 Some("/roms/nes")
1160 );
1161 }
1162
1163 #[test]
1164 fn roms_layout_honors_platform_dirs_without_legacy_mode() {
1165 let config_json = r#"{
1166 "base_url": "http://example.test",
1167 "download_dir": "/tmp",
1168 "use_https": false,
1169 "auth": null,
1170 "roms_layout": {
1171 "platform_dirs": { "1": "/custom/nes" }
1172 }
1173 }"#;
1174 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1175 assert_eq!(
1176 cfg.roms_layout.platform_dirs.get(&1).map(String::as_str),
1177 Some("/custom/nes")
1178 );
1179 }
1180
1181 #[test]
1182 fn roms_layout_save_omits_legacy_mode_field() {
1183 let config_json = r#"{
1184 "base_url": "http://example.test",
1185 "download_dir": "/tmp",
1186 "use_https": false,
1187 "auth": null,
1188 "roms_layout": {
1189 "mode": "manual",
1190 "platform_dirs": { "1": "/custom/nes" }
1191 }
1192 }"#;
1193 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1194 let json = serde_json::to_string(&cfg.roms_layout).expect("serialize");
1195 assert!(!json.contains("mode"));
1196 assert!(json.contains("platform_dirs"));
1197 }
1198
1199 #[test]
1200 fn resolved_save_dir_falls_back_to_download_dir_saves() {
1201 let cfg = Config {
1202 base_url: "http://example.test".into(),
1203 download_dir: "/roms".into(),
1204 use_https: false,
1205 auth: None,
1206 extras_defaults: ExtrasDefaults::default(),
1207 save_sync: SaveSyncConfig::default(),
1208 roms_layout: RomsLayoutConfig::default(),
1209 theme: default_theme_id(),
1210 tui_layout: TuiLayoutConfig::default(),
1211 };
1212 assert_eq!(
1213 resolved_save_dir(&cfg),
1214 PathBuf::from("/roms").join("saves")
1215 );
1216 }
1217
1218 #[test]
1219 fn save_sync_deserializes_platform_dirs() {
1220 let config_json = r#"{
1221 "base_url": "http://example.test",
1222 "download_dir": "/tmp",
1223 "use_https": false,
1224 "auth": null,
1225 "save_sync": {
1226 "save_dir": "/saves",
1227 "platform_dirs": {
1228 "7": "D:\\Saves\\Switch"
1229 }
1230 }
1231 }"#;
1232 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1233 assert_eq!(
1234 cfg.save_sync.platform_dirs.get(&7).map(String::as_str),
1235 Some("D:\\Saves\\Switch")
1236 );
1237 }
1238
1239 #[test]
1240 fn save_sync_save_includes_platform_dirs() {
1241 let cfg = Config {
1242 base_url: "http://example.test".into(),
1243 download_dir: "/tmp".into(),
1244 use_https: false,
1245 auth: None,
1246 extras_defaults: ExtrasDefaults::default(),
1247 save_sync: SaveSyncConfig {
1248 save_dir: Some("/saves".into()),
1249 device_id: None,
1250 platform_dirs: HashMap::from([(7, "D:\\Saves\\Switch".into())]),
1251 },
1252 roms_layout: RomsLayoutConfig::default(),
1253 theme: default_theme_id(),
1254 tui_layout: TuiLayoutConfig::default(),
1255 };
1256 let json = serde_json::to_string(&cfg.save_sync).expect("serialize");
1257 assert!(json.contains("platform_dirs"));
1258 }
1259
1260 #[test]
1261 fn roms_dir_env_takes_precedence_over_legacy_download_dir_env() {
1262 let _env = TestEnv::new();
1263 std::env::set_var("API_BASE_URL", "http://example.test");
1264 std::env::set_var("ROMM_ROMS_DIR", "/preferred-roms");
1265 std::env::set_var("ROMM_DOWNLOAD_DIR", "/legacy-downloads");
1266
1267 let cfg = load_config().expect("config should load");
1268 assert_eq!(cfg.download_dir, "/preferred-roms");
1269 }
1270
1271 #[test]
1272 fn auth_for_persist_merge_prefers_in_memory() {
1273 let env = TestEnv::new();
1274 let on_disk = r#"{
1275 "base_url": "http://disk.test",
1276 "download_dir": "/tmp",
1277 "use_https": false,
1278 "auth": { "Bearer": { "token": "from-disk" } }
1279 }"#;
1280 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
1281
1282 let mem = Some(AuthConfig::Bearer {
1283 token: "from-memory".into(),
1284 });
1285 let merged = auth_for_persist_merge(mem.clone());
1286 assert_eq!(format!("{:?}", merged), format!("{:?}", mem));
1287 }
1288
1289 #[test]
1290 fn auth_for_persist_merge_falls_back_to_disk_when_memory_empty() {
1291 let env = TestEnv::new();
1292 let on_disk = r#"{
1293 "base_url": "http://disk.test",
1294 "download_dir": "/tmp",
1295 "use_https": false,
1296 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
1297 }"#;
1298 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
1299
1300 let merged = auth_for_persist_merge(None);
1301 match merged {
1302 Some(AuthConfig::Bearer { token }) => {
1303 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1304 }
1305 _ => panic!("expected bearer auth from disk"),
1306 }
1307 }
1308
1309 #[test]
1310 fn bearer_keyring_sentinel_without_keyring_entry_yields_no_auth() {
1311 let env = TestEnv::new();
1312 std::env::set_var("API_BASE_URL", "http://example.test");
1313 let config_json = r#"{
1314 "base_url": "http://example.test",
1315 "download_dir": "/tmp",
1316 "use_https": false,
1317 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
1318 }"#;
1319 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
1320
1321 let cfg = load_config().expect("load");
1322 assert!(
1323 cfg.auth.is_none(),
1324 "unresolved keyring sentinel must not become Bearer auth in Config"
1325 );
1326 assert!(disk_has_unresolved_keyring_sentinel(&cfg));
1327 }
1328
1329 #[test]
1330 fn bearer_token_from_romm_token_file() {
1331 let env = TestEnv::new();
1332 let token_path = env.config_dir.join("secret.token");
1333 std::fs::write(&token_path, " tok-from-file\n").unwrap();
1334 std::env::set_var("API_BASE_URL", "http://example.test");
1335 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1336
1337 let cfg = load_config().expect("load");
1338 match cfg.auth {
1339 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "tok-from-file"),
1340 _ => panic!("expected bearer from token file"),
1341 }
1342 }
1343
1344 #[test]
1345 fn api_token_env_wins_over_token_file() {
1346 let env = TestEnv::new();
1347 let token_path = env.config_dir.join("secret.token");
1348 std::fs::write(&token_path, "from-file").unwrap();
1349 std::env::set_var("API_BASE_URL", "http://example.test");
1350 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1351 std::env::set_var("API_TOKEN", "from-env");
1352
1353 let cfg = load_config().expect("load");
1354 match cfg.auth {
1355 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-env"),
1356 _ => panic!("expected env API_TOKEN to win"),
1357 }
1358 }
1359
1360 #[test]
1361 fn romm_token_file_overrides_json_bearer() {
1362 let env = TestEnv::new();
1363 let token_path = env.config_dir.join("secret.token");
1364 std::fs::write(&token_path, "from-file").unwrap();
1365 std::env::set_var("API_BASE_URL", "http://example.test");
1366 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1367 let config_json = r#"{
1368 "base_url": "http://example.test",
1369 "download_dir": "/tmp",
1370 "use_https": false,
1371 "auth": { "Bearer": { "token": "from-json" } }
1372 }"#;
1373 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
1374
1375 let cfg = load_config().expect("load");
1376 match cfg.auth {
1377 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-file"),
1378 _ => panic!("expected token file to override json"),
1379 }
1380 }
1381
1382 #[test]
1383 fn romm_token_file_missing_errors() {
1384 let env = TestEnv::new();
1385 let missing = env.config_dir.join("this-token-file-does-not-exist");
1386 std::env::set_var("API_BASE_URL", "http://example.test");
1387 std::env::set_var("ROMM_TOKEN_FILE", missing.to_str().unwrap());
1388
1389 let err = load_config().expect_err("missing token file should error");
1390 let msg = format!("{err:#}");
1391 assert!(
1392 msg.contains("read bearer token file"),
1393 "unexpected error: {msg}"
1394 );
1395 }
1396
1397 #[test]
1398 fn romm_token_file_empty_errors() {
1399 let env = TestEnv::new();
1400 let token_path = env.config_dir.join("empty.token");
1401 std::fs::write(&token_path, " \n\t ").unwrap();
1402 std::env::set_var("API_BASE_URL", "http://example.test");
1403 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1404
1405 let err = load_config().expect_err("empty token file should error");
1406 assert!(
1407 format!("{err:#}").contains("empty"),
1408 "unexpected error: {err:#}"
1409 );
1410 }
1411
1412 #[test]
1413 fn romm_token_file_too_large_errors() {
1414 let env = TestEnv::new();
1415 let token_path = env.config_dir.join("huge.token");
1416 std::fs::write(&token_path, vec![b'a'; MAX_TOKEN_FILE_BYTES + 1]).unwrap();
1417 std::env::set_var("API_BASE_URL", "http://example.test");
1418 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1419
1420 let err = load_config().expect_err("oversized token file should error");
1421 assert!(
1422 format!("{err:#}").contains("max size"),
1423 "unexpected error: {err:#}"
1424 );
1425 }
1426
1427 #[test]
1431 fn persist_user_config_preserves_sentinel_secrets_in_json() {
1432 let env = TestEnv::new();
1433 let path = env.config_dir.join("config.json");
1434
1435 persist_user_config(&Config {
1436 base_url: "https://updated.example".into(),
1437 download_dir: "/var/romm-dl".into(),
1438 use_https: true,
1439 auth: Some(AuthConfig::Bearer {
1440 token: KEYRING_SECRET_PLACEHOLDER.to_string(),
1441 }),
1442 extras_defaults: ExtrasDefaults::default(),
1443 save_sync: SaveSyncConfig::default(),
1444 roms_layout: RomsLayoutConfig::default(),
1445 theme: default_theme_id(),
1446 tui_layout: TuiLayoutConfig::default(),
1447 })
1448 .expect("persist bearer sentinel");
1449
1450 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1451 assert_eq!(cfg.base_url, "https://updated.example");
1452 assert_eq!(cfg.download_dir, "/var/romm-dl");
1453 assert!(cfg.use_https);
1454 match cfg.auth {
1455 Some(AuthConfig::Bearer { token }) => {
1456 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1457 }
1458 _ => panic!("expected bearer sentinel preserved in config.json"),
1459 }
1460
1461 persist_user_config(&Config {
1462 base_url: "https://apikey.example".into(),
1463 download_dir: "/dl".into(),
1464 use_https: false,
1465 auth: Some(AuthConfig::ApiKey {
1466 header: "X-Api-Key".into(),
1467 key: KEYRING_SECRET_PLACEHOLDER.to_string(),
1468 }),
1469 extras_defaults: ExtrasDefaults::default(),
1470 save_sync: SaveSyncConfig::default(),
1471 roms_layout: RomsLayoutConfig::default(),
1472 theme: default_theme_id(),
1473 tui_layout: TuiLayoutConfig::default(),
1474 })
1475 .expect("persist api key sentinel");
1476
1477 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1478 assert_eq!(cfg.base_url, "https://apikey.example");
1479 match cfg.auth {
1480 Some(AuthConfig::ApiKey { header, key }) => {
1481 assert_eq!(header, "X-Api-Key");
1482 assert_eq!(key, KEYRING_SECRET_PLACEHOLDER);
1483 }
1484 _ => panic!("expected api key sentinel preserved"),
1485 }
1486
1487 persist_user_config(&Config {
1488 base_url: "https://basic.example".into(),
1489 download_dir: "/dl".into(),
1490 use_https: true,
1491 auth: Some(AuthConfig::Basic {
1492 username: "alice".into(),
1493 password: KEYRING_SECRET_PLACEHOLDER.to_string(),
1494 }),
1495 extras_defaults: ExtrasDefaults::default(),
1496 save_sync: SaveSyncConfig::default(),
1497 roms_layout: RomsLayoutConfig::default(),
1498 theme: default_theme_id(),
1499 tui_layout: TuiLayoutConfig::default(),
1500 })
1501 .expect("persist basic password sentinel");
1502
1503 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1504 assert_eq!(cfg.base_url, "https://basic.example");
1505 match cfg.auth {
1506 Some(AuthConfig::Basic { username, password }) => {
1507 assert_eq!(username, "alice");
1508 assert_eq!(password, KEYRING_SECRET_PLACEHOLDER);
1509 }
1510 _ => panic!("expected basic password sentinel preserved"),
1511 }
1512 }
1513
1514 #[test]
1515 fn should_check_updates_defaults_true_and_honors_false_values() {
1516 let _env = TestEnv::new();
1517 std::env::remove_var("ROMM_CHECK_UPDATES");
1518 assert!(should_check_updates());
1519
1520 for value in ["false", "FALSE", "0", "no", "off"] {
1521 std::env::set_var("ROMM_CHECK_UPDATES", value);
1522 assert!(
1523 !should_check_updates(),
1524 "expected ROMM_CHECK_UPDATES={value} to disable checks"
1525 );
1526 }
1527
1528 std::env::set_var("ROMM_CHECK_UPDATES", "true");
1529 assert!(should_check_updates());
1530 }
1531}