Skip to main content

modde_games/
detection.rs

1//! Unified game detection across Steam and Heroic launchers.
2//!
3//! Scans all known launcher libraries and returns every detected game
4//! installation, including launcher metadata. This allows the UI and CLI
5//! to present a "pick your game" experience without manual path entry.
6
7use 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/// A game installation detected by scanning launcher libraries.
23#[derive(Debug, Clone)]
24pub struct DetectedGame {
25    /// The modde `game_id` (e.g. "skyrim-se", "cyberpunk2077").
26    pub game_id: &'static str,
27    /// Human-readable display name.
28    pub display_name: &'static str,
29    /// Absolute path to the game's install directory.
30    pub install_path: PathBuf,
31    /// Which launcher owns this installation.
32    pub source: LauncherSource,
33}
34
35/// Which launcher/store a detected game belongs to.
36#[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    /// Launch the game via its detected launcher.
64    ///
65    /// Returns `Ok(Some(ExitStatus))` if we could wait for the game process to exit
66    /// (Heroic), or `Ok(None)` for fire-and-forget launchers (Steam).
67    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
105/// Detect the Heroic Games Launcher binary.
106///
107/// - Linux: checks flatpak first, then native binary on `$PATH`
108/// - macOS: checks `/Applications/Heroic.app`, then `$PATH`
109/// - Windows: checks standard install path, then `%PATH%`
110///
111/// Returns `(binary, base_args)` — e.g. `("flatpak", ["run", "com.heroicgameslauncher.hgl"])`
112/// or `("heroic", [])`.
113fn heroic_command() -> Option<(String, Vec<String>)> {
114    #[cfg(target_os = "linux")]
115    {
116        // Check flatpak first (common on NixOS / immutable distros)
117        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        // Check native binary on PATH
132        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/// Find a detected game by its modde `game_id`.
164///
165/// Convenience wrapper that returns the first match from the latest detection
166/// scan, performing one if no cached result exists yet.
167#[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/// Scan all known launchers for installed games.
175///
176/// Returns every detected game with its install path and launcher source.
177/// A game may appear multiple times if installed via different launchers.
178#[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
206/// Scan all Steam library folders for known games.
207fn 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
400/// Scan Heroic's installed game databases (GOG, Epic/Legendary, Sideload).
401fn scan_heroic_stores(detected: &mut Vec<DetectedGame>) {
402    let Some(heroic_dir) = paths::heroic_config_dir() else {
403        return;
404    };
405
406    // GOG store
407    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    // Epic/Legendary store
418    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    // Sideloaded apps — match by directory name heuristic
429    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
438/// Parse a Heroic `installed.json` and match entries against known games.
439fn 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
514/// Scan Heroic sideloaded apps — match by directory name against known `steam_dir` names.
515fn 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        // Try to match by directory name
554        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/// Find the install path for a specific game by scanning all launchers.
587///
588/// This is used by `GamePlugin::detect_install()` implementations to check
589/// all available sources instead of just hardcoded paths.
590#[must_use]
591pub fn find_game_install(game_id: &GameId) -> Option<PathBuf> {
592    // Check settings override first
593    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    // Use the latest scan when available to avoid repeatedly scanning every
601    // launcher while the UI resolves supported games one by one.
602    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    // ── Heroic store file parsing ─────────────────────────────────────
612
613    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        // Path does not exist on disk
690        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        // Should not panic
714        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        // Create a dir named like the Cyberpunk Steam dir
748        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    // ── Steam library scanning ────────────────────────────────────────
796
797    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    // ── LauncherSource display ────────────────────────────────────────
905
906    #[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    // ── Registry integrity ────────────────────────────────────────────
940
941    #[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        // AE intentionally shares SE's steam dir
957        {
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}