oliver 0.9.3

Lightweight CLI mod development tool for Baldur's Gate 3 on Linux
Documentation
pub use larian_formats;

use anyhow::{Result, anyhow, bail};
use clap::ValueEnum;
use larian_formats::{
    lsf::LsfData,
    lspk::{self, Lspk, ModuleInfo, is_override},
};
use std::{
    collections::VecDeque,
    ffi::OsStr,
    fs::{File, read_dir},
    path::{Path, PathBuf},
};

/// Specifies the type of data file.
#[derive(Debug, ValueEnum, Clone, Copy)]
pub enum DataFileType {
    Lsf,
    Lsx,
}

impl DataFileType {
    fn from_path(path: &Path) -> Option<Self> {
        match path.extension().map(|s| s.to_string_lossy()).as_deref() {
            Some("lsf") => Some(Self::Lsf),
            Some("lsx") => Some(Self::Lsx),
            _ => None,
        }
    }

    const fn extension(self) -> &'static str {
        match self {
            Self::Lsf => "lsf",
            Self::Lsx => "lsx",
        }
    }
}

macro_rules! convert_inner {
    (
        $from: expr,
        $to: expr,
        $read_fn: expr,
        $write_fn: expr $(,)?
    ) => {
        let mut input = File::open($from)?;
        let data = $read_fn(&mut input)
            .map_err(|e| anyhow!("Failed to parse {}: {e}", $from.display()))?;
        let mut out = File::options()
            .create(true)
            .write(true)
            .truncate(true)
            .open($to)?;
        $write_fn(&data, &mut out).map_err(|e| {
            anyhow!("Failed to serialize data from {} into {}: {e}", $from.display(), $to.display())
        })?;
        eprintln!("Converted {} to {}", $from.display(), $to.display());
    };
}

/// Lists the file paths contained in the mod file.
///
/// # Errors
///
/// Returns an error if the mod file header could not be parsed.
#[cfg(unix)]
pub fn contents(path: &Path) -> Result<()> {
    use std::os::unix::ffi::OsStrExt;

    let paths = larian_formats::lspk::list_mod_pack_files(path)?;

    for bytes in paths.iter() {
        println!("{}", OsStr::from_bytes(bytes.as_ref()).display());
    }

    Ok(())
}

/// Extracts a single file from a mod.
///
/// # Errors
///
/// Returns an error if the target path is not relative, if the mod file header could not be parsed,
/// if the target file path does not exist in the mod, or the file could not be decompressed and
/// read.
#[cfg(unix)]
pub fn extract_file(from: &Path, internal: &Path, to: &Path) -> Result<()> {
    use anyhow::Context;

    let Some(bytes) = larian_formats::raw::extract_file_from_pak(internal, from)? else {
        bail!("{} not found in {}", internal.display(), from.display());
    };

    std::fs::write(to, bytes).with_context(|| {
        format!(
            "could not write {} from {} to {}",
            internal.display(),
            from.display(),
            to.display()
        )
    })
}

/// Recursively converts all files from one type to another in the given directory and its
/// subdirectories.
///
/// # Errors
///
/// Returns an error if conversion fails for any file.
pub fn convert_all(path: PathBuf, from: DataFileType, to: DataFileType) -> Result<()> {
    let mut dirs = VecDeque::from_iter([path]);

    while let Some(dir) = dirs.pop_front() {
        for entry in read_dir(dir)? {
            let e = entry?;
            let file_type = e.file_type()?;

            if file_type.is_dir() {
                dirs.push_back(e.path());
            } else if file_type.is_file() &&
                e.path().extension() == Some(OsStr::new(from.extension()))
            {
                let mut dest = e.path().clone();
                dest.set_extension(to.extension());
                convert(&e.path(), &dest)?;
            }
        }
    }

    Ok(())
}

/// Converts a single file from from one type to another.
///
/// # Errors
///
/// Returns an error if conversion fails.
pub fn convert(from: &Path, to: &Path) -> Result<()> {
    let Some(from_type) = DataFileType::from_path(from) else {
        bail!("`{}` does not have either `.lsf` or `.lsx` extension", from.display());
    };

    let Some(to_type) = DataFileType::from_path(to) else {
        bail!("`{}` does not have either `.lsf` or `.lsx` extension", to.display());
    };

    match (from_type, to_type) {
        (DataFileType::Lsf, DataFileType::Lsx) => {
            convert_inner!(from, to, LsfData::read_lsf, LsfData::write_lsx);
        }
        (DataFileType::Lsx, DataFileType::Lsf) => {
            convert_inner!(from, to, LsfData::read_lsx, LsfData::write_lsf);
        }
        (..) => {
            std::fs::copy(from, to)?;
        }
    }

    Ok(())
}

/// Packs the loose mod files in the given directory into an LSPK file.
///
/// # Errors
///
/// Returns an error if packing fails.
pub fn pack(mod_files_root: PathBuf, destination: Option<PathBuf>) -> Result<()> {
    lspk::write(mod_files_root, destination)?;
    Ok(())
}

/// Unpacks the given LSPK file into loose mod files.
///
/// # Errors
///
/// Returns an error if the LSPK file is invalid or if creating the loose files fails.
pub fn unpack(mod_file_path: &Path, destination: Option<PathBuf>) -> Result<()> {
    let data = Lspk::from_file(mod_file_path)?;

    let prefix_dir = match destination {
        Some(unpack_dir) => {
            std::fs::create_dir_all(&unpack_dir)?;
            unpack_dir
        }
        None => "./".into(),
    };

    for file in data.files {
        let path = prefix_dir.join(file.path);

        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }

        std::fs::write(path, file.contents)?;
    }

    Ok(())
}

/// Parses the given paths, printing LSPK metadata if verbose is enabled.
///
/// # Errors
///
/// Returns an error if a valid LSPK file cannot be parsed at one of the given paths.
pub fn parse(paths: impl IntoIterator<Item = PathBuf>, verbose: bool) -> Result<()> {
    for path in paths {
        let displayed_path = path.display().to_string();
        let data = Lspk::from_file(&path)?;

        print!("{displayed_path}");

        if is_override(path) {
            print!(" (override)");
        }

        println!();

        for _ in displayed_path.chars() {
            print!("-");
        }

        println!();

        print_lspk(&data, verbose)?;

        println!();
    }

    Ok(())
}

fn print_lspk(data: &Lspk, verbose: bool) -> Result<()> {
    let meta_lsx = data.deserialize_meta_lsx()?;

    let ModuleInfo {
        author,
        description,
        folder,
        md5,
        name,
        num_players,
        module_type,
        uuid,
        version,
        ..
    } = meta_lsx.module_info;

    println!("Name         : {name}");
    println!("Folder       : {folder}");
    println!("Version64    : {version}");
    println!("UUID         : {uuid}");

    if !verbose {
        return Ok(());
    }

    println!("Author       : {author}");
    println!("Description  : {description}");
    println!("MD5          : {md5}");
    println!("NumPlayers   : {num_players}");
    println!("Type         : {module_type}");

    Ok(())
}