loadsmith-thunderstore 0.3.1

Thunderstore API client, index backends, and r2z format for the loadsmith mod-manager library
Documentation
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};

/// Writes an r2z (r2modman zip) profile export.
///
/// Produces a zip archive containing a YAML manifest (`export.r2x`) and
/// optional config files.
///
/// # Examples
///
/// ```
/// use std::io::Cursor;
/// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest, Mod, Version};
/// use thunderstore::PackageIdent;
///
/// let manifest = ProfileManifest::new(
///     "Test Profile",
///     vec![Mod::new(PackageIdent::new("A", "B"), Version::new(1, 0, 0), true)],
///     (),
/// );
///
/// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
/// export.write_file("config/settings.cfg", &b"enabled=true"[..]).unwrap();
/// let data: Cursor<Vec<u8>> = export.finish().unwrap();
/// assert!(!data.get_ref().is_empty());
/// ```
pub struct ExportFile<W: Write + Seek> {
    zip: ZipWriter<W>,
}

impl<W: Write + Seek> ExportFile<W> {
    /// Creates a new export archive and writes the manifest as `export.r2x`.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::io::Cursor;
    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
    ///
    /// let manifest = ProfileManifest::new("Test", vec![], ());
    /// let export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
    /// ```
    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 })
    }

    /// Finalizes the zip archive and returns the inner writer.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::io::Cursor;
    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
    ///
    /// let manifest = ProfileManifest::new("Test", vec![], ());
    /// let export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
    /// let data = export.finish().unwrap();
    /// ```
    pub fn finish(self) -> Result<W> {
        self.zip.finish().map_err(Error::Zip)
    }

    /// Writes a file into the zip archive at the given virtual path.
    ///
    /// The path is normalized before being stored in the archive.
    ///
    /// # Examples
    ///
    /// ```
    /// use std::io::Cursor;
    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
    ///
    /// let manifest = ProfileManifest::new("Test", vec![], ());
    /// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
    /// export.write_file("BepInEx/config/plugin.cfg", &b"key=value"[..]).unwrap();
    /// let _data = export.finish().unwrap();
    /// ```
    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(())
    }

    /// Walks a directory and writes its contents into the archive.
    ///
    /// When `filter` is `true`, only files matching common config extensions
    /// (`.cfg`, `.txt`, `.json`, `.yml`, `.yaml`, `.ini`) or paths under
    /// `BepInEx/config/` are included.
    ///
    /// # Examples
    ///
    /// ```no_run
    /// use std::io::Cursor;
    /// use loadsmith_thunderstore::r2z::{ExportFile, ProfileManifest};
    /// use camino::Utf8Path;
    ///
    /// let manifest = ProfileManifest::new("Test", vec![], ());
    /// let mut export = ExportFile::create(Cursor::new(Vec::new()), &manifest).unwrap();
    /// export.write_config_from_dir(Utf8Path::new("./config"), true).unwrap();
    /// let _data = export.finish().unwrap();
    /// ```
    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"
        ));
    }
}