packdir 0.1.0

Easily embed directories of binary file data
Documentation
use anyhow::{Result, anyhow, bail};
use std::env;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;

use crate::PackedDir;
use crate::packer::{Gzip, Packer, SIG, VERSION, Xz};

/// Bundle a directory using no compression.
pub fn pack_raw<P: AsRef<Path>>(dir: P, pack_name: &str) -> Result<()> {
    pack(dir, pack_name, Packer::Raw)
}

/// Bundle a directory using XZ compression.
pub fn pack_xz<P: AsRef<Path>>(dir: P, pack_name: &str) -> Result<()> {
    pack(dir, pack_name, Xz::default())
}

/// Bundle a directory using Gzip compression.
pub fn pack_gz<P: AsRef<Path>>(dir: P, pack_name: &str) -> Result<()> {
    pack(dir, pack_name, Gzip::default())
}

pub fn pack<P: AsRef<Path>>(dir: P, packed_name: &str, packer: impl Into<Packer>) -> Result<()> {
    let packer = packer.into();

    let dir_path = dir.as_ref();

    if !PackedDir::is_valid_packed_name(packed_name) {
        bail!("Invalid bundle name: {packed_name} (must be valid identifier)");
    }

    let manifest_dir = env::var("CARGO_MANIFEST_DIR")
        .map_err(|_| anyhow!("CARGO_MANIFEST_DIR not set - must be called from build script"))?;

    let manifest_path = Path::new(&manifest_dir);

    let source_dir = if dir_path.is_absolute() {
        if !dir_path.exists() {
            bail!("Source directory does not exist: {}", dir_path.display());
        }

        if !dir_path.is_dir() {
            bail!("Source path is not a directory: {}", dir_path.display());
        }

        dir_path.to_path_buf()
    } else {
        let resolved = manifest_path.join(dir_path);

        if !resolved.exists() {
            bail!("Source directory does not exist: {}", resolved.display());
        }

        if !resolved.is_dir() {
            bail!("Source path is not a directory: {}", resolved.display());
        }

        resolved.canonicalize().unwrap_or(resolved)
    };

    if let Ok(relative_path) = source_dir.strip_prefix(manifest_path) {
        println!("cargo:rerun-if-changed={}", relative_path.display());
    } else {
        println!("cargo:rerun-if-changed={}", source_dir.display());
    }

    let mut sorted_entries = Vec::new();

    let walk = WalkDir::new(&source_dir)
        .follow_links(false)
        .sort_by_file_name();

    for entry in walk {
        let entry = entry?;
        if entry.metadata()?.is_file() {
            sorted_entries.push(entry);
        }
    }

    sorted_entries.sort_by(|a, b| a.path().cmp(b.path()));

    let mut files = Vec::new();
    for entry in sorted_entries {
        let file_path = entry.path();
        let archive_path = FileToPack::normalize_path_for_archive(file_path, &source_dir);

        let path_str = archive_path.to_string_lossy().to_string();
        let path_len = path_str.len();
        let max_path_len = u16::MAX as usize;
        if path_len > max_path_len {
            bail!("Path too long: {path_str:?} ({path_len} bytes, max {max_path_len})");
        }

        let file_entry = FileToPack::new(path_str, archive_path, file_path, packer)?;
        files.push(file_entry);
    }

    let out_dir = env::var("OUT_DIR")
        .map_err(|_| anyhow!("OUT_DIR not set - must be called from build script"))?;

    let output_path = Path::new(&out_dir).join(format!("PACKDIR_{packed_name}"));

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

    let mut file = File::create(&output_path)?;

    let header = compute_header(&files)?;
    file.write_all(&header)?;

    for file_entry in files {
        file.write_all(&file_entry.packed_data)?;
    }

    Ok(())
}

#[derive(Debug)]
struct FileToPack {
    path: PathBuf,
    path_str: String,
    unpacked_size: u64,
    packed_size: u64,
    packed_data: Vec<u8>,
    packer: Packer,
}
impl FileToPack {
    pub fn new(
        path_str: String,
        path: impl Into<PathBuf>,
        file_path: &Path,
        packer: Packer,
    ) -> Result<Self> {
        let path = path.into();
        let mut file = File::open(file_path)?;
        let mut unpacked_data = Vec::new();
        file.read_to_end(&mut unpacked_data)?;

        let unpacked_size = unpacked_data.len() as u64;
        let packed_data = packer.pack(&unpacked_data)?;
        let packed_size = packed_data.len() as u64;

        Ok(Self {
            path,
            path_str,
            unpacked_size,
            packed_size,
            packed_data,
            packer,
        })
    }

    fn normalize_path_for_archive(path: &Path, base_dir: &Path) -> PathBuf {
        if let Ok(relative) = path.strip_prefix(base_dir) {
            // If the relative path is empty (same as base_dir), use "unknown"
            if relative.as_os_str().is_empty() {
                PathBuf::from("unknown")
            } else {
                relative.to_path_buf()
            }
        } else {
            // For paths outside the base directory, try to get the filename
            // If it's a directory path (ends with / or has no filename), use "unknown"
            if path.to_string_lossy().ends_with('/') || path.to_string_lossy().ends_with('\\') {
                PathBuf::from("unknown")
            } else {
                path.file_name()
                    .map(PathBuf::from)
                    .unwrap_or_else(|| PathBuf::from("unknown"))
            }
        }
    }
}
fn compute_header(files: &[FileToPack]) -> Result<Vec<u8>> {
    if files.len() > u32::MAX as usize {
        bail!("Too many files: {} (max {})", files.len(), u32::MAX);
    }

    // Calculate header size: SIG(8) + VERSION(1) + num_files(4) + entries
    let mut header_size = 8 + 1 + 4;
    for file in files {
        header_size +=
        2 +            // PATH_LEN(2)
        file.path_str.len() // PATH...
        + 8            // UNPACKED_SIZE(8)
        + 8            // PACKED_SIZE(8)
        + 8            // OFFSET(8)
        + 1            // COMPRESSION_TYPE(1)
        ;
    }

    let mut header = Vec::with_capacity(header_size);

    // Write header
    header.extend_from_slice(SIG);
    header.push(VERSION);
    header.extend_from_slice(&(files.len() as u32).to_le_bytes());

    // Write file entries
    let mut data_start = header_size as u64;
    for file in files {
        let path_str = file.path.to_string_lossy();
        let path_bytes = path_str.as_bytes();

        header.extend_from_slice(&(path_bytes.len() as u16).to_le_bytes());
        header.extend_from_slice(path_bytes);
        header.extend_from_slice(&file.unpacked_size.to_le_bytes());
        header.extend_from_slice(&file.packed_size.to_le_bytes());
        header.extend_from_slice(&data_start.to_le_bytes());
        header.push(file.packer.into());

        data_start += file.packed_size;
    }

    Ok(header)
}