balatro_mod_manager 0.1.0

a library for downloading Balatro mods and interacting with the `balatro_mod_index` crate.
Documentation
#![allow(clippy::missing_errors_doc)]

pub const CRATE_NAME: &str = "libmanager";

use std::collections::HashMap;
use std::fs::File;
use std::io::{self, Write};
use std::path::PathBuf;

use balatro_mod_index::mods::{Mod, ModId, ModIndex};
use zip::{ZipArchive, read::root_dir_common_filter};

pub type ModEntry<'index> = (ModId, Mod<'index>);
#[derive(Clone, Debug, Default)]
pub struct ModManager<'index> {
    pub index: ModIndex<'index>,
    pub installed_mods: HashMap<ModId, String>,
}
impl ModManager<'_> {
    pub fn mut_detect_installed_mods(&mut self) -> Result<(), String> {
        self.installed_mods = detect_installed_mods()?;
        Ok(())
    }

    pub fn uninstall_mod(&mut self, (id, m): &ModEntry) -> Result<(), String> {
        _ = self.installed_mods.remove(id).ok_or("mod not installed")?;
        uninstall_mod(m)?;
        Ok(())
    }

    pub async fn install_mod(
        &mut self,
        client: &reqwest::Client,
        entry: &(ModId, Mod<'_>),
    ) -> Result<(), String> {
        install_mod(self, client, entry, false).await
    }
    pub async fn reinstall_mod(
        &mut self,
        client: &reqwest::Client,
        entry: &(ModId, Mod<'_>),
    ) -> Result<(), String> {
        install_mod(self, client, entry, true).await
    }
}

fn uninstall_mod(m: &Mod<'_>) -> Result<(), String> {
    let mod_dir = mod_path(m)?;

    if !mod_dir.exists() {
        return Ok(());
    }

    std::fs::remove_dir_all(mod_dir).map_err(|e| e.to_string())
}

async fn install_mod(
    manager: &mut ModManager<'_>,
    client: &reqwest::Client,
    (id, m): &(ModId, Mod<'_>),
    reinstall: bool,
) -> Result<(), String> {
    let outdir = mod_path(m)?;

    let data = client
        .get(&m.meta.download_url)
        .send()
        .await
        .map_err(|e| e.to_string())?
        .bytes()
        .await
        .map_err(|e| e.to_string())?;

    let mut zip = ZipArchive::new(io::Cursor::new(data)).map_err(|e| e.to_string())?;
    log::debug!(
        "downloaded zip file {}, will install it to {}",
        m.meta.download_url,
        outdir.display()
    );

    if outdir.exists() {
        if !reinstall {
            return Err(format!("mod `{}` is already installed", id.0));
        }
        std::fs::remove_dir_all(&outdir).map_err(|e| e.to_string())?;
    }
    zip.extract_unwrapped_root_dir(&outdir, root_dir_common_filter)
        .map_err(|e| e.to_string())?;

    let mut statefile =
        File::create(outdir.join(format!(".{}", crate::CRATE_NAME))).map_err(|e| e.to_string())?;
    write!(statefile, "id {id}\nversion {}", m.meta.version).map_err(|e| e.to_string())?;

    manager
        .installed_mods
        .insert(id.clone(), m.meta.version.clone());
    Ok(())
}

fn mod_path(m: &Mod<'_>) -> Result<PathBuf, String> {
    let basename = m
        .meta
        .folder_name
        .clone()
        .unwrap_or_else(|| m.meta.title.chars().filter(char::is_ascii).collect());
    let outdir = mods_dir()?.join(&basename);

    Ok(outdir)
}

fn detect_installed_mods() -> Result<HashMap<ModId, String>, String> {
    let mods_dir = mods_dir()?;

    if !mods_dir.exists() {
        return Err("Mods directory does not exist".to_string());
    }

    let mut mods = HashMap::new();
    for entry in std::fs::read_dir(&mods_dir).map_err(|e| e.to_string())? {
        let path = entry.map_err(|e| e.to_string())?.path();
        if !path.is_dir() {
            continue;
        }

        let statefile = path.join(format!(".{}", crate::CRATE_NAME));
        if !statefile.exists() {
            continue;
        }

        let (id, version) =
            parse_status(&std::fs::read_to_string(statefile).map_err(|e| e.to_string())?)?;
        mods.insert(id, version);
    }

    Ok(mods)
}

fn mods_dir() -> Result<PathBuf, String> {
    let mods_dir = dirs::config_dir()
        .ok_or("couldn't find config directory, your env is so cooked")?
        .join("Balatro")
        .join("Mods");

    if !mods_dir.exists() {
        std::fs::create_dir_all(&mods_dir).map_err(|e| e.to_string())?;
    }

    Ok(mods_dir)
}

pub fn parse_status(meta: &str) -> Result<(ModId, String), String> {
    let mut id = ModId::default();
    let mut version = String::new();
    for line in meta.lines() {
        let (key, value) = line
            .trim()
            .split_once(' ')
            .ok_or("line is not a key-value pair")?;
        match key {
            "id" => {
                id = ModId(value.to_owned());
                if id.0.is_empty() {
                    return Err("id can't be empty".to_string());
                }
            }
            "version" => {
                if value.is_empty() {
                    return Err("version can't be empty".to_string());
                }
                value.clone_into(&mut version);
            }
            _ => {}
        }
    }

    Ok((id, version))
}