rustpak 0.1.3

Rust library and CLI tool for reading and writing GoldSrc .pak archive files
Documentation
#[cfg(test)]
mod tests {
    use byteorder::{LittleEndian, ReadBytesExt};
    use rustpak::{Pak, PakFileEntry, PakFileError};
    use std::error::Error;
    use std::io::Cursor;
    use std::io::Read;

    fn verify_pak_structure(pak: &Pak, expected_file_count: usize) {
        assert_eq!(pak.header.id, "PACK", "Header should have PACK magic");
        assert_eq!(
            pak.files.len(),
            expected_file_count,
            "File count should match expected"
        );
    }

    fn verify_binary_format(
        test_file: &str,
        expected_file_count: u32,
    ) -> Result<(), Box<dyn Error>> {
        let data = std::fs::read(test_file)?;

        assert!(data.len() >= 12, "Pak file too small for header");

        let mut cursor = Cursor::new(&data);

        let mut magic = [0u8; 4];
        cursor.read_exact(&mut magic)?;
        assert_eq!(
            String::from_utf8_lossy(&magic),
            "PACK",
            "File should start with PACK magic"
        );

        let offset = cursor.read_u32::<LittleEndian>()?;
        let size = cursor.read_u32::<LittleEndian>()?;

        assert_eq!(
            size,
            expected_file_count * 64,
            "Size field should equal file count * 64"
        );

        cursor.set_position(u64::from(offset));
        for i in 0..expected_file_count {
            let mut name_buf = vec![0u8; 56];
            cursor.read_exact(&mut name_buf)?;

            let nul_pos = name_buf.iter().position(|&c| c == b'\0').unwrap_or(56);
            let name = String::from_utf8_lossy(&name_buf[..nul_pos]);
            assert!(!name.is_empty(), "File {i} should have a valid name");

            let file_offset = cursor.read_u32::<LittleEndian>()?;
            let file_size = cursor.read_u32::<LittleEndian>()?;

            assert!(
                file_offset >= 12,
                "File offset {i} should be after header (>= 12)"
            );
            assert!(
                file_offset as usize + file_size as usize <= data.len(),
                "File {i} data should be within file bounds"
            );
        }

        Ok(())
    }

    #[test]
    fn pak_from_file() -> Result<(), Box<dyn Error>> {
        let pak = Pak::from_file("extras.pak".to_string())?;
        verify_pak_structure(&pak, pak.files.len());
        Ok(())
    }

    #[test]
    fn pak_add_file_and_verify_structure() -> Result<(), Box<dyn Error>> {
        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new("test.txt".to_string(), 12 + 64, b"Hi"))?;

        assert_eq!(pak.files.len(), 1, "Should have 1 file");
        assert_eq!(pak.files[0].name, "test.txt");
        assert_eq!(pak.files[0].size, 2);
        assert_eq!(*pak.files[0].get_data(), vec![b'H', b'i']);

        Ok(())
    }

    #[test]
    fn pak_add_duplicate_file() -> Result<(), Box<dyn Error>> {
        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new("test.txt".to_string(), 0, b"H"))?;
        let result = pak.add_file(PakFileEntry::new("test.txt".to_string(), 0, b"H"));

        if result.is_err() {
            Ok(())
        } else {
            Err(Box::new(PakFileError {
                msg: "Failed".to_string(),
            }))
        }
    }

    #[test]
    fn pak_delete_file_and_verify_structure() -> Result<(), Box<dyn Error>> {
        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new("test.txt".to_string(), 0, b"H"))?;
        assert_eq!(pak.files.len(), 1);

        pak.remove_file("test.txt")?;
        assert_eq!(pak.files.len(), 0, "File should be removed");

        Ok(())
    }

    #[test]
    #[should_panic(expected = "file entry not found")]
    fn pak_delete_file_nonexisting() {
        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new("test.txt".to_string(), 0, b"H"))
            .unwrap();
        pak.remove_file("doesnotexist.txt").unwrap();
    }

    #[test]
    fn pak_save_and_verify_binary_format() -> Result<(), Box<dyn Error>> {
        let test_file = "test_save.pak";

        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new(
            "test.txt".to_string(),
            12 + 64,
            "Hello World".as_bytes(),
        ))?;
        pak.save(test_file.to_string())?;

        verify_binary_format(test_file, 1)?;

        let reloaded = Pak::from_file(test_file.to_string())?;
        verify_pak_structure(&reloaded, 1);
        assert_eq!(reloaded.files[0].name, "test.txt");
        assert_eq!(
            *reloaded.files[0].get_data(),
            "Hello World".as_bytes().to_vec()
        );

        std::fs::remove_file(test_file)?;
        Ok(())
    }

    #[test]
    fn pak_save_and_load() -> Result<(), Box<dyn Error>> {
        let test_string = "Hello World".as_bytes().to_vec();
        let test_file = "test.pak";

        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new(
            "test.txt".to_string(),
            12 + 64,
            &test_string,
        ))?;
        pak.save(test_file.to_string())?;

        let pak = Pak::from_file(test_file.to_string())?;
        verify_pak_structure(&pak, 1);

        let f = pak
            .files
            .iter()
            .find(|f| f.name == "test.txt")
            .ok_or_else(|| {
                Box::new(PakFileError {
                    msg: "File not found after save/load".to_string(),
                }) as Box<dyn Error>
            })?;

        assert_eq!(*f.get_data(), test_string);

        std::fs::remove_file(test_file)?;
        Ok(())
    }

    #[test]
    fn pak_multiple_files_save_and_verify() -> Result<(), Box<dyn Error>> {
        let test_file = "test_multi.pak";

        let data1 = "Content 1".as_bytes().to_vec();
        let data2 = "Content 2".as_bytes().to_vec();
        let data3 = "Content 3".as_bytes().to_vec();

        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new(
            "file1.txt".to_string(),
            12 + (3 * 64),
            &data1,
        ))?;
        pak.add_file(PakFileEntry::new(
            "file2.txt".to_string(),
            12 + (3 * 64) + u32::try_from(data1.len()).unwrap(),
            &data2,
        ))?;
        pak.add_file(PakFileEntry::new(
            "file3.txt".to_string(),
            12 + (3 * 64)
                + u32::try_from(data1.len()).unwrap()
                + u32::try_from(data2.len()).unwrap(),
            &data3,
        ))?;

        pak.save(test_file.to_string())?;

        verify_binary_format(test_file, 3)?;

        let reloaded = Pak::from_file(test_file.to_string())?;
        verify_pak_structure(&reloaded, 3);

        assert_eq!(reloaded.files[0].name, "file1.txt");
        assert_eq!(*reloaded.files[0].get_data(), data1);
        assert_eq!(reloaded.files[1].name, "file2.txt");
        assert_eq!(*reloaded.files[1].get_data(), data2);
        assert_eq!(reloaded.files[2].name, "file3.txt");
        assert_eq!(*reloaded.files[2].get_data(), data3);

        std::fs::remove_file(test_file)?;
        Ok(())
    }

    #[test]
    fn pak_roundtrip_preserves_integrity() -> Result<(), Box<dyn Error>> {
        let test_file = "test_roundtrip.pak";
        let original_data = b"This is test data with special chars: \x00\x01\x02\xff".to_vec();

        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new(
            "binary.dat".to_string(),
            12 + 64,
            &original_data,
        ))?;

        pak.save(test_file.to_string())?;

        let reloaded = Pak::from_file(test_file.to_string())?;
        verify_pak_structure(&reloaded, 1);

        let loaded_data = reloaded.files[0].get_data();
        assert_eq!(
            *loaded_data, original_data,
            "Binary data should be preserved"
        );

        std::fs::remove_file(test_file)?;
        Ok(())
    }

    #[test]
    fn pak_header_structure() -> Result<(), Box<dyn Error>> {
        let test_file = "test_header.pak";

        let mut pak = Pak::new();
        pak.add_file(PakFileEntry::new(
            "test.txt".to_string(),
            12 + 64,
            "Test".as_bytes(),
        ))?;

        pak.save(test_file.to_string())?;

        let data = std::fs::read(test_file)?;
        assert!(data.len() >= 12, "Header should be 12 bytes");

        assert_eq!(&data[0..4], b"PACK", "Magic bytes should be PACK");

        let mut cursor = Cursor::new(&data[4..8]);
        let offset = cursor.read_u32::<LittleEndian>()?;
        assert_eq!(offset, 12, "Offset should point after header");

        std::fs::remove_file(test_file)?;
        Ok(())
    }
}