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