1use std::path::{Path, PathBuf};
8use std::process::{Command, ExitStatus, Stdio};
9use std::sync::{LazyLock, RwLock};
10
11use anyhow::{Context, Result};
12use serde_json::Value;
13use tracing::{debug, info, warn};
14
15use crate::registry::{GameRegistration, launcher_games};
16use modde_core::paths;
17use modde_core::resolver::GameId;
18
19static DETECTION_CACHE: LazyLock<RwLock<Option<Vec<DetectedGame>>>> =
20 LazyLock::new(|| RwLock::new(None));
21
22#[derive(Debug, Clone)]
24pub struct DetectedGame {
25 pub game_id: &'static str,
27 pub display_name: &'static str,
29 pub install_path: PathBuf,
31 pub source: LauncherSource,
33}
34
35#[derive(Debug, Clone)]
37pub enum LauncherSource {
38 Steam {
39 app_id: String,
40 library_path: PathBuf,
41 },
42 HeroicGog {
43 app_id: String,
44 },
45 HeroicEpic {
46 app_id: String,
47 },
48 HeroicSideload {
49 app_id: String,
50 },
51}
52
53impl LauncherSource {
54 fn label_and_id(&self) -> (&str, &str) {
55 match self {
56 LauncherSource::Steam { app_id, .. } => ("Steam", app_id),
57 LauncherSource::HeroicGog { app_id } => ("Heroic/GOG", app_id),
58 LauncherSource::HeroicEpic { app_id } => ("Heroic/Epic", app_id),
59 LauncherSource::HeroicSideload { app_id } => ("Heroic/Sideload", app_id),
60 }
61 }
62
63 pub fn launch(&self) -> Result<Option<ExitStatus>> {
68 match self {
69 LauncherSource::Steam { app_id, .. } => {
70 let url = format!("steam://rungameid/{app_id}");
71 info!(%url, "launching via Steam");
72 open::that(&url)
73 .with_context(|| format!("failed to launch Steam via URI ({url})"))?;
74 Ok(None)
75 }
76 LauncherSource::HeroicGog { app_id }
77 | LauncherSource::HeroicEpic { app_id }
78 | LauncherSource::HeroicSideload { app_id } => {
79 let (bin, base_args) = heroic_command()
80 .context("Heroic Games Launcher not found (checked flatpak and PATH)")?;
81 info!(%bin, %app_id, "launching via Heroic");
82 let mut cmd = Command::new(&bin);
83 for arg in &base_args {
84 cmd.arg(arg);
85 }
86 let status = cmd
87 .args(["--no-gui", "--launch", app_id])
88 .status()
89 .with_context(|| {
90 format!("failed to launch Heroic ({bin} --no-gui --launch {app_id})")
91 })?;
92 Ok(Some(status))
93 }
94 }
95 }
96}
97
98impl std::fmt::Display for LauncherSource {
99 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100 let (label, id) = self.label_and_id();
101 write!(f, "{label} ({id})")
102 }
103}
104
105fn heroic_command() -> Option<(String, Vec<String>)> {
114 #[cfg(target_os = "linux")]
115 {
116 if Command::new("flatpak")
118 .args(["info", "com.heroicgameslauncher.hgl"])
119 .stdout(Stdio::null())
120 .stderr(Stdio::null())
121 .status()
122 .ok()
123 .is_some_and(|s| s.success())
124 {
125 return Some((
126 "flatpak".to_string(),
127 vec!["run".to_string(), "com.heroicgameslauncher.hgl".to_string()],
128 ));
129 }
130
131 if let Ok(path) = which::which("heroic") {
133 return Some((path.to_string_lossy().to_string(), vec![]));
134 }
135
136 None
137 }
138
139 #[cfg(target_os = "macos")]
140 {
141 let app_path = "/Applications/Heroic.app/Contents/MacOS/Heroic";
142 if std::path::Path::new(app_path).exists() {
143 return Some((app_path.to_string(), vec![]));
144 }
145 if let Ok(path) = which::which("heroic") {
146 return Some((path.to_string_lossy().to_string(), vec![]));
147 }
148 None
149 }
150
151 #[cfg(target_os = "windows")]
152 {
153 if let Some(exe) = modde_core::paths::heroic_exe_path() {
154 return Some((exe.to_string_lossy().to_string(), vec![]));
155 }
156 if let Ok(path) = which::which("heroic") {
157 return Some((path.to_string_lossy().to_string(), vec![]));
158 }
159 None
160 }
161}
162
163#[must_use]
168pub fn find_detected_game(game_id: &GameId) -> Option<DetectedGame> {
169 cached_installed_games()
170 .into_iter()
171 .find(|g| game_id.as_str() == g.game_id)
172}
173
174#[must_use]
179pub fn scan_installed_games() -> Vec<DetectedGame> {
180 let mut detected = Vec::new();
181
182 scan_steam_libraries(&mut detected);
183 scan_heroic_stores(&mut detected);
184
185 update_detection_cache(&detected);
186
187 detected
188}
189
190fn cached_installed_games() -> Vec<DetectedGame> {
191 if let Ok(cache) = DETECTION_CACHE.read()
192 && let Some(detected) = cache.as_ref()
193 {
194 return detected.clone();
195 }
196
197 scan_installed_games()
198}
199
200fn update_detection_cache(detected: &[DetectedGame]) {
201 if let Ok(mut cache) = DETECTION_CACHE.write() {
202 *cache = Some(detected.to_vec());
203 }
204}
205
206fn scan_steam_libraries(detected: &mut Vec<DetectedGame>) {
208 let libraries = paths::steam_library_folders();
209
210 for lib_path in &libraries {
211 scan_steam_library(lib_path, detected);
212 }
213}
214
215fn scan_steam_library(lib_path: &Path, detected: &mut Vec<DetectedGame>) {
216 for steamapps_dir in steamapps_dir_candidates(lib_path) {
217 scan_steam_appmanifests(lib_path, &steamapps_dir, detected);
218 scan_steam_common_fallback(lib_path, &steamapps_dir, detected);
219 }
220}
221
222fn steamapps_dir_candidates(lib_path: &Path) -> Vec<PathBuf> {
223 let mut candidates = Vec::new();
224 push_unique_existing_dir(&mut candidates, lib_path.join("steamapps"));
225 push_unique_existing_dir(&mut candidates, lib_path.to_path_buf());
226 candidates
227}
228
229fn push_unique_existing_dir(paths: &mut Vec<PathBuf>, path: PathBuf) {
230 if path.is_dir() && !paths.iter().any(|p| p == &path) {
231 paths.push(path);
232 }
233}
234
235#[derive(Debug, Clone, PartialEq, Eq)]
236struct SteamAppManifest {
237 appid: String,
238 name: String,
239 installdir: String,
240}
241
242fn scan_steam_appmanifests(
243 library_path: &Path,
244 steamapps_dir: &Path,
245 detected: &mut Vec<DetectedGame>,
246) {
247 let manifests = match std::fs::read_dir(steamapps_dir) {
248 Ok(entries) => entries,
249 Err(e) => {
250 debug!(error = %e, path = %steamapps_dir.display(), "failed to read Steam library");
251 return;
252 }
253 };
254
255 for entry in manifests.flatten() {
256 let path = entry.path();
257 if !is_steam_appmanifest(&path) {
258 continue;
259 }
260 let content = match std::fs::read_to_string(&path) {
261 Ok(content) => content,
262 Err(e) => {
263 debug!(error = %e, path = %path.display(), "failed to read Steam appmanifest");
264 continue;
265 }
266 };
267 let Some(manifest) = parse_steam_appmanifest(&content) else {
268 debug!(path = %path.display(), "failed to parse Steam appmanifest");
269 continue;
270 };
271 let Some(game) = launcher_games()
272 .find(|game| game.launcher.steam_app_id == Some(manifest.appid.as_str()))
273 else {
274 continue;
275 };
276 let install_path = steamapps_dir.join("common").join(&manifest.installdir);
277 if install_path.is_dir() {
278 push_steam_detected_game(
279 detected,
280 game,
281 install_path,
282 manifest.appid,
283 library_path.to_path_buf(),
284 "detected Steam game from appmanifest",
285 );
286 } else {
287 debug!(
288 game_id = game.game_id,
289 app_id = %manifest.appid,
290 name = %manifest.name,
291 path = %install_path.display(),
292 "Steam appmanifest install path does not exist"
293 );
294 }
295 }
296}
297
298fn is_steam_appmanifest(path: &Path) -> bool {
299 let Some(file_name) = path.file_name().and_then(|name| name.to_str()) else {
300 return false;
301 };
302 file_name.starts_with("appmanifest_") && file_name.ends_with(".acf")
303}
304
305fn parse_steam_appmanifest(content: &str) -> Option<SteamAppManifest> {
306 let mut appid = None;
307 let mut name = None;
308 let mut installdir = None;
309
310 for line in content.lines() {
311 let Some((key, value)) = parse_vdf_key_value(line) else {
312 continue;
313 };
314 match key {
315 "appid" => appid = Some(value.to_string()),
316 "name" => name = Some(value.to_string()),
317 "installdir" => installdir = Some(value.to_string()),
318 _ => {}
319 }
320 }
321
322 Some(SteamAppManifest {
323 appid: appid?,
324 name: name?,
325 installdir: installdir?,
326 })
327}
328
329fn parse_vdf_key_value(line: &str) -> Option<(&str, &str)> {
330 let line = line.trim();
331 let rest = line.strip_prefix('"')?;
332 let key_end = rest.find('"')?;
333 let key = &rest[..key_end];
334 let rest = rest[key_end + 1..].trim_start();
335 let rest = rest.strip_prefix('"')?;
336 let value_end = rest.find('"')?;
337 Some((key, &rest[..value_end]))
338}
339
340fn scan_steam_common_fallback(
341 library_path: &Path,
342 steamapps_dir: &Path,
343 detected: &mut Vec<DetectedGame>,
344) {
345 let common_dir = steamapps_dir.join("common");
346 if !common_dir.is_dir() {
347 return;
348 }
349
350 for game in launcher_games() {
351 let Some(steam_dir) = game.launcher.steam_dir else {
352 continue;
353 };
354
355 let install_path = common_dir.join(steam_dir);
356 if install_path.is_dir() {
357 push_steam_detected_game(
358 detected,
359 game,
360 install_path,
361 game.launcher.steam_app_id.unwrap_or("unknown").to_string(),
362 library_path.to_path_buf(),
363 "detected Steam game from common directory fallback",
364 );
365 }
366 }
367}
368
369fn push_steam_detected_game(
370 detected: &mut Vec<DetectedGame>,
371 game: &GameRegistration,
372 install_path: PathBuf,
373 app_id: String,
374 library_path: PathBuf,
375 message: &'static str,
376) {
377 if detected
378 .iter()
379 .any(|detected| detected.game_id == game.game_id && detected.install_path == install_path)
380 {
381 return;
382 }
383
384 debug!(
385 game_id = game.game_id,
386 path = %install_path.display(),
387 message
388 );
389 detected.push(DetectedGame {
390 game_id: game.game_id,
391 display_name: game.display_name,
392 install_path,
393 source: LauncherSource::Steam {
394 app_id,
395 library_path,
396 },
397 });
398}
399
400fn scan_heroic_stores(detected: &mut Vec<DetectedGame>) {
402 let Some(heroic_dir) = paths::heroic_config_dir() else {
403 return;
404 };
405
406 scan_heroic_store_file(
408 &heroic_dir.join("gog_store/installed.json"),
409 |app_id| {
410 launcher_games()
411 .find(|g| g.launcher.heroic_gog_app_id == Some(app_id))
412 .map(|g| (g, HeroicStoreKind::Gog))
413 },
414 detected,
415 );
416
417 scan_heroic_store_file(
419 &heroic_dir.join("legendary_store/installed.json"),
420 |app_id| {
421 launcher_games()
422 .find(|g| g.launcher.heroic_epic_app_id == Some(app_id))
423 .map(|g| (g, HeroicStoreKind::Epic))
424 },
425 detected,
426 );
427
428 scan_heroic_sideload(&heroic_dir.join("sideload_apps/installed.json"), detected);
430}
431
432#[derive(Clone, Copy)]
433enum HeroicStoreKind {
434 Gog,
435 Epic,
436}
437
438fn scan_heroic_store_file(
440 path: &Path,
441 matcher: impl Fn(&str) -> Option<(&'static GameRegistration, HeroicStoreKind)>,
442 detected: &mut Vec<DetectedGame>,
443) {
444 let data = match std::fs::read_to_string(path) {
445 Ok(d) => d,
446 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
447 return;
448 }
449 Err(e) => {
450 debug!(error = %e, path = %path.display(), "failed to read Heroic store file");
451 return;
452 }
453 };
454
455 let parsed: Value = match serde_json::from_str(&data) {
456 Ok(v) => v,
457 Err(e) => {
458 warn!(error = %e, path = %path.display(), "failed to parse Heroic store JSON");
459 return;
460 }
461 };
462
463 let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
464 debug!(path = %path.display(), "Heroic store file missing 'installed' array");
465 return;
466 };
467
468 for entry in installed {
469 let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
470 continue;
471 };
472 let Some(install_path) = entry.get("install_path").and_then(|v| v.as_str()) else {
473 continue;
474 };
475
476 let install_path = PathBuf::from(install_path);
477 if !install_path.is_dir() {
478 continue;
479 }
480
481 if let Some((game, kind)) = matcher(app_name) {
482 debug!(
483 game_id = game.game_id,
484 app_name,
485 path = %install_path.display(),
486 "detected Heroic game"
487 );
488 let source = match kind {
489 HeroicStoreKind::Gog => LauncherSource::HeroicGog {
490 app_id: game
491 .launcher
492 .heroic_gog_app_id
493 .unwrap_or(app_name)
494 .to_string(),
495 },
496 HeroicStoreKind::Epic => LauncherSource::HeroicEpic {
497 app_id: game
498 .launcher
499 .heroic_epic_app_id
500 .unwrap_or(app_name)
501 .to_string(),
502 },
503 };
504 detected.push(DetectedGame {
505 game_id: game.game_id,
506 display_name: game.display_name,
507 install_path,
508 source,
509 });
510 }
511 }
512}
513
514fn scan_heroic_sideload(path: &Path, detected: &mut Vec<DetectedGame>) {
516 let data = match std::fs::read_to_string(path) {
517 Ok(d) => d,
518 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
519 return;
520 }
521 Err(e) => {
522 debug!(error = %e, path = %path.display(), "failed to read Heroic sideload file");
523 return;
524 }
525 };
526
527 let parsed: Value = match serde_json::from_str(&data) {
528 Ok(v) => v,
529 Err(e) => {
530 warn!(error = %e, path = %path.display(), "failed to parse Heroic sideload JSON");
531 return;
532 }
533 };
534
535 let Some(installed) = parsed.get("installed").and_then(|v| v.as_array()) else {
536 debug!(path = %path.display(), "Heroic sideload file missing 'installed' array");
537 return;
538 };
539
540 for entry in installed {
541 let Some(app_name) = entry.get("appName").and_then(|v| v.as_str()) else {
542 continue;
543 };
544 let Some(install_path_str) = entry.get("install_path").and_then(|v| v.as_str()) else {
545 continue;
546 };
547
548 let install_path = PathBuf::from(install_path_str);
549 if !install_path.is_dir() {
550 continue;
551 }
552
553 let dir_name = install_path
555 .file_name()
556 .and_then(|n| n.to_str())
557 .unwrap_or("");
558
559 for game in launcher_games() {
560 let matches = game
561 .launcher
562 .steam_dir
563 .is_some_and(|sd| sd.eq_ignore_ascii_case(dir_name));
564
565 if matches {
566 debug!(
567 game_id = game.game_id,
568 app_name,
569 path = %install_path.display(),
570 "detected Heroic sideloaded game"
571 );
572 detected.push(DetectedGame {
573 game_id: game.game_id,
574 display_name: game.display_name,
575 install_path,
576 source: LauncherSource::HeroicSideload {
577 app_id: app_name.to_string(),
578 },
579 });
580 break;
581 }
582 }
583 }
584}
585
586#[must_use]
591pub fn find_game_install(game_id: &GameId) -> Option<PathBuf> {
592 let settings = modde_core::settings::AppSettings::load();
594 if let Some(path) = settings.game_path(game_id)
595 && path.is_dir()
596 {
597 return Some(path.clone());
598 }
599
600 cached_installed_games()
603 .into_iter()
604 .find(|g| game_id.as_str() == g.game_id)
605 .map(|g| g.install_path)
606}
607
608#[cfg(test)]
609mod tests {
610 use super::*;
611 fn write_heroic_installed(dir: &std::path::Path, entries: &[(&str, &str)]) {
614 let items: Vec<serde_json::Value> = entries
615 .iter()
616 .map(|(app_name, install_path)| {
617 serde_json::json!({
618 "appName": app_name,
619 "install_path": install_path,
620 })
621 })
622 .collect();
623 let json = serde_json::json!({ "installed": items });
624 std::fs::write(dir, serde_json::to_string(&json).unwrap()).unwrap();
625 }
626
627 #[test]
628 fn scan_heroic_gog_detects_known_game() {
629 let tmp = tempfile::tempdir().unwrap();
630 let install_dir = tmp.path().join("cyberpunk");
631 std::fs::create_dir_all(&install_dir).unwrap();
632
633 let store_file = tmp.path().join("installed.json");
634 write_heroic_installed(
635 &store_file,
636 &[("1423049311", &install_dir.to_string_lossy())],
637 );
638
639 let mut detected = Vec::new();
640 scan_heroic_store_file(
641 &store_file,
642 |app_id| {
643 launcher_games()
644 .find(|g| g.launcher.heroic_gog_app_id == Some(app_id))
645 .map(|g| (g, HeroicStoreKind::Gog))
646 },
647 &mut detected,
648 );
649
650 assert_eq!(detected.len(), 1);
651 assert_eq!(detected[0].game_id, "cyberpunk2077");
652 assert_eq!(detected[0].install_path, install_dir);
653 assert!(matches!(
654 detected[0].source,
655 LauncherSource::HeroicGog { .. }
656 ));
657 }
658
659 #[test]
660 fn scan_heroic_gog_unknown_game_ignored() {
661 let tmp = tempfile::tempdir().unwrap();
662 let install_dir = tmp.path().join("some_game");
663 std::fs::create_dir_all(&install_dir).unwrap();
664
665 let store_file = tmp.path().join("installed.json");
666 write_heroic_installed(
667 &store_file,
668 &[("9999999999", &install_dir.to_string_lossy())],
669 );
670
671 let mut detected = Vec::new();
672 scan_heroic_store_file(
673 &store_file,
674 |app_id| {
675 launcher_games()
676 .find(|g| g.launcher.heroic_gog_app_id == Some(app_id))
677 .map(|g| (g, HeroicStoreKind::Gog))
678 },
679 &mut detected,
680 );
681
682 assert_eq!(detected.len(), 0, "unknown game should not be added");
683 }
684
685 #[test]
686 fn scan_heroic_nonexistent_install_path_skipped() {
687 let tmp = tempfile::tempdir().unwrap();
688 let store_file = tmp.path().join("installed.json");
689 write_heroic_installed(&store_file, &[("1423049311", "/nonexistent/cyberpunk")]);
691
692 let mut detected = Vec::new();
693 scan_heroic_store_file(
694 &store_file,
695 |app_id| {
696 launcher_games()
697 .find(|g| g.launcher.heroic_gog_app_id == Some(app_id))
698 .map(|g| (g, HeroicStoreKind::Gog))
699 },
700 &mut detected,
701 );
702
703 assert_eq!(
704 detected.len(),
705 0,
706 "nonexistent install path should be skipped"
707 );
708 }
709
710 #[test]
711 fn scan_heroic_missing_file_is_no_op() {
712 let mut detected = Vec::new();
713 scan_heroic_store_file(
715 std::path::Path::new("/nonexistent/installed.json"),
716 |_| None,
717 &mut detected,
718 );
719 assert_eq!(detected.len(), 0);
720 }
721
722 #[test]
723 fn scan_heroic_malformed_json_is_no_op() {
724 let tmp = tempfile::tempdir().unwrap();
725 let store_file = tmp.path().join("installed.json");
726 std::fs::write(&store_file, "this is not json").unwrap();
727
728 let mut detected = Vec::new();
729 scan_heroic_store_file(&store_file, |_| None, &mut detected);
730 assert_eq!(detected.len(), 0);
731 }
732
733 #[test]
734 fn scan_heroic_empty_installed_array() {
735 let tmp = tempfile::tempdir().unwrap();
736 let store_file = tmp.path().join("installed.json");
737 std::fs::write(&store_file, r#"{"installed":[]}"#).unwrap();
738
739 let mut detected = Vec::new();
740 scan_heroic_store_file(&store_file, |_| None, &mut detected);
741 assert_eq!(detected.len(), 0);
742 }
743
744 #[test]
745 fn scan_heroic_sideload_matches_by_dirname() {
746 let tmp = tempfile::tempdir().unwrap();
747 let install_dir = tmp.path().join("Cyberpunk 2077");
749 std::fs::create_dir_all(&install_dir).unwrap();
750
751 let store_file = tmp.path().join("installed.json");
752 write_heroic_installed(
753 &store_file,
754 &[("some_sideload_id", &install_dir.to_string_lossy())],
755 );
756
757 let mut detected = Vec::new();
758 scan_heroic_sideload(&store_file, &mut detected);
759
760 assert_eq!(detected.len(), 1);
761 assert_eq!(detected[0].game_id, "cyberpunk2077");
762 assert!(matches!(
763 detected[0].source,
764 LauncherSource::HeroicSideload { .. }
765 ));
766 }
767
768 #[test]
769 fn scan_heroic_sideload_unknown_dirname_ignored() {
770 let tmp = tempfile::tempdir().unwrap();
771 let install_dir = tmp.path().join("Some Unknown Game 2077");
772 std::fs::create_dir_all(&install_dir).unwrap();
773
774 let store_file = tmp.path().join("installed.json");
775 write_heroic_installed(&store_file, &[("some_id", &install_dir.to_string_lossy())]);
776
777 let mut detected = Vec::new();
778 scan_heroic_sideload(&store_file, &mut detected);
779
780 assert_eq!(detected.len(), 0);
781 }
782
783 #[test]
784 fn scan_heroic_sideload_missing_file_is_no_op() {
785 let mut detected = Vec::new();
786
787 scan_heroic_sideload(
788 std::path::Path::new("/nonexistent/installed.json"),
789 &mut detected,
790 );
791
792 assert_eq!(detected.len(), 0);
793 }
794
795 fn write_steam_appmanifest(
798 steamapps_dir: &std::path::Path,
799 appid: &str,
800 name: &str,
801 installdir: &str,
802 ) -> PathBuf {
803 let manifest = format!(
804 r#""AppState"
805{{
806 "appid" "{appid}"
807 "Universe" "1"
808 "name" "{name}"
809 "StateFlags" "4"
810 "installdir" "{installdir}"
811}}
812"#
813 );
814 let path = steamapps_dir.join(format!("appmanifest_{appid}.acf"));
815 std::fs::write(&path, manifest).unwrap();
816 path
817 }
818
819 #[test]
820 fn parse_steam_appmanifest_reads_required_fields() {
821 let content = r#""AppState"
822{
823 "appid" "3489700"
824 "Universe" "1"
825 "name" "Stellar Blade™"
826 "StateFlags" "4"
827 "installdir" "StellarBlade"
828}
829"#;
830
831 let manifest = parse_steam_appmanifest(content).unwrap();
832
833 assert_eq!(
834 manifest,
835 SteamAppManifest {
836 appid: "3489700".to_string(),
837 name: "Stellar Blade™".to_string(),
838 installdir: "StellarBlade".to_string(),
839 }
840 );
841 }
842
843 #[test]
844 fn scan_steam_library_detects_manifest_installdir_in_standard_library() {
845 let tmp = tempfile::tempdir().unwrap();
846 let steamapps = tmp.path().join("steamapps");
847 let install_path = steamapps.join("common/StellarBlade");
848 std::fs::create_dir_all(&install_path).unwrap();
849 write_steam_appmanifest(&steamapps, "3489700", "Stellar Blade™", "StellarBlade");
850
851 let mut detected = Vec::new();
852 scan_steam_library(tmp.path(), &mut detected);
853
854 assert_eq!(detected.len(), 1);
855 assert_eq!(detected[0].game_id, "stellar-blade");
856 assert_eq!(detected[0].install_path, install_path);
857 assert!(matches!(
858 detected[0].source,
859 LauncherSource::Steam { ref app_id, .. } if app_id == "3489700"
860 ));
861 }
862
863 #[test]
864 fn scan_steam_library_detects_manifest_installdir_in_nested_steamapps_library() {
865 let tmp = tempfile::tempdir().unwrap();
866 let reported_library = tmp.path().join("steamapps");
867 let steamapps = reported_library.join("steamapps");
868 let install_path = steamapps.join("common/StellarBlade");
869 std::fs::create_dir_all(&install_path).unwrap();
870 write_steam_appmanifest(&steamapps, "3489700", "Stellar Blade™", "StellarBlade");
871
872 let mut detected = Vec::new();
873 scan_steam_library(&reported_library, &mut detected);
874
875 assert_eq!(detected.len(), 1);
876 assert_eq!(detected[0].game_id, "stellar-blade");
877 assert_eq!(detected[0].install_path, install_path);
878 }
879
880 #[test]
881 fn scan_steam_library_uses_manifest_installdir_not_known_steam_dir() {
882 let tmp = tempfile::tempdir().unwrap();
883 let steamapps = tmp.path().join("steamapps");
884 let install_path = steamapps.join("common/StellarBlade");
885 std::fs::create_dir_all(&install_path).unwrap();
886 write_steam_appmanifest(&steamapps, "3489700", "Stellar Blade™", "StellarBlade");
887
888 assert_eq!(
889 launcher_games()
890 .find(|g| g.game_id == "stellar-blade")
891 .unwrap()
892 .launcher
893 .steam_dir,
894 Some("Stellar Blade")
895 );
896
897 let mut detected = Vec::new();
898 scan_steam_library(tmp.path(), &mut detected);
899
900 assert_eq!(detected.len(), 1);
901 assert_eq!(detected[0].install_path, install_path);
902 }
903
904 #[test]
907 fn launcher_source_display_steam() {
908 let src = LauncherSource::Steam {
909 app_id: "1091500".to_string(),
910 library_path: PathBuf::from("/games"),
911 };
912 assert_eq!(src.to_string(), "Steam (1091500)");
913 }
914
915 #[test]
916 fn launcher_source_display_heroic_gog() {
917 let src = LauncherSource::HeroicGog {
918 app_id: "1423049311".to_string(),
919 };
920 assert_eq!(src.to_string(), "Heroic/GOG (1423049311)");
921 }
922
923 #[test]
924 fn launcher_source_display_heroic_epic() {
925 let src = LauncherSource::HeroicEpic {
926 app_id: "Ginger".to_string(),
927 };
928 assert_eq!(src.to_string(), "Heroic/Epic (Ginger)");
929 }
930
931 #[test]
932 fn launcher_source_display_sideload() {
933 let src = LauncherSource::HeroicSideload {
934 app_id: "custom_app".to_string(),
935 };
936 assert_eq!(src.to_string(), "Heroic/Sideload (custom_app)");
937 }
938
939 #[test]
942 fn launcher_game_ids_are_unique() {
943 let ids: Vec<_> = launcher_games().map(|g| g.game_id).collect();
944 let deduped: std::collections::HashSet<_> = ids.iter().collect();
945 assert_eq!(
946 ids.len(),
947 deduped.len(),
948 "launcher registry has duplicate game_ids"
949 );
950 }
951
952 #[test]
953 fn launcher_registry_includes_detectable_supported_games() {
954 use crate::SUPPORTED_GAME_IDS;
955 for &game_id in SUPPORTED_GAME_IDS.iter().filter(|g| **g != "skyrim-ae")
956 {
958 if ["skyrim-se", "fallout4", "cyberpunk2077"].contains(&game_id) {
959 assert!(
960 launcher_games().any(|g| g.game_id == game_id),
961 "launcher registry missing {game_id}"
962 );
963 }
964 }
965 }
966}