steamlocate 2.0.0-beta.4

Crate for locating Steam game installation directories (and Steam itself!)
Documentation
//! Some test helpers for setting up isolated dummy steam installations.

use std::{
    collections::BTreeMap,
    fs, iter,
    path::{Path, PathBuf},
};

use super::{temp::TempDir, TestError};
use crate::SteamDir;

use serde::Serialize;

pub fn expect_test_env() -> TempSteamDir {
    TempSteamDir::builder()
        .app(SampleApp::GarrysMod.into())
        .app(SampleApp::Warframe.into())
        .library(SampleApp::GraveyardKeeper.try_into().unwrap())
        .finish()
        .unwrap()
}

// TODO(cosmic): Add in functionality for providing shortcuts too
pub struct TempSteamDir {
    steam_dir: crate::SteamDir,
    _tmps: Vec<TempDir>,
}

impl TryFrom<AppFile> for TempSteamDir {
    type Error = TestError;

    fn try_from(app: AppFile) -> Result<Self, Self::Error> {
        Self::builder().app(app).finish()
    }
}

impl TryFrom<SampleApp> for TempSteamDir {
    type Error = TestError;

    fn try_from(sample_app: SampleApp) -> Result<Self, Self::Error> {
        Self::try_from(AppFile::from(sample_app))
    }
}

impl TryFrom<SampleShortcuts> for TempSteamDir {
    type Error = TestError;

    fn try_from(sample_shortcuts: SampleShortcuts) -> Result<Self, Self::Error> {
        Self::builder().shortcuts(sample_shortcuts).finish()
    }
}

impl TempSteamDir {
    pub fn builder() -> TempSteamDirBuilder {
        TempSteamDirBuilder::default()
    }

    pub fn steam_dir(&self) -> &SteamDir {
        &self.steam_dir
    }
}

#[derive(Default)]
#[must_use]
pub struct TempSteamDirBuilder {
    shortcuts: Option<SampleShortcuts>,
    libraries: Vec<TempLibrary>,
    apps: Vec<AppFile>,
}

impl TempSteamDirBuilder {
    pub fn shortcuts(mut self, shortcuts: SampleShortcuts) -> Self {
        self.shortcuts = Some(shortcuts);
        self
    }

    pub fn app(mut self, app: AppFile) -> Self {
        self.apps.push(app);
        self
    }

    pub fn library(mut self, library: TempLibrary) -> Self {
        self.libraries.push(library);
        self
    }

    // Steam dir is also a library, but is laid out slightly differently than a regular library
    pub fn finish(self) -> Result<TempSteamDir, TestError> {
        let Self {
            shortcuts,
            libraries,
            apps,
        } = self;

        let tmp = TempDir::new()?;
        let root_dir = tmp.path().join("test-steam-dir");
        let steam_dir = root_dir.join("Steam");
        let apps_dir = steam_dir.join("steamapps");
        fs::create_dir_all(&apps_dir)?;
        let shortcuts_dir = steam_dir.join("userdata").join("123123123").join("config");
        fs::create_dir_all(&shortcuts_dir)?;

        if let Some(shortcuts) = shortcuts {
            let data = shortcuts.data();
            let shortcuts_file = shortcuts_dir.join("shortcuts.vdf");
            fs::write(&shortcuts_file, data)?;
        }

        setup_steamapps_dir(&apps_dir, &apps)?;

        let steam_dir_content_id = i32::MIN;
        let apps = apps.iter().map(|app| (app.id, 0)).collect();
        let root_library =
            LibraryFolder::mostly_default(steam_dir.clone(), steam_dir_content_id, apps);
        setup_libraryfolders_file(&apps_dir, root_library, &libraries)?;

        let tmps = iter::once(tmp)
            .chain(libraries.into_iter().map(|library| library._tmp))
            .collect();

        Ok(TempSteamDir {
            steam_dir: SteamDir::from_dir(&steam_dir)?,
            _tmps: tmps,
        })
    }
}

fn setup_steamapps_dir(apps_dir: &Path, apps: &[AppFile]) -> Result<(), TestError> {
    let apps_common_dir = apps_dir.join("common");
    fs::create_dir_all(&apps_common_dir)?;

    for app in apps {
        let manifest_path = apps_dir.join(app.file_name());
        fs::write(&manifest_path, &app.contents)?;
        let app_install_dir = apps_common_dir.join(&app.install_dir);
        fs::create_dir_all(&app_install_dir)?;
    }

    Ok(())
}

fn setup_libraryfolders_file(
    apps_dir: &Path,
    root_library: LibraryFolder,
    aux_libraries: &[TempLibrary],
) -> Result<(), TestError> {
    let library_folders =
        iter::once(root_library).chain(aux_libraries.iter().map(|temp_library| {
            LibraryFolder::mostly_default(
                temp_library.path.clone(),
                temp_library.content_id,
                temp_library.apps.clone(),
            )
        }));
    let inner: BTreeMap<u32, LibraryFolder> = library_folders
        .into_iter()
        .enumerate()
        .map(|(i, f)| (i.try_into().unwrap(), f))
        .collect();
    let library_folders_contents =
        keyvalues_serde::to_string_with_key(&inner, "libraryfolders").unwrap();
    let library_folders_path = apps_dir.join("libraryfolders.vdf");
    fs::write(library_folders_path, library_folders_contents)?;

    Ok(())
}

#[derive(Serialize)]
struct LibraryFolder {
    path: PathBuf,
    label: String,
    contentid: i32,
    totalsize: u64,
    update_clean_bytes_tally: u64,
    time_last_update_corruption: u64,
    apps: BTreeMap<u32, u64>,
}

impl LibraryFolder {
    fn mostly_default(path: PathBuf, contentid: i32, apps: BTreeMap<u32, u64>) -> Self {
        let totalsize = apps.values().sum();
        Self {
            path,
            contentid,
            apps,
            totalsize,
            label: String::default(),
            update_clean_bytes_tally: 79_799_828_443,
            time_last_update_corruption: 0,
        }
    }
}

pub struct TempLibrary {
    content_id: i32,
    path: PathBuf,
    apps: BTreeMap<u32, u64>,
    _tmp: TempDir,
}

impl TryFrom<AppFile> for TempLibrary {
    type Error = TestError;

    fn try_from(app: AppFile) -> Result<Self, Self::Error> {
        Self::builder().app(app).finish()
    }
}

impl TryFrom<SampleApp> for TempLibrary {
    type Error = TestError;

    fn try_from(sample_app: SampleApp) -> Result<Self, Self::Error> {
        Self::try_from(AppFile::from(sample_app))
    }
}

impl TempLibrary {
    pub fn builder() -> TempLibraryBuilder {
        TempLibraryBuilder::default()
    }
}

#[derive(Default)]
#[must_use]
pub struct TempLibraryBuilder {
    apps: Vec<AppFile>,
}

impl TempLibraryBuilder {
    fn app(mut self, app: AppFile) -> Self {
        self.apps.push(app);
        self
    }

    fn finish(self) -> Result<TempLibrary, TestError> {
        let tmp = TempDir::new()?;
        let root_dir = tmp.path().join("test-library");
        let apps_dir = root_dir.join("steamapps");
        fs::create_dir_all(&apps_dir)?;

        let meta_path = apps_dir.join("libraryfolder.vdf");
        fs::write(
            meta_path,
            include_str!("../../tests/assets/libraryfolder.vdf"),
        )?;

        setup_steamapps_dir(&apps_dir, &self.apps)?;
        let apps = self.apps.iter().map(|app| (app.id, 0)).collect();

        Ok(TempLibrary {
            content_id: 1234,
            path: root_dir,
            apps,
            _tmp: tmp,
        })
    }
}

pub struct AppFile {
    id: u32,
    install_dir: String,
    contents: String,
}

impl From<SampleApp> for AppFile {
    fn from(sample: SampleApp) -> Self {
        Self {
            id: sample.id(),
            install_dir: sample.install_dir().to_owned(),
            contents: sample.contents().to_owned(),
        }
    }
}

impl AppFile {
    fn file_name(&self) -> String {
        format!("appmanifest_{}.acf", self.id)
    }
}

#[derive(Clone, Copy)]
pub enum SampleApp {
    GarrysMod,
    GraveyardKeeper,
    Resonite,
    Warframe,
}

impl SampleApp {
    pub const fn id(&self) -> u32 {
        self.data().0
    }

    pub const fn install_dir(&self) -> &'static str {
        self.data().1
    }

    pub const fn contents(&self) -> &'static str {
        self.data().2
    }

    pub const fn data(&self) -> (u32, &'static str, &'static str) {
        match self {
            Self::GarrysMod => (
                4_000,
                "GarrysMod",
                include_str!("../../tests/assets/appmanifest_4000.acf"),
            ),
            Self::GraveyardKeeper => (
                599_140,
                "Graveyard Keeper",
                include_str!("../../tests/assets/appmanifest_599140.acf"),
            ),
            Self::Resonite => (
                2_519_830,
                "Resonite",
                include_str!("../../tests/assets/appmanifest_2519830.acf"),
            ),
            Self::Warframe => (
                230_410,
                "Warframe",
                include_str!("../../tests/assets/appmanifest_230410.acf"),
            ),
        }
    }
}

pub enum SampleShortcuts {
    JustGogMoonlighter,
}

impl SampleShortcuts {
    pub const fn data(&self) -> &'static [u8] {
        match self {
            Self::JustGogMoonlighter => {
                include_bytes!("../../tests/sample_data/shortcuts_just_gog_moonlighter.vdf")
            }
        }
    }
}

#[cfg(test)]
mod test {
    use super::*;
    use crate::__private_tests::TestResult;

    #[test]
    fn sanity() -> TestResult {
        let tmp_steam_dir = TempSteamDir::try_from(SampleApp::GarrysMod)?;
        let steam_dir = tmp_steam_dir.steam_dir();
        assert!(steam_dir
            .find_app(SampleApp::GarrysMod.id())
            .unwrap()
            .is_some());

        Ok(())
    }
}