loadsmith 0.3.1

A modular, interface-agnostic Rust library for mod manager implementations
Documentation
use std::{
    io::Cursor,
    path::{Path, PathBuf},
};

use anyhow::{Context, anyhow};
use bytes::Bytes;
use loadsmith::{
    Checksum, ChecksumAlgorithm, InstallRuleset, InstalledPackage, PackageRef,
    thunderstore::PackageRefExt,
};
use loadsmith_core::Version;
use serde::{Deserialize, Serialize};
use tempfile::TempDir;
use walkdir::WalkDir;

async fn get_loader(
    client: &thunderstore::Client,
    name: &str,
) -> anyhow::Result<Box<dyn loadsmith::Loader>> {
    let schema = client.get_schema("dev").await?;
    let lethal_company = schema
        .games
        .get(name)
        .ok_or_else(|| anyhow!("{name} not found in schema"))?;
    let r2_config = lethal_company
        .r2modman
        .as_ref()
        .and_then(|vec| vec.first())
        .ok_or_else(|| anyhow!("r2modman config not found in schema"))?;
    let loader = loadsmith::thunderstore::r2_config_to_loader(r2_config)
        .context("failed to create loader")?;

    Ok(loader)
}

async fn download_package(
    client: &thunderstore::Client,
    package: &PackageRef,
) -> anyhow::Result<Bytes> {
    let bytes = client.download(package.clone().into_ts_ident()?).await?;

    Ok(bytes)
}

fn extract_and_install_package(
    bytes: &[u8],
    package: PackageRef,
    ruleset: InstallRuleset,
) -> anyhow::Result<(TempDir, InstalledPackage)> {
    let extract_dir = tempfile::tempdir()?;
    loadsmith::extract(Cursor::new(bytes), &extract_dir)?;

    let install_dir = tempfile::tempdir()?;
    let (install_manifest, _overwritten_files) =
        loadsmith::install(package, ruleset, &extract_dir, &install_dir, false, None)?;

    Ok((install_dir, install_manifest))
}

#[derive(Debug, Serialize, Deserialize)]
struct ListedFile {
    file: PathBuf,
    hash: Checksum,
}

impl ListedFile {
    fn from_path(path: PathBuf, root: &Path) -> anyhow::Result<Self> {
        let relative_path = path.strip_prefix(root)?.to_path_buf();
        let hash = Checksum::compute_from_path(&path, ChecksumAlgorithm::Blake3)?;

        Ok(ListedFile {
            file: relative_path,
            hash,
        })
    }
}

fn list_files(dir: impl AsRef<Path>) -> anyhow::Result<Vec<ListedFile>> {
    let dir = dir.as_ref();

    let mut files = WalkDir::new(dir)
        .into_iter()
        .filter_map(|entry| entry.ok())
        .filter_map(|entry| {
            if entry.file_type().is_dir() {
                return None;
            }

            let path = entry.path().to_path_buf();
            Some(ListedFile::from_path(path, dir))
        })
        .collect::<anyhow::Result<Vec<_>>>()?;

    files.sort_by(|a, b| a.file.cmp(&b.file));
    Ok(files)
}

async fn test_install_flow(
    community: &str,
    package: PackageRef,
    is_loader: bool,
) -> anyhow::Result<()> {
    let client = thunderstore::Client::new();

    let loader = get_loader(&client, community)
        .await
        .context("failed to get loader")?;

    let bytes = download_package(&client, &package)
        .await
        .context("failed to download package")?;

    let (profile, install_manifest) = extract_and_install_package(
        &bytes,
        package,
        if is_loader {
            loader.loader_install_rules()
        } else {
            loader.package_install_rules()
        },
    )
    .context("failed to extract and install package")?;

    let files = list_files(&profile).context("failed to list files")?;

    insta::assert_yaml_snapshot!(install_manifest.ref_().to_string(), files);

    loadsmith::uninstall(&install_manifest, &profile).context("failed to uninstall package")?;

    let files = list_files(&profile).context("failed to list files after uninstall")?;

    assert!(
        files.is_empty(),
        "profile directory should be empty after uninstall"
    );

    Ok(())
}

#[tokio::test]
#[ignore]
async fn install_lethal_lib() -> anyhow::Result<()> {
    let package = PackageRef::new("Evaisa-LethalLib".to_string(), Version::new(1, 2, 0));
    test_install_flow("lethal-company", package, false).await
}

#[tokio::test]
#[ignore]
async fn install_bep_in_ex() -> anyhow::Result<()> {
    let package = PackageRef::new(
        "BepInEx-BepInExPack_ROUNDS".to_string(),
        Version::new(5, 4, 1901),
    );
    test_install_flow("rounds", package, true).await
}