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