loadsmith-thunderstore 0.2.0

Thunderstore API client, index backends, and r2z format for the loadsmith mod-manager library
use std::{
    fs::File,
    io::{self, Read, Seek, Write},
    sync::LazyLock,
};

use camino::Utf8Path;
use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use serde::Serialize;
use walkdir::WalkDir;
use zip::{ZipWriter, write::SimpleFileOptions};

use crate::{Error, Result, r2z::ProfileManifest};

pub struct ExportFile<W: Write + Seek> {
    zip: ZipWriter<W>,
}

impl<W: Write + Seek> ExportFile<W> {
    pub fn create<T: Serialize>(writer: W, manifest: &ProfileManifest<T>) -> Result<Self> {
        let mut zip = ZipWriter::new(writer);

        zip.start_file("export.r2x", SimpleFileOptions::default())?;
        serde_yaml_ng::to_writer(&mut zip, manifest)?;

        Ok(Self { zip })
    }

    pub fn finish(self) -> Result<W> {
        self.zip.finish().map_err(Error::Zip)
    }

    pub fn write_file(
        &mut self,
        zip_path: impl AsRef<Utf8Path>,
        mut reader: impl Read,
    ) -> Result<()> {
        let path = super::normalize_zip_file_path(zip_path.as_ref())?;
        let path = path.into_string().replace("\\", "/");
        self.zip.start_file(path, SimpleFileOptions::default())?;

        io::copy(&mut reader, &mut self.zip)?;

        Ok(())
    }

    pub fn write_config_from_dir(
        &mut self,
        directory: impl AsRef<Utf8Path>,
        filter: bool,
    ) -> Result<()> {
        let directory = directory.as_ref();
        WalkDir::new(directory)
            .follow_links(false)
            .into_iter()
            .filter_map(|entry| entry.ok())
            .filter(|entry| entry.file_type().is_file())
            .try_for_each(|entry| {
                let source_path = Utf8Path::from_path(entry.path())
                    .ok_or_else(|| Error::NonUtf8Path(entry.path().to_path_buf()))?;

                let relative_path = source_path.strip_prefix(directory).unwrap_or(source_path);
                let normalized_path = super::normalize_zip_file_path(relative_path)?;

                if filter && !is_included(&normalized_path) {
                    return Ok(());
                }

                let mut file = File::open(source_path)?;
                self.write_file(relative_path, &mut file)
            })
    }
}

fn is_included(relative_path: impl AsRef<Utf8Path>) -> bool {
    static INCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
        GlobSetBuilder::new()
            .add(Glob::new("BepInEx/config/*").unwrap())
            .add(Glob::new("*.{cfg,txt,json,yml,yaml,ini}").unwrap())
            .build()
            .unwrap()
    });

    static EXCLUDE_SET: LazyLock<GlobSet> = LazyLock::new(|| {
        GlobSetBuilder::new()
            .add(Glob::new("{dotnet,_state,MelonLoader}/*").unwrap())
            .add(Glob::new("dotnet/*").unwrap())
            .add(Glob::new("GDWeave/{GDWeave.log,core/*,mods/*}").unwrap())
            .add(Glob::new("mods.yml").unwrap())
            .add(
                GlobBuilder::new("BepInEx/plugins/*/manifest.json")
                    .literal_separator(true)
                    .build()
                    .unwrap(),
            )
            .build()
            .unwrap()
    });

    let path = relative_path.as_ref();
    INCLUDE_SET.is_match(path) && !EXCLUDE_SET.is_match(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_is_included() {
        assert!(is_included("config.ini"));
        assert!(is_included("other/test.json"));
        assert!(is_included("BepInEx/config/test.png"));
        assert!(is_included("BepInEx/config/test.cfg"));

        assert!(!is_included("test.dll"));
        assert!(!is_included("nested/latest.log"));
        assert!(!is_included("mods.yml"));
        assert!(!is_included("random/test.exe"));
        assert!(!is_included("dotnet/test.cfg"));

        assert!(is_included("GDWeave/test.cfg"));
        assert!(!is_included("GDWeave/GDWeave.log"));
        assert!(!is_included("GDWeave/core/test.cfg"));
        assert!(!is_included("GDWeave/mods/test.cfg"));

        assert!(is_included("BepInEx/plugins/test.txt"));
        assert!(!is_included("BepInEx/plugins/Author-Name/manifest.json"));
        assert!(is_included(
            "BepInEx/plugins/Author-Name/nested/manifest.json"
        ));
    }
}