heroforge-core 0.2.2

Pure Rust core library for reading and writing Fossil SCM repositories
Documentation
use flate2::read::ZlibDecoder;
use flate2::write::ZlibEncoder;
use flate2::Compression;
use std::io::{Read, Write};

use crate::error::{FossilError, Result};
use crate::repo::Database;

use super::delta::apply_delta;

/// Decompress zlib-compressed data from Heroforge's blob format
///
/// Heroforge stores compressed blobs with a size prefix:
/// - Bytes 0-3: Big-endian uncompressed size (if first byte is not 0x78)
/// - Bytes 4+: zlib compressed data
///
/// If the first byte is 0x78 (zlib magic), there's no size prefix.
pub fn decompress(data: &[u8]) -> Result<Vec<u8>> {
    if data.is_empty() {
        return Ok(Vec::new());
    }

    // Check for direct zlib header (0x78 followed by various second bytes)
    // 0x78 0x01 - No compression
    // 0x78 0x5E - Fast compression
    // 0x78 0x9C - Default compression
    // 0x78 0xDA - Best compression
    if data[0] == 0x78 {
        let mut decoder = ZlibDecoder::new(data);
        let mut decompressed = Vec::new();
        decoder
            .read_to_end(&mut decompressed)
            .map_err(|e| FossilError::Decompression(e.to_string()))?;
        return Ok(decompressed);
    }

    // Heroforge format: 4-byte big-endian size prefix followed by zlib data
    if data.len() > 4 {
        // Check if there's a zlib header after the size prefix
        if data[4] == 0x78 {
            let expected_size = u32::from_be_bytes([data[0], data[1], data[2], data[3]]) as usize;

            let mut decoder = ZlibDecoder::new(&data[4..]);
            let mut decompressed = Vec::with_capacity(expected_size);
            decoder
                .read_to_end(&mut decompressed)
                .map_err(|e| FossilError::Decompression(e.to_string()))?;

            return Ok(decompressed);
        }
    }

    // Not compressed, return as-is
    Ok(data.to_vec())
}

/// Get fully expanded artifact content (handles delta chain)
pub fn get_artifact_content(db: &Database, rid: i64) -> Result<Vec<u8>> {
    let compressed = db.get_blob_by_rid(rid)?;
    let decompressed = decompress(&compressed)?;

    // Check if this is a delta
    if let Some(src_rid) = db.get_delta_source(rid)? {
        // Recursively get source content
        let source = get_artifact_content(db, src_rid)?;
        // Apply delta
        apply_delta(&source, &decompressed)
    } else {
        Ok(decompressed)
    }
}

/// Get artifact content by hash
pub fn get_artifact_by_hash(db: &Database, hash: &str) -> Result<Vec<u8>> {
    let rid = db.get_rid_by_hash(hash)?;
    get_artifact_content(db, rid)
}

/// Compress data using zlib with Heroforge's size prefix format
pub fn compress(data: &[u8]) -> Result<Vec<u8>> {
    if data.is_empty() {
        return Ok(Vec::new());
    }

    let mut encoder = ZlibEncoder::new(Vec::new(), Compression::default());
    encoder
        .write_all(data)
        .map_err(|e| FossilError::Decompression(e.to_string()))?;
    let compressed = encoder
        .finish()
        .map_err(|e| FossilError::Decompression(e.to_string()))?;

    // Add 4-byte big-endian size prefix
    let size = data.len() as u32;
    let mut result = Vec::with_capacity(4 + compressed.len());
    result.extend_from_slice(&size.to_be_bytes());
    result.extend_from_slice(&compressed);

    Ok(result)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_decompress_uncompressed() {
        let data = b"hello world";
        let result = decompress(data).unwrap();
        assert_eq!(result, data);
    }

    #[test]
    fn test_decompress_zlib() {
        // "hello" compressed with zlib
        let compressed = [
            0x78, 0x9c, 0xcb, 0x48, 0xcd, 0xc9, 0xc9, 0x07, 0x00, 0x06, 0x2c, 0x02, 0x15,
        ];
        let result = decompress(&compressed).unwrap();
        assert_eq!(result, b"hello");
    }

    #[test]
    fn test_decompress_with_size_prefix() {
        // "hello" with 4-byte size prefix (5 = 0x00000005)
        let compressed = [
            0x00, 0x00, 0x00, 0x05, // size = 5
            0x78, 0x9c, 0xcb, 0x48, 0xcd, 0xc9, 0xc9, 0x07, 0x00, 0x06, 0x2c, 0x02, 0x15,
        ];
        let result = decompress(&compressed).unwrap();
        assert_eq!(result, b"hello");
    }
}