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
128#[derive(Debug, Clone, Copy, Deserialize, Default, PartialEq, Eq)]
130#[serde(rename_all = "lowercase")]
131enum LegacyRomsLayoutMode {
132 #[default]
133 Auto,
134 Manual,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
142pub struct RomsLayoutConfig {
143 #[serde(default, skip_serializing, rename = "mode")]
145 _legacy_mode: Option<LegacyRomsLayoutMode>,
146 #[serde(default)]
148 pub platform_dirs: HashMap<u64, String>,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
156pub struct SaveSyncConfig {
157 #[serde(default)]
159 pub save_dir: Option<String>,
160 #[serde(default)]
162 pub device_id: Option<String>,
163 #[serde(default)]
165 pub platform_dirs: HashMap<u64, String>,
166}
167
168#[derive(Debug, Clone, Serialize, Deserialize)]
173pub struct Config {
174 pub base_url: String,
176 pub download_dir: String,
178 pub use_https: bool,
180 pub auth: Option<AuthConfig>,
182 #[serde(default)]
184 pub extras_defaults: ExtrasDefaults,
185 #[serde(default)]
187 pub save_sync: SaveSyncConfig,
188 #[serde(default)]
190 pub roms_layout: RomsLayoutConfig,
191 #[serde(default = "default_theme_id")]
193 pub theme: String,
194}
195
196pub const DEFAULT_THEME_ID: &str = "terminal";
198
199pub fn default_theme_id() -> String {
200 DEFAULT_THEME_ID.to_string()
201}
202
203pub fn resolved_save_dir(config: &Config) -> PathBuf {
204 config
205 .save_sync
206 .save_dir
207 .as_deref()
208 .map(str::trim)
209 .filter(|s| !s.is_empty())
210 .map(PathBuf::from)
211 .unwrap_or_else(|| PathBuf::from(&config.download_dir).join("saves"))
212}
213
214pub fn resolve_console_save_dir(
216 save_sync: &SaveSyncConfig,
217 base_save_dir: &std::path::Path,
218 platform_id: u64,
219 platform_fs_slug: Option<&str>,
220 platform_slug: Option<&str>,
221) -> Result<PathBuf, DownloadError> {
222 crate::core::download::resolve_console_save_dir(
223 save_sync,
224 base_save_dir,
225 platform_id,
226 platform_fs_slug,
227 platform_slug,
228 )
229}
230
231pub fn resolve_game_save_dir(
233 config: &Config,
234 rom: &crate::types::Rom,
235) -> Result<PathBuf, DownloadError> {
236 crate::core::download::resolve_game_save_dir(config, rom)
237}
238
239fn is_placeholder(value: &str) -> bool {
240 value.contains("your-") || value.contains("placeholder") || value.trim().is_empty()
241}
242
243pub const KEYRING_SECRET_PLACEHOLDER: &str = "<stored-in-keyring>";
245
246pub fn is_keyring_placeholder(s: &str) -> bool {
248 s == KEYRING_SECRET_PLACEHOLDER
249}
250
251pub fn normalize_romm_origin(url: &str) -> String {
261 let mut s = url.trim().trim_end_matches('/').to_string();
262 if s.ends_with("/api") {
263 s.truncate(s.len() - 4);
264 }
265 s.trim_end_matches('/').to_string()
266}
267
268const KEYRING_SERVICE: &str = "romm-cli";
273
274pub fn keyring_store(key: &str, value: &str) -> Result<(), ConfigError> {
279 let entry = Entry::new(KEYRING_SERVICE, key).map_err(|e| ConfigError::KeyringEntry {
280 key: key.to_string(),
281 message: e.to_string(),
282 })?;
283 entry
284 .set_password(value)
285 .map_err(|e| ConfigError::KeyringStore {
286 key: key.to_string(),
287 message: e.to_string(),
288 })
289}
290
291fn keyring_get_password_result(key: &str, result: KeyringResult<String>) -> Option<String> {
294 match result {
295 Ok(s) => Some(s),
296 Err(KeyringError::NoEntry) => None,
297 Err(e) => {
298 tracing::warn!("keyring get_password for key {key}: {e}");
299 None
300 }
301 }
302}
303
304pub(crate) fn keyring_get(key: &str) -> Option<String> {
308 let entry = match Entry::new(KEYRING_SERVICE, key) {
309 Ok(e) => e,
310 Err(e) => {
311 tracing::warn!("keyring Entry::new for key {key}: {e}");
312 return None;
313 }
314 };
315 keyring_get_password_result(key, entry.get_password())
316}
317
318fn keyring_verify_read_back_matches(key: &str, expected: &str) -> bool {
321 let entry = match Entry::new(KEYRING_SERVICE, key) {
322 Ok(e) => e,
323 Err(e) => {
324 tracing::warn!(
325 "keyring verify: Entry::new for key {key} after successful store: {e}; writing plaintext to config.json"
326 );
327 return false;
328 }
329 };
330 match entry.get_password() {
331 Ok(read) if read == expected => true,
332 Ok(_) => {
333 tracing::warn!(
334 "keyring verify: read-back for key {key} did not match; writing plaintext to config.json"
335 );
336 false
337 }
338 Err(e) => {
339 tracing::warn!(
340 "keyring verify: get_password for key {key} after successful store: {e}; writing plaintext to config.json"
341 );
342 false
343 }
344 }
345}
346
347pub fn user_config_dir() -> Option<PathBuf> {
353 if let Ok(dir) = std::env::var("ROMM_TEST_CONFIG_DIR") {
354 return Some(PathBuf::from(dir));
355 }
356 dirs::config_dir().map(|d| d.join("romm-cli"))
357}
358
359pub fn user_config_json_path() -> Option<PathBuf> {
361 user_config_dir().map(|d| d.join("config.json"))
362}
363
364pub fn read_user_config_json_from_disk() -> Option<Config> {
367 let path = user_config_json_path()?;
368 let content = std::fs::read_to_string(path).ok()?;
369 serde_json::from_str(&content).ok()
370}
371
372pub fn auth_for_persist_merge(in_memory: Option<AuthConfig>) -> Option<AuthConfig> {
378 in_memory.or_else(|| read_user_config_json_from_disk().and_then(|c| c.auth))
379}
380
381pub fn openapi_cache_path() -> Result<PathBuf, ConfigError> {
385 if let Ok(p) = std::env::var("ROMM_OPENAPI_PATH") {
386 return Ok(PathBuf::from(p));
387 }
388 let dir = user_config_dir().ok_or(ConfigError::ConfigDirUnavailable)?;
389 Ok(dir.join("openapi.json"))
390}
391
392fn env_nonempty(key: &str) -> Option<String> {
397 std::env::var(key).ok().filter(|s| !s.trim().is_empty())
398}
399
400pub fn should_check_updates() -> bool {
405 match std::env::var("ROMM_CHECK_UPDATES") {
406 Ok(value) => {
407 let normalized = value.trim().to_ascii_lowercase();
408 !matches!(normalized.as_str(), "0" | "false" | "no" | "off")
409 }
410 Err(_) => true,
411 }
412}
413
414const MAX_TOKEN_FILE_BYTES: usize = 64 * 1024;
416
417fn token_from_env_or_file() -> Result<Option<String>, ConfigError> {
419 if let Some(t) = env_nonempty("API_TOKEN") {
420 return Ok(Some(t));
421 }
422 let path = env_nonempty("ROMM_TOKEN_FILE").or_else(|| env_nonempty("API_TOKEN_FILE"));
423 let Some(path) = path else {
424 return Ok(None);
425 };
426 let path = path.trim();
427 let bytes = fs::read(path).map_err(|e| ConfigError::TokenFileRead {
428 path: path.to_string(),
429 source: e,
430 })?;
431 if bytes.len() > MAX_TOKEN_FILE_BYTES {
432 return Err(ConfigError::TokenFileTooLarge {
433 max: MAX_TOKEN_FILE_BYTES,
434 });
435 }
436 let s = String::from_utf8(bytes).map_err(|_| ConfigError::TokenFileInvalidUtf8 {
437 path: path.to_string(),
438 })?;
439 let t = s.trim();
440 if t.is_empty() {
441 return Err(ConfigError::TokenFileEmpty {
442 path: path.to_string(),
443 });
444 }
445 Ok(Some(t.to_string()))
446}
447
448pub fn disk_has_unresolved_keyring_sentinel(config: &Config) -> bool {
451 if config.auth.is_some() {
452 return false;
453 }
454 let Some(disk) = read_user_config_json_from_disk() else {
455 return false;
456 };
457 match &disk.auth {
458 Some(AuthConfig::Bearer { token }) => is_keyring_placeholder(token),
459 Some(AuthConfig::Basic { password, .. }) => is_keyring_placeholder(password),
460 Some(AuthConfig::ApiKey { key, .. }) => is_keyring_placeholder(key),
461 None => false,
462 }
463}
464
465pub fn load_config() -> Result<Config, ConfigError> {
476 let mut json_config = None;
478 if let Some(path) = user_config_json_path() {
479 if path.is_file() {
480 if let Ok(content) = std::fs::read_to_string(&path) {
481 if let Ok(config) = serde_json::from_str::<Config>(&content) {
482 json_config = Some(config);
483 }
484 }
485 }
486 }
487
488 let base_raw = env_nonempty("API_BASE_URL")
490 .or_else(|| json_config.as_ref().map(|c| c.base_url.clone()))
491 .ok_or(ConfigError::MissingBaseUrl)?;
492 let mut base_url = normalize_romm_origin(&base_raw);
493
494 let download_dir = env_nonempty("ROMM_ROMS_DIR")
496 .or_else(|| env_nonempty("ROMM_DOWNLOAD_DIR"))
497 .or_else(|| json_config.as_ref().map(|c| c.download_dir.clone()))
498 .unwrap_or_else(|| {
499 dirs::download_dir()
500 .unwrap_or_else(|| dirs::home_dir().unwrap_or_default().join("Downloads"))
501 .join("romm-cli")
502 .display()
503 .to_string()
504 });
505
506 let use_https = if let Ok(s) = std::env::var("API_USE_HTTPS") {
508 s.to_lowercase() == "true"
509 } else if let Some(c) = &json_config {
510 c.use_https
511 } else {
512 true
513 };
514
515 if use_https && base_url.starts_with("http://") {
516 base_url = base_url.replace("http://", "https://");
517 }
518
519 let mut username = env_nonempty("API_USERNAME");
521 let mut password = env_nonempty("API_PASSWORD");
522 let mut token = token_from_env_or_file()?;
523 let mut api_key = env_nonempty("API_KEY");
524 let mut api_key_header = env_nonempty("API_KEY_HEADER");
525
526 if let Some(c) = &json_config {
527 if let Some(auth) = &c.auth {
528 match auth {
529 AuthConfig::Basic {
530 username: u,
531 password: p,
532 } => {
533 if username.is_none() {
534 username = Some(u.clone());
535 }
536 if password.is_none() {
537 password = Some(p.clone());
538 }
539 }
540 AuthConfig::Bearer { token: t } => {
541 if token.is_none() {
542 token = Some(t.clone());
543 }
544 }
545 AuthConfig::ApiKey { header: h, key: k } => {
546 if api_key_header.is_none() {
547 api_key_header = Some(h.clone());
548 }
549 if api_key.is_none() {
550 api_key = Some(k.clone());
551 }
552 }
553 }
554 }
555 }
556
557 if let Some(p) = &password {
559 if is_placeholder(p) || is_keyring_placeholder(p) {
560 if let Some(k) = keyring_get("API_PASSWORD") {
561 password = Some(k);
562 }
563 }
564 } else {
565 password = keyring_get("API_PASSWORD");
566 }
567
568 if let Some(t) = &token {
569 if is_placeholder(t) || is_keyring_placeholder(t) {
570 if let Some(k) = keyring_get("API_TOKEN") {
571 token = Some(k);
572 }
573 }
574 } else {
575 token = keyring_get("API_TOKEN");
576 }
577
578 if let Some(k) = &api_key {
579 if is_placeholder(k) || is_keyring_placeholder(k) {
580 if let Some(kr) = keyring_get("API_KEY") {
581 api_key = Some(kr);
582 }
583 }
584 } else {
585 api_key = keyring_get("API_KEY");
586 }
587
588 if let Some(ref p) = password {
589 if is_keyring_placeholder(p) {
590 tracing::warn!(
591 "Could not read API_PASSWORD from the OS keyring; value is still <stored-in-keyring>. \
592 On Windows, look for a Generic credential with target API_PASSWORD.romm-cli."
593 );
594 }
595 }
596 if let Some(ref t) = token {
597 if is_keyring_placeholder(t) {
598 tracing::warn!(
599 "Could not read API_TOKEN from the OS keyring; value is still <stored-in-keyring>. \
600 On Windows, look for a Generic credential with target API_TOKEN.romm-cli."
601 );
602 }
603 }
604 if let Some(ref k) = api_key {
605 if is_keyring_placeholder(k) {
606 tracing::warn!(
607 "Could not read API_KEY from the OS keyring; value is still <stored-in-keyring>. \
608 On Windows, look for a Generic credential with target API_KEY.romm-cli."
609 );
610 }
611 }
612
613 let auth = if let (Some(user), Some(pass)) = (username, password) {
614 if !is_placeholder(&pass) && !is_keyring_placeholder(&pass) {
615 Some(AuthConfig::Basic {
616 username: user,
617 password: pass,
618 })
619 } else {
620 None
621 }
622 } else if let (Some(key), Some(header)) = (api_key, api_key_header) {
623 if !is_placeholder(&key) && !is_keyring_placeholder(&key) {
624 Some(AuthConfig::ApiKey { header, key })
625 } else {
626 None
627 }
628 } else if let Some(tok) = token {
629 if !is_placeholder(&tok) && !is_keyring_placeholder(&tok) {
630 Some(AuthConfig::Bearer { token: tok })
631 } else {
632 None
633 }
634 } else {
635 None
636 };
637
638 let extras_defaults = json_config
639 .as_ref()
640 .map(|c| c.extras_defaults.clone())
641 .unwrap_or_default();
642 let save_sync = json_config
643 .as_ref()
644 .map(|c| c.save_sync.clone())
645 .unwrap_or_default();
646
647 let roms_layout = json_config
648 .as_ref()
649 .map(|c| c.roms_layout.clone())
650 .unwrap_or_default();
651
652 let theme = env_nonempty("ROMM_THEME")
653 .or_else(|| json_config.as_ref().map(|c| c.theme.clone()))
654 .unwrap_or_else(default_theme_id);
655
656 Ok(Config {
657 base_url,
658 download_dir,
659 use_https,
660 auth,
661 extras_defaults,
662 save_sync,
663 roms_layout,
664 theme,
665 })
666}
667
668pub fn persist_user_config(config: &Config) -> Result<(), ConfigError> {
679 let Some(path) = user_config_json_path() else {
680 return Err(ConfigError::ConfigDirNotFound);
681 };
682 let dir = path.parent().ok_or(ConfigError::InvalidConfigPath)?;
683 std::fs::create_dir_all(dir).map_err(|e| ConfigError::Io {
684 context: format!("create {}", dir.display()),
685 source: e,
686 })?;
687
688 let mut config_to_save = config.clone();
689
690 match &mut config_to_save.auth {
691 None => {}
692 Some(AuthConfig::Basic { password, .. }) => {
693 if is_keyring_placeholder(password) {
694 tracing::debug!(
695 "skip keyring store for API_PASSWORD: value is keyring sentinel; leaving disk sentinel unchanged"
696 );
697 } else if let Err(e) = keyring_store("API_PASSWORD", password) {
698 tracing::warn!("keyring store API_PASSWORD: {e}; writing plaintext to config.json");
699 } else if keyring_verify_read_back_matches("API_PASSWORD", password.as_str()) {
700 *password = KEYRING_SECRET_PLACEHOLDER.to_string();
701 }
702 }
703 Some(AuthConfig::Bearer { token }) => {
704 if is_keyring_placeholder(token) {
705 tracing::debug!(
706 "skip keyring store for API_TOKEN: value is keyring sentinel; leaving disk sentinel unchanged"
707 );
708 } else if let Err(e) = keyring_store("API_TOKEN", token) {
709 tracing::warn!("keyring store API_TOKEN: {e}; writing plaintext to config.json");
710 } else if keyring_verify_read_back_matches("API_TOKEN", token.as_str()) {
711 *token = KEYRING_SECRET_PLACEHOLDER.to_string();
712 }
713 }
714 Some(AuthConfig::ApiKey { key, .. }) => {
715 if is_keyring_placeholder(key) {
716 tracing::debug!(
717 "skip keyring store for API_KEY: value is keyring sentinel; leaving disk sentinel unchanged"
718 );
719 } else if let Err(e) = keyring_store("API_KEY", key) {
720 tracing::warn!("keyring store API_KEY: {e}; writing plaintext to config.json");
721 } else if keyring_verify_read_back_matches("API_KEY", key.as_str()) {
722 *key = KEYRING_SECRET_PLACEHOLDER.to_string();
723 }
724 }
725 }
726
727 let content = serde_json::to_string_pretty(&config_to_save)?;
728 {
729 use std::io::Write;
730 let mut f = std::fs::File::create(&path).map_err(|e| ConfigError::Io {
731 context: format!("write {}", path.display()),
732 source: e,
733 })?;
734 f.write_all(content.as_bytes())
735 .map_err(|e| ConfigError::Io {
736 context: format!("write {}", path.display()),
737 source: e,
738 })?;
739 }
740
741 #[cfg(unix)]
742 {
743 use std::os::unix::fs::PermissionsExt;
744 let mut perms = std::fs::metadata(&path)
745 .map_err(|e| ConfigError::Io {
746 context: format!("chmod metadata {}", path.display()),
747 source: e,
748 })?
749 .permissions();
750 perms.set_mode(0o600);
751 std::fs::set_permissions(&path, perms).map_err(|e| ConfigError::Io {
752 context: format!("chmod {}", path.display()),
753 source: e,
754 })?;
755 }
756
757 Ok(())
758}
759
760pub fn reset_all_settings() -> Result<(), ConfigError> {
762 if let Some(path) = user_config_json_path() {
763 if path.exists() {
764 let _ = std::fs::remove_file(&path);
765 }
766 }
767 for key in ["API_PASSWORD", "API_TOKEN", "API_KEY"] {
768 if let Ok(entry) = Entry::new(KEYRING_SERVICE, key) {
769 let _ = entry.delete_credential();
770 }
771 }
772 Ok(())
773}
774
775#[cfg(test)]
776pub(crate) fn test_env_lock() -> &'static std::sync::Mutex<()> {
777 use std::sync::{Mutex, OnceLock};
778 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
779 LOCK.get_or_init(|| Mutex::new(()))
780}
781
782#[cfg(test)]
783mod tests {
784 use super::*;
785 use std::sync::MutexGuard;
786
787 #[test]
788 fn keyring_get_password_result_ok() {
789 assert_eq!(
790 super::keyring_get_password_result("API_TOKEN", Ok("secret".into())),
791 Some("secret".into())
792 );
793 }
794
795 #[test]
796 fn keyring_get_password_result_no_entry_is_none() {
797 assert_eq!(
798 super::keyring_get_password_result("API_TOKEN", Err(KeyringError::NoEntry)),
799 None
800 );
801 }
802
803 #[test]
804 fn auth_config_debug_redacts_secrets() {
805 let basic = AuthConfig::Basic {
806 username: "alice".to_string(),
807 password: "sekrit".to_string(),
808 };
809 let bearer = AuthConfig::Bearer {
810 token: "tok123".to_string(),
811 };
812 let api_key = AuthConfig::ApiKey {
813 header: "X-Api-Key".to_string(),
814 key: "key456".to_string(),
815 };
816 let basic_dbg = format!("{basic:?}");
817 let bearer_dbg = format!("{bearer:?}");
818 let api_key_dbg = format!("{api_key:?}");
819 assert!(!basic_dbg.contains("sekrit"));
820 assert!(basic_dbg.contains("alice"));
821 assert!(!bearer_dbg.contains("tok123"));
822 assert!(!api_key_dbg.contains("key456"));
823 assert!(api_key_dbg.contains("X-Api-Key"));
824 }
825
826 struct TestEnv {
827 _guard: MutexGuard<'static, ()>,
828 config_dir: PathBuf,
829 }
830
831 impl TestEnv {
832 fn new() -> Self {
833 let guard = super::test_env_lock()
834 .lock()
835 .unwrap_or_else(|e| e.into_inner());
836 clear_auth_env();
837
838 let ts = std::time::SystemTime::now()
839 .duration_since(std::time::UNIX_EPOCH)
840 .unwrap()
841 .as_nanos();
842 let config_dir = std::env::temp_dir().join(format!("romm-config-test-{ts}"));
843 std::fs::create_dir_all(&config_dir).unwrap();
844 std::env::set_var("ROMM_TEST_CONFIG_DIR", &config_dir);
845
846 Self {
847 _guard: guard,
848 config_dir,
849 }
850 }
851 }
852
853 impl Drop for TestEnv {
854 fn drop(&mut self) {
855 clear_auth_env();
856 std::env::remove_var("ROMM_TEST_CONFIG_DIR");
857 let _ = std::fs::remove_dir_all(&self.config_dir);
858 }
859 }
860
861 #[test]
862 fn config_theme_defaults_to_terminal() {
863 let cfg: Config = serde_json::from_str(
864 r#"{"base_url":"http://x","download_dir":"/tmp","use_https":false}"#,
865 )
866 .unwrap();
867 assert_eq!(cfg.theme, "terminal");
868 }
869
870 #[test]
871 fn config_theme_round_trip() {
872 let json =
873 r#"{"base_url":"http://x","download_dir":"/tmp","use_https":false,"theme":"dracula"}"#;
874 let cfg: Config = serde_json::from_str(json).unwrap();
875 assert_eq!(cfg.theme, "dracula");
876 }
877
878 fn clear_auth_env() {
879 for key in [
880 "API_BASE_URL",
881 "ROMM_ROMS_DIR",
882 "API_USERNAME",
883 "API_PASSWORD",
884 "API_TOKEN",
885 "ROMM_TOKEN_FILE",
886 "API_TOKEN_FILE",
887 "API_KEY",
888 "API_KEY_HEADER",
889 "API_USE_HTTPS",
890 "ROMM_THEME",
891 "ROMM_TEST_CONFIG_DIR",
892 ] {
893 std::env::remove_var(key);
894 }
895 }
896
897 #[test]
898 fn prefers_basic_auth_over_other_modes() {
899 let _env = TestEnv::new();
900 std::env::set_var("API_BASE_URL", "http://example.test");
901 std::env::set_var("API_USERNAME", "user");
902 std::env::set_var("API_PASSWORD", "pass");
903 std::env::set_var("API_TOKEN", "token");
904 std::env::set_var("API_KEY", "apikey");
905 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
906
907 let cfg = load_config().expect("config should load");
908 match cfg.auth {
909 Some(AuthConfig::Basic { username, password }) => {
910 assert_eq!(username, "user");
911 assert_eq!(password, "pass");
912 }
913 _ => panic!("expected basic auth"),
914 }
915 }
916
917 #[test]
918 fn uses_api_key_header_when_token_missing() {
919 let _env = TestEnv::new();
920 std::env::set_var("API_BASE_URL", "http://example.test");
921 std::env::set_var("API_KEY", "real-key");
922 std::env::set_var("API_KEY_HEADER", "X-Api-Key");
923
924 let cfg = load_config().expect("config should load");
925 match cfg.auth {
926 Some(AuthConfig::ApiKey { header, key }) => {
927 assert_eq!(header, "X-Api-Key");
928 assert_eq!(key, "real-key");
929 }
930 _ => panic!("expected api key auth"),
931 }
932 }
933
934 #[test]
935 fn normalizes_api_base_url_and_enforces_https_by_default() {
936 let _env = TestEnv::new();
937 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
938 let cfg = load_config().expect("config");
939 assert_eq!(cfg.base_url, "https://romm.example");
941 }
942
943 #[test]
944 fn does_not_enforce_https_if_toggle_is_false() {
945 let _env = TestEnv::new();
946 std::env::set_var("API_BASE_URL", "http://romm.example/api/");
947 std::env::set_var("API_USE_HTTPS", "false");
948 let cfg = load_config().expect("config");
949 assert_eq!(cfg.base_url, "http://romm.example");
950 }
951
952 #[test]
953 fn normalize_romm_origin_trims_and_strips_api_suffix() {
954 assert_eq!(
955 normalize_romm_origin("http://localhost:8080/api/"),
956 "http://localhost:8080"
957 );
958 assert_eq!(
959 normalize_romm_origin("https://x.example"),
960 "https://x.example"
961 );
962 }
963
964 #[test]
965 fn empty_api_username_does_not_enable_basic() {
966 let _env = TestEnv::new();
967 std::env::set_var("API_BASE_URL", "http://example.test");
968 std::env::set_var("API_USERNAME", "");
969 std::env::set_var("API_PASSWORD", "secret");
970
971 let cfg = load_config().expect("config should load");
972 assert!(
973 cfg.auth.is_none(),
974 "empty API_USERNAME should not pair with password for Basic"
975 );
976 }
977
978 #[test]
979 fn ignores_placeholder_bearer_token() {
980 let _env = TestEnv::new();
981 std::env::set_var("API_BASE_URL", "http://example.test");
982 std::env::set_var("API_TOKEN", "your-bearer-token-here");
983
984 let cfg = load_config().expect("config should load");
985 assert!(cfg.auth.is_none(), "placeholder token should be ignored");
986 }
987
988 #[test]
989 fn loads_from_user_json_file() {
990 let env = TestEnv::new();
991 let config_json = r#"{
992 "base_url": "http://from-json-file.test",
993 "download_dir": "/tmp/downloads",
994 "use_https": false,
995 "auth": null
996 }"#;
997
998 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
999
1000 let cfg = load_config().expect("load from user config.json");
1001 assert_eq!(cfg.base_url, "http://from-json-file.test");
1002 assert_eq!(cfg.download_dir, "/tmp/downloads");
1003 assert!(!cfg.use_https);
1004 }
1005
1006 #[test]
1007 fn extras_defaults_default_to_all_true_when_missing_from_json() {
1008 let config_json = r#"{
1009 "base_url": "http://from-json-file.test",
1010 "download_dir": "/tmp/downloads",
1011 "use_https": false,
1012 "auth": null
1013 }"#;
1014 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1015 assert!(cfg.extras_defaults.include_related_roms);
1016 assert!(cfg.extras_defaults.include_cover);
1017 assert!(cfg.extras_defaults.include_manual);
1018 }
1019
1020 #[test]
1021 fn save_sync_defaults_when_missing_from_legacy_json() {
1022 let config_json = r#"{
1023 "base_url": "http://from-json-file.test",
1024 "download_dir": "/tmp/downloads",
1025 "use_https": false,
1026 "auth": null
1027 }"#;
1028 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1029 assert_eq!(cfg.save_sync, SaveSyncConfig::default());
1030 }
1031
1032 #[test]
1033 fn roms_layout_defaults_when_missing_from_legacy_json() {
1034 let config_json = r#"{
1035 "base_url": "http://from-json-file.test",
1036 "download_dir": "/tmp/downloads",
1037 "use_https": false,
1038 "auth": null
1039 }"#;
1040 let cfg: Config = serde_json::from_str(config_json).expect("deserialize legacy config");
1041 assert_eq!(cfg.roms_layout, RomsLayoutConfig::default());
1042 }
1043
1044 #[test]
1045 fn roms_layout_deserializes_legacy_mode_with_platform_dirs() {
1046 let config_json = r#"{
1047 "base_url": "http://example.test",
1048 "download_dir": "/tmp/downloads",
1049 "use_https": false,
1050 "auth": null,
1051 "roms_layout": {
1052 "mode": "manual",
1053 "platform_dirs": {
1054 "7": "D:\\Roms\\Switch",
1055 "3": "/roms/nes"
1056 }
1057 }
1058 }"#;
1059 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1060 assert_eq!(
1061 cfg.roms_layout.platform_dirs.get(&7).map(String::as_str),
1062 Some("D:\\Roms\\Switch")
1063 );
1064 assert_eq!(
1065 cfg.roms_layout.platform_dirs.get(&3).map(String::as_str),
1066 Some("/roms/nes")
1067 );
1068 }
1069
1070 #[test]
1071 fn roms_layout_honors_platform_dirs_without_legacy_mode() {
1072 let config_json = r#"{
1073 "base_url": "http://example.test",
1074 "download_dir": "/tmp",
1075 "use_https": false,
1076 "auth": null,
1077 "roms_layout": {
1078 "platform_dirs": { "1": "/custom/nes" }
1079 }
1080 }"#;
1081 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1082 assert_eq!(
1083 cfg.roms_layout.platform_dirs.get(&1).map(String::as_str),
1084 Some("/custom/nes")
1085 );
1086 }
1087
1088 #[test]
1089 fn roms_layout_save_omits_legacy_mode_field() {
1090 let config_json = r#"{
1091 "base_url": "http://example.test",
1092 "download_dir": "/tmp",
1093 "use_https": false,
1094 "auth": null,
1095 "roms_layout": {
1096 "mode": "manual",
1097 "platform_dirs": { "1": "/custom/nes" }
1098 }
1099 }"#;
1100 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1101 let json = serde_json::to_string(&cfg.roms_layout).expect("serialize");
1102 assert!(!json.contains("mode"));
1103 assert!(json.contains("platform_dirs"));
1104 }
1105
1106 #[test]
1107 fn resolved_save_dir_falls_back_to_download_dir_saves() {
1108 let cfg = Config {
1109 base_url: "http://example.test".into(),
1110 download_dir: "/roms".into(),
1111 use_https: false,
1112 auth: None,
1113 extras_defaults: ExtrasDefaults::default(),
1114 save_sync: SaveSyncConfig::default(),
1115 roms_layout: RomsLayoutConfig::default(),
1116 theme: default_theme_id(),
1117 };
1118 assert_eq!(
1119 resolved_save_dir(&cfg),
1120 PathBuf::from("/roms").join("saves")
1121 );
1122 }
1123
1124 #[test]
1125 fn save_sync_deserializes_platform_dirs() {
1126 let config_json = r#"{
1127 "base_url": "http://example.test",
1128 "download_dir": "/tmp",
1129 "use_https": false,
1130 "auth": null,
1131 "save_sync": {
1132 "save_dir": "/saves",
1133 "platform_dirs": {
1134 "7": "D:\\Saves\\Switch"
1135 }
1136 }
1137 }"#;
1138 let cfg: Config = serde_json::from_str(config_json).expect("deserialize");
1139 assert_eq!(
1140 cfg.save_sync.platform_dirs.get(&7).map(String::as_str),
1141 Some("D:\\Saves\\Switch")
1142 );
1143 }
1144
1145 #[test]
1146 fn save_sync_save_includes_platform_dirs() {
1147 let cfg = Config {
1148 base_url: "http://example.test".into(),
1149 download_dir: "/tmp".into(),
1150 use_https: false,
1151 auth: None,
1152 extras_defaults: ExtrasDefaults::default(),
1153 save_sync: SaveSyncConfig {
1154 save_dir: Some("/saves".into()),
1155 device_id: None,
1156 platform_dirs: HashMap::from([(7, "D:\\Saves\\Switch".into())]),
1157 },
1158 roms_layout: RomsLayoutConfig::default(),
1159 theme: default_theme_id(),
1160 };
1161 let json = serde_json::to_string(&cfg.save_sync).expect("serialize");
1162 assert!(json.contains("platform_dirs"));
1163 }
1164
1165 #[test]
1166 fn roms_dir_env_takes_precedence_over_legacy_download_dir_env() {
1167 let _env = TestEnv::new();
1168 std::env::set_var("API_BASE_URL", "http://example.test");
1169 std::env::set_var("ROMM_ROMS_DIR", "/preferred-roms");
1170 std::env::set_var("ROMM_DOWNLOAD_DIR", "/legacy-downloads");
1171
1172 let cfg = load_config().expect("config should load");
1173 assert_eq!(cfg.download_dir, "/preferred-roms");
1174 }
1175
1176 #[test]
1177 fn auth_for_persist_merge_prefers_in_memory() {
1178 let env = TestEnv::new();
1179 let on_disk = r#"{
1180 "base_url": "http://disk.test",
1181 "download_dir": "/tmp",
1182 "use_https": false,
1183 "auth": { "Bearer": { "token": "from-disk" } }
1184 }"#;
1185 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
1186
1187 let mem = Some(AuthConfig::Bearer {
1188 token: "from-memory".into(),
1189 });
1190 let merged = auth_for_persist_merge(mem.clone());
1191 assert_eq!(format!("{:?}", merged), format!("{:?}", mem));
1192 }
1193
1194 #[test]
1195 fn auth_for_persist_merge_falls_back_to_disk_when_memory_empty() {
1196 let env = TestEnv::new();
1197 let on_disk = r#"{
1198 "base_url": "http://disk.test",
1199 "download_dir": "/tmp",
1200 "use_https": false,
1201 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
1202 }"#;
1203 std::fs::write(env.config_dir.join("config.json"), on_disk).unwrap();
1204
1205 let merged = auth_for_persist_merge(None);
1206 match merged {
1207 Some(AuthConfig::Bearer { token }) => {
1208 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1209 }
1210 _ => panic!("expected bearer auth from disk"),
1211 }
1212 }
1213
1214 #[test]
1215 fn bearer_keyring_sentinel_without_keyring_entry_yields_no_auth() {
1216 let env = TestEnv::new();
1217 std::env::set_var("API_BASE_URL", "http://example.test");
1218 let config_json = r#"{
1219 "base_url": "http://example.test",
1220 "download_dir": "/tmp",
1221 "use_https": false,
1222 "auth": { "Bearer": { "token": "<stored-in-keyring>" } }
1223 }"#;
1224 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
1225
1226 let cfg = load_config().expect("load");
1227 assert!(
1228 cfg.auth.is_none(),
1229 "unresolved keyring sentinel must not become Bearer auth in Config"
1230 );
1231 assert!(disk_has_unresolved_keyring_sentinel(&cfg));
1232 }
1233
1234 #[test]
1235 fn bearer_token_from_romm_token_file() {
1236 let env = TestEnv::new();
1237 let token_path = env.config_dir.join("secret.token");
1238 std::fs::write(&token_path, " tok-from-file\n").unwrap();
1239 std::env::set_var("API_BASE_URL", "http://example.test");
1240 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1241
1242 let cfg = load_config().expect("load");
1243 match cfg.auth {
1244 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "tok-from-file"),
1245 _ => panic!("expected bearer from token file"),
1246 }
1247 }
1248
1249 #[test]
1250 fn api_token_env_wins_over_token_file() {
1251 let env = TestEnv::new();
1252 let token_path = env.config_dir.join("secret.token");
1253 std::fs::write(&token_path, "from-file").unwrap();
1254 std::env::set_var("API_BASE_URL", "http://example.test");
1255 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1256 std::env::set_var("API_TOKEN", "from-env");
1257
1258 let cfg = load_config().expect("load");
1259 match cfg.auth {
1260 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-env"),
1261 _ => panic!("expected env API_TOKEN to win"),
1262 }
1263 }
1264
1265 #[test]
1266 fn romm_token_file_overrides_json_bearer() {
1267 let env = TestEnv::new();
1268 let token_path = env.config_dir.join("secret.token");
1269 std::fs::write(&token_path, "from-file").unwrap();
1270 std::env::set_var("API_BASE_URL", "http://example.test");
1271 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1272 let config_json = r#"{
1273 "base_url": "http://example.test",
1274 "download_dir": "/tmp",
1275 "use_https": false,
1276 "auth": { "Bearer": { "token": "from-json" } }
1277 }"#;
1278 std::fs::write(env.config_dir.join("config.json"), config_json).unwrap();
1279
1280 let cfg = load_config().expect("load");
1281 match cfg.auth {
1282 Some(AuthConfig::Bearer { token }) => assert_eq!(token, "from-file"),
1283 _ => panic!("expected token file to override json"),
1284 }
1285 }
1286
1287 #[test]
1288 fn romm_token_file_missing_errors() {
1289 let env = TestEnv::new();
1290 let missing = env.config_dir.join("this-token-file-does-not-exist");
1291 std::env::set_var("API_BASE_URL", "http://example.test");
1292 std::env::set_var("ROMM_TOKEN_FILE", missing.to_str().unwrap());
1293
1294 let err = load_config().expect_err("missing token file should error");
1295 let msg = format!("{err:#}");
1296 assert!(
1297 msg.contains("read bearer token file"),
1298 "unexpected error: {msg}"
1299 );
1300 }
1301
1302 #[test]
1303 fn romm_token_file_empty_errors() {
1304 let env = TestEnv::new();
1305 let token_path = env.config_dir.join("empty.token");
1306 std::fs::write(&token_path, " \n\t ").unwrap();
1307 std::env::set_var("API_BASE_URL", "http://example.test");
1308 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1309
1310 let err = load_config().expect_err("empty token file should error");
1311 assert!(
1312 format!("{err:#}").contains("empty"),
1313 "unexpected error: {err:#}"
1314 );
1315 }
1316
1317 #[test]
1318 fn romm_token_file_too_large_errors() {
1319 let env = TestEnv::new();
1320 let token_path = env.config_dir.join("huge.token");
1321 std::fs::write(&token_path, vec![b'a'; MAX_TOKEN_FILE_BYTES + 1]).unwrap();
1322 std::env::set_var("API_BASE_URL", "http://example.test");
1323 std::env::set_var("ROMM_TOKEN_FILE", token_path.to_str().unwrap());
1324
1325 let err = load_config().expect_err("oversized token file should error");
1326 assert!(
1327 format!("{err:#}").contains("max size"),
1328 "unexpected error: {err:#}"
1329 );
1330 }
1331
1332 #[test]
1336 fn persist_user_config_preserves_sentinel_secrets_in_json() {
1337 let env = TestEnv::new();
1338 let path = env.config_dir.join("config.json");
1339
1340 persist_user_config(&Config {
1341 base_url: "https://updated.example".into(),
1342 download_dir: "/var/romm-dl".into(),
1343 use_https: true,
1344 auth: Some(AuthConfig::Bearer {
1345 token: KEYRING_SECRET_PLACEHOLDER.to_string(),
1346 }),
1347 extras_defaults: ExtrasDefaults::default(),
1348 save_sync: SaveSyncConfig::default(),
1349 roms_layout: RomsLayoutConfig::default(),
1350 theme: default_theme_id(),
1351 })
1352 .expect("persist bearer sentinel");
1353
1354 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1355 assert_eq!(cfg.base_url, "https://updated.example");
1356 assert_eq!(cfg.download_dir, "/var/romm-dl");
1357 assert!(cfg.use_https);
1358 match cfg.auth {
1359 Some(AuthConfig::Bearer { token }) => {
1360 assert_eq!(token, KEYRING_SECRET_PLACEHOLDER);
1361 }
1362 _ => panic!("expected bearer sentinel preserved in config.json"),
1363 }
1364
1365 persist_user_config(&Config {
1366 base_url: "https://apikey.example".into(),
1367 download_dir: "/dl".into(),
1368 use_https: false,
1369 auth: Some(AuthConfig::ApiKey {
1370 header: "X-Api-Key".into(),
1371 key: KEYRING_SECRET_PLACEHOLDER.to_string(),
1372 }),
1373 extras_defaults: ExtrasDefaults::default(),
1374 save_sync: SaveSyncConfig::default(),
1375 roms_layout: RomsLayoutConfig::default(),
1376 theme: default_theme_id(),
1377 })
1378 .expect("persist api key sentinel");
1379
1380 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1381 assert_eq!(cfg.base_url, "https://apikey.example");
1382 match cfg.auth {
1383 Some(AuthConfig::ApiKey { header, key }) => {
1384 assert_eq!(header, "X-Api-Key");
1385 assert_eq!(key, KEYRING_SECRET_PLACEHOLDER);
1386 }
1387 _ => panic!("expected api key sentinel preserved"),
1388 }
1389
1390 persist_user_config(&Config {
1391 base_url: "https://basic.example".into(),
1392 download_dir: "/dl".into(),
1393 use_https: true,
1394 auth: Some(AuthConfig::Basic {
1395 username: "alice".into(),
1396 password: KEYRING_SECRET_PLACEHOLDER.to_string(),
1397 }),
1398 extras_defaults: ExtrasDefaults::default(),
1399 save_sync: SaveSyncConfig::default(),
1400 roms_layout: RomsLayoutConfig::default(),
1401 theme: default_theme_id(),
1402 })
1403 .expect("persist basic password sentinel");
1404
1405 let cfg: Config = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
1406 assert_eq!(cfg.base_url, "https://basic.example");
1407 match cfg.auth {
1408 Some(AuthConfig::Basic { username, password }) => {
1409 assert_eq!(username, "alice");
1410 assert_eq!(password, KEYRING_SECRET_PLACEHOLDER);
1411 }
1412 _ => panic!("expected basic password sentinel preserved"),
1413 }
1414 }
1415
1416 #[test]
1417 fn should_check_updates_defaults_true_and_honors_false_values() {
1418 let _env = TestEnv::new();
1419 std::env::remove_var("ROMM_CHECK_UPDATES");
1420 assert!(should_check_updates());
1421
1422 for value in ["false", "FALSE", "0", "no", "off"] {
1423 std::env::set_var("ROMM_CHECK_UPDATES", value);
1424 assert!(
1425 !should_check_updates(),
1426 "expected ROMM_CHECK_UPDATES={value} to disable checks"
1427 );
1428 }
1429
1430 std::env::set_var("ROMM_CHECK_UPDATES", "true");
1431 assert!(should_check_updates());
1432 }
1433}