hunpak 1.2.0

Utility for PAK files of the game engine Heaps
Documentation
use anyhow::anyhow;
use std::{fs::File, io::Write, path::PathBuf};

use clap::{Parser, Subcommand};
use clap_verbosity_flag::{InfoLevel, Verbosity};
use log::{debug, error, info};

use hunpak::{PakFile, PakPosition};
use schemars::{JsonSchema, schema_for};
use serde::Deserialize;
use walkdir::WalkDir;
use zip::ZipArchive;

static PATCH_META_FILE: &str = "hunpak-patch.json";

#[derive(Debug, Clone, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub struct PatchMetadata {
    pub name: String,
    pub version: String,
    pub author: String,
    pub description: Option<String>,
}

#[derive(Parser)]
struct Cli {
    #[command(flatten)]
    verbosity: Verbosity<InfoLevel>,
    #[command(subcommand)]
    command: Commands,
}

#[derive(Subcommand)]
enum Commands {
    /// Prints the contents of a PAK file
    List {
        /// PAK file
        input: PathBuf,
    },
    /// Extract a PAK file
    Extract {
        /// PAK file
        input: PathBuf,
        /// output folder
        output: PathBuf,
        /// optional mapping file to output
        #[arg(short, long)]
        mapping: Option<PathBuf>,
    },
    /// Extract and repack a PAK file
    Repack {
        /// PAK file
        input: PathBuf,
        /// output PAK file
        output: PathBuf,
        /// patch for PAK file (can be specified multiple times and be either a folder or ZIP file)
        #[arg(short, long)]
        patch: Vec<PathBuf>,
    },
    /// Print single file to standard out
    Get {
        /// PAK file
        input: PathBuf,
        /// path of file inside PAK
        path: PathBuf,
    },
    /// Pack folder contents into new PAK file
    Pack {
        /// folder with contents
        input: PathBuf,
        /// output PAK file
        output: PathBuf,
    },
    /// Output the patch metadata schema to the current working directory
    Schema,
}

fn main() {
    let args = Cli::parse();
    env_logger::Builder::new()
        .filter_level(args.verbosity.into())
        .init();
    if let Err(e) = main_res(args) {
        error!("{e}");
    }
}

fn main_res(args: Cli) -> anyhow::Result<()> {
    match args.command {
        Commands::Extract {
            input,
            output,
            mapping,
        } => {
            let archive = PakFile::read(input)?;
            archive.extract(output)?;
            if let Some(mapping_output) = mapping {
                let mut file = File::create(mapping_output)?;
                writeln!(file, "path, position, size, checksum")?;
                for (path, (pos, size, checksum)) in archive.mappings()? {
                    let (t, p) = match pos {
                        PakPosition::Float(x) => ("float", x as u64),
                        PakPosition::Int(x) => ("int", x as u64),
                    };
                    writeln!(
                        file,
                        "{}, {}, {}, {}, {:04X}",
                        path.display(),
                        p,
                        t,
                        size,
                        checksum
                    )?;
                }
            }
        }
        Commands::List { input } => {
            let archive = PakFile::read(input)?;
            archive.print_dir();
        }
        Commands::Repack {
            input,
            output,
            patch,
        } => {
            let temporary_dir = std::env::temp_dir().join("hunpak");
            if temporary_dir.exists() {
                std::fs::remove_dir_all(&temporary_dir)?;
            }
            info!("Extracting archive");
            {
                let old = PakFile::read(input)?;
                old.extract(&temporary_dir)?;
            }
            // patch contents
            for patch in patch {
                if !patch.exists() {
                    error!("Could not apply patch at {}", patch.display());
                    continue;
                }
                if patch.is_dir() {
                    let metadata_path = patch.join(PATCH_META_FILE);
                    let metadata: PatchMetadata =
                        serde_json::from_reader(File::open(&metadata_path)?)?;
                    info!(
                        "Adding {}@{} by {})",
                        metadata.name, metadata.version, metadata.author
                    );
                    for entry in WalkDir::new(&patch) {
                        let entry = entry?;
                        let path = entry.path();
                        if path == metadata_path {
                            continue;
                        }
                        let relative = path.strip_prefix(&patch)?;
                        let target = temporary_dir.join(relative);
                        if path.is_dir() {
                            if target.exists() {
                                if !target.is_dir() {
                                    error!(
                                        "Expected directory at {}, found file",
                                        relative.display()
                                    );
                                }
                            } else {
                                debug!("Creating folder {}", relative.display());
                                std::fs::create_dir_all(&target)?;
                            }
                        } else if target.exists() && target.is_dir() {
                            error!("Expected file at {}, found directory", relative.display());
                        } else {
                            info!("Copying {}", relative.display());
                            if let Some(parent) = target.parent()
                                && !parent.exists()
                            {
                                debug!("Creating parent of {}", relative.display());
                                std::fs::create_dir_all(parent)?;
                            }
                            std::fs::copy(path, &target)?;
                        }
                    }
                } else {
                    let mut patch_archive = ZipArchive::new(File::open(&patch)?)?;
                    let metadata_file = patch_archive.by_name(PATCH_META_FILE)?;
                    let metadata: PatchMetadata = serde_json::from_reader(metadata_file)?;
                    info!(
                        "Adding {}@{} by {})",
                        metadata.name, metadata.version, metadata.author
                    );
                    for i in 0..patch_archive.len() {
                        let mut entry = patch_archive.by_index(i)?;
                        let relative = entry
                            .enclosed_name()
                            .ok_or(anyhow!("Invalid filename in archive"))?;
                        if relative.parent().is_none()
                            && relative.file_name().and_then(|x| x.to_str())
                                == Some(PATCH_META_FILE)
                        {
                            continue;
                        }
                        let target = temporary_dir.join(&relative);
                        if entry.is_dir() {
                            if target.exists() {
                                if !target.is_dir() {
                                    error!(
                                        "Expected directory at {}, found file",
                                        relative.display()
                                    );
                                }
                            } else {
                                debug!("Creating folder {}", relative.display());
                                std::fs::create_dir_all(&target)?;
                            }
                        } else if target.exists() && target.is_dir() {
                            error!("Expected file at {}, found directory", relative.display());
                        } else {
                            info!("Copying {}", relative.display());
                            if let Some(parent) = target.parent()
                                && !parent.exists()
                            {
                                debug!("Creating parent of {}", relative.display());
                                std::fs::create_dir_all(parent)?;
                            }
                            std::io::copy(&mut entry, &mut File::create(&target)?)?;
                        }
                    }
                }
            }
            info!("Repacking archive");
            {
                let mut new = PakFile::default();
                new.add_recursive(&temporary_dir)?;
                new.write(output)?;
            }
            std::fs::remove_dir_all(&temporary_dir)?;
        }
        Commands::Get { input, path } => {
            let archive = PakFile::read(input)?;
            if let Some(data) = archive.get(path)? {
                std::io::stdout().lock().write_all(data)?;
            }
        }
        Commands::Pack { input, output } => {
            let mut archive = PakFile::default();
            archive.add_recursive(input)?;
            archive.write(output)?;
        }
        Commands::Schema => {
            let schema = schema_for!(PatchMetadata);
            std::fs::write(
                "patch-metadata.schema.json",
                serde_json::to_string_pretty(&schema)?,
            )?;
        }
    }
    Ok(())
}