diff --git a/Cargo.toml b/Cargo.toml index 5926627..f3dd35b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,3 +28,4 @@ dirs = "5" [dev-dependencies] insta = { version = "1.34.0", features = ["redactions", "ron"] } +tempfile = "3.8.1" diff --git a/src/__test_helpers.rs b/src/__test_helpers.rs new file mode 100644 index 0000000..8469892 --- /dev/null +++ b/src/__test_helpers.rs @@ -0,0 +1,351 @@ +//! NOT PART OF THE PUBLIC API +//! +//! Some test helpers for setting up isolated dummy steam installations. +//! +//! Publicly accessible so that we can use them in unit, doc, and integration tests. + +// TODO: add a test with an env var flag that runs against your real local steam installation? + +use std::{ + collections::BTreeMap, + convert::{TryFrom, TryInto}, + fs, iter, + marker::PhantomData, + path::{Path, PathBuf}, +}; + +use crate::SteamDir; + +use serde::Serialize; + +// A little bit of a headache. We want to use tempdirs for isolating the dummy steam installations, +// but we can't specify a `cfg` that includes integration tests while also allowing for naming a +// `dev-dependency` here. Instead we abstract the functionality behind a trait and every dependent +// can provide their own concrete implementation. It makes for a bit of a mess unfortunately, but +// it's either this or add a feature that's only used internally for testing which I don't like +// even more. +pub trait TempDir: Sized { + fn new() -> Result; + fn path(&self) -> PathBuf; +} + +#[cfg(test)] +pub struct TestTempDir(tempfile::TempDir); + +#[cfg(test)] +impl TempDir for TestTempDir { + fn new() -> Result { + let mut builder = tempfile::Builder::new(); + builder.prefix("steamlocate-test-"); + let temp_dir = builder.tempdir()?; + Ok(Self(temp_dir)) + } + + fn path(&self) -> PathBuf { + self.0.path().to_owned() + } +} + +pub type TestError = Box; +pub type TestResult = Result<(), TestError>; + +// TODO(cosmic): Add in functionality for providing shortcuts too +pub struct TempSteamDir { + steam_dir: crate::SteamDir, + _tmps: Vec, +} + +impl TryFrom for TempSteamDir { + type Error = TestError; + + fn try_from(app: AppFile) -> Result { + Self::builder().app(app).finish() + } +} + +impl TryFrom for TempSteamDir { + type Error = TestError; + + fn try_from(sample_app: SampleApp) -> Result { + Self::try_from(AppFile::from(sample_app)) + } +} + +impl TempSteamDir { + pub fn builder() -> TempSteamDirBuilder { + TempSteamDirBuilder::new() + } + + pub fn steam_dir(&self) -> &SteamDir { + &self.steam_dir + } +} + +#[must_use] +pub struct TempSteamDirBuilder { + libraries: Vec>, + apps: Vec, +} + +impl Default for TempSteamDirBuilder { + fn default() -> Self { + Self { + libraries: Vec::default(), + apps: Vec::default(), + } + } +} + +impl TempSteamDirBuilder { + pub fn new() -> Self { + Self::default() + } + + 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, TestError> + where + TmpDir: TempDir, + { + let tmp = TmpDir::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)?; + + setup_steamapps_dir(&apps_dir, &self.apps)?; + + let steam_dir_content_id = i32::MIN; + let apps = self.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, &self.libraries)?; + + let tmps = iter::once(tmp) + .chain(self.libraries.into_iter().map(|library| library._tmp)) + .collect(); + + Ok(TempSteamDir { + steam_dir: SteamDir::from_steam_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 = 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, +} + +impl LibraryFolder { + fn mostly_default(path: PathBuf, contentid: i32, apps: BTreeMap) -> 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, + _tmp: TmpDir, +} + +impl TryFrom for TempLibrary { + type Error = TestError; + + fn try_from(app: AppFile) -> Result { + Self::builder().app(app).finish() + } +} + +impl TryFrom for TempLibrary { + type Error = TestError; + + fn try_from(sample_app: SampleApp) -> Result { + Self::try_from(AppFile::from(sample_app)) + } +} + +impl TempLibrary { + pub fn builder() -> TempLibraryBuilder { + TempLibraryBuilder::new() + } +} + +#[must_use] +pub struct TempLibraryBuilder { + apps: Vec, + temp_dir_type: PhantomData, +} + +impl Default for TempLibraryBuilder { + fn default() -> Self { + Self { + apps: Vec::default(), + temp_dir_type: PhantomData, + } + } +} + +impl TempLibraryBuilder { + fn new() -> Self { + Self::default() + } + + fn app(mut self, app: AppFile) -> Self { + self.apps.push(app); + self + } + + fn finish(self) -> Result, TestError> + where + TmpDir: TempDir, + { + let tmp = TmpDir::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 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) + } +} + +pub enum SampleApp { + GarrysMod, + GraveyardKeeper, +} + +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"), + ), + } + } +} + +#[test] +fn sanity() -> TestResult { + let tmp_steam_dir = TempSteamDir::::try_from(SampleApp::GarrysMod)?; + let steam_dir = tmp_steam_dir.steam_dir(); + assert!(steam_dir.app(SampleApp::GarrysMod.id()).unwrap().is_some()); + + Ok(()) +} diff --git a/src/error.rs b/src/error.rs index 8e24e69..dd3996a 100644 --- a/src/error.rs +++ b/src/error.rs @@ -45,6 +45,7 @@ impl Error { #[derive(Copy, Clone, Debug)] #[non_exhaustive] pub enum ParseErrorKind { + // FIXME(cosmic): this is misspelled ;-; LibaryFolders, SteamApp, Shortcut, diff --git a/src/lib.rs b/src/lib.rs index 3c09bf3..848d11d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -150,6 +150,12 @@ pub use libraryfolders::{parse_library_folders, Library}; pub mod shortcut; pub use shortcut::Shortcut; +/// NOT A PART OF THE PUBLIC API +/// +/// These are just some helpers for setting up dummy test environments +#[doc(hidden)] +pub mod __test_helpers; + /// An instance of a Steam installation. /// /// All functions of this struct will cache their results. @@ -223,6 +229,18 @@ impl SteamDir { shortcut::ShortcutIter::new(&self.path) } + pub fn from_steam_dir(path: &Path) -> Result { + if !path.is_dir() { + return Err(Error::FailedLocatingSteamDir); + } + + // TODO(cosmic): should we do some kind of extra validation here? Could also use validation + // to determine if a steam dir has been uninstalled. Should fix all the flatpack/snap issues + Ok(Self { + path: path.to_owned(), + }) + } + /// Locates the Steam installation directory on the filesystem and initializes a `SteamDir` (Windows) /// /// Returns `None` if no Steam installation can be located. diff --git a/src/tests.rs b/src/tests.rs index 0f94b86..e4103d8 100644 --- a/src/tests.rs +++ b/src/tests.rs @@ -1,41 +1,53 @@ -// Prerequisites: -// * Steam must be installed -// * At least two library folders must be setup -// * At least two Steam apps must be installed -// * An installed Steam game's app ID must be specified below -static APP_ID: u32 = 4000; +use std::convert::TryInto; -use super::*; +use crate::{ + Error, + __test_helpers::{SampleApp, TempSteamDir, TestError, TestResult, TestTempDir}, +}; -#[test] -fn find_steam() { - SteamDir::locate().unwrap(); +static GMOD_ID: u32 = SampleApp::GarrysMod.id(); + +// The legacy test env assumed the following prerequisites: +// - Steam must be installed +// - At least two library folders must be setup (the steam dir acts as one) +// - Garry's Mod along with at least one other steam app must be installed +pub fn legacy_test_env() -> std::result::Result, TestError> { + TempSteamDir::builder() + .app(SampleApp::GarrysMod.into()) + .library(SampleApp::GraveyardKeeper.try_into()?) + .finish() } #[test] -fn find_library_folders() { - let steam_dir = SteamDir::locate().unwrap(); +fn find_library_folders() -> TestResult { + let tmp_steam_dir = legacy_test_env()?; + let steam_dir = tmp_steam_dir.steam_dir(); assert!(steam_dir.libraries().unwrap().len() > 1); + Ok(()) } #[test] -fn find_app() { - let steam_dir = SteamDir::locate().unwrap(); - let steam_app = steam_dir.app(APP_ID).unwrap(); - assert_eq!(steam_app.unwrap().app_id, APP_ID); +fn find_app() -> TestResult { + let tmp_steam_dir = legacy_test_env()?; + let steam_dir = tmp_steam_dir.steam_dir(); + let steam_app = steam_dir.app(GMOD_ID).unwrap(); + assert_eq!(steam_app.unwrap().app_id, GMOD_ID); + Ok(()) } #[test] -fn app_details() { - let steam_dir = SteamDir::locate().unwrap(); - // TODO(cosmic): I don't like the double `.unwrap()` here. Represent missing as an error or no? - let steam_app = steam_dir.app(APP_ID).unwrap().unwrap(); +fn app_details() -> TestResult { + let tmp_steam_dir = legacy_test_env()?; + let steam_dir = tmp_steam_dir.steam_dir(); + let steam_app = steam_dir.app(GMOD_ID)?.unwrap(); assert_eq!(steam_app.name.unwrap(), "Garry's Mod"); + Ok(()) } #[test] -fn all_apps() { - let steam_dir = SteamDir::locate().unwrap(); +fn all_apps() -> TestResult { + let tmp_steam_dir = legacy_test_env()?; + let steam_dir = tmp_steam_dir.steam_dir(); let mut libraries = steam_dir.libraries().unwrap(); let all_apps: Vec<_> = libraries .try_fold(Vec::new(), |mut acc, maybe_library| { @@ -48,11 +60,13 @@ fn all_apps() { }) .unwrap(); assert!(all_apps.len() > 1); + Ok(()) } #[test] -fn all_apps_get_one() { - let steam_dir = SteamDir::locate().unwrap(); +fn all_apps_get_one() -> TestResult { + let tmp_steam_dir = legacy_test_env()?; + let steam_dir = tmp_steam_dir.steam_dir(); let mut libraries = steam_dir.libraries().unwrap(); let all_apps: Vec<_> = libraries @@ -68,12 +82,14 @@ fn all_apps_get_one() { assert!(!all_apps.is_empty()); assert!(all_apps.len() > 1); - let steam_app = steam_dir.app(APP_ID).unwrap().unwrap(); + let steam_app = steam_dir.app(GMOD_ID).unwrap().unwrap(); assert_eq!( all_apps .into_iter() - .find(|app| app.app_id == APP_ID) + .find(|app| app.app_id == GMOD_ID) .unwrap(), steam_app ); + + Ok(()) } diff --git a/tests/assets/appmanifest_4000.acf b/tests/assets/appmanifest_4000.acf new file mode 100644 index 0000000..8de8726 --- /dev/null +++ b/tests/assets/appmanifest_4000.acf @@ -0,0 +1,43 @@ +"AppState" +{ + "appid" "4000" + "Universe" "1" + "name" "Garry's Mod" + "StateFlags" "4" + "installdir" "GarrysMod" + "LastUpdated" "1699500640" + "SizeOnDisk" "4152333499" + "StagingSize" "0" + "buildid" "12123796" + "LastOwner" "12312312312312312" + "UpdateResult" "0" + "BytesToDownload" "2313758368" + "BytesDownloaded" "2313758368" + "BytesToStage" "4152290626" + "BytesStaged" "4152290626" + "TargetBuildID" "12123796" + "AutoUpdateBehavior" "0" + "AllowOtherDownloadsWhileRunning" "0" + "ScheduledAutoUpdate" "0" + "InstalledDepots" + { + "4001" + { + "manifest" "8033896166589191357" + "size" "3875126726" + } + "4003" + { + "manifest" "6271527943975114763" + "size" "281149259" + } + } + "UserConfig" + { + "language" "english" + } + "MountedConfig" + { + "language" "english" + } +} diff --git a/tests/assets/libraryfolder.vdf b/tests/assets/libraryfolder.vdf new file mode 100644 index 0000000..a39147a --- /dev/null +++ b/tests/assets/libraryfolder.vdf @@ -0,0 +1,5 @@ +"libraryfolder" +{ + "contentid" "1298765432109876543" + "label" "" +}