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