shk_parser 0.1.1

A parser for Stronghold Kingdoms attack formation files (.cas)
Documentation
//! Parser for Stronghold Kingdoms .cas formation files

use crate::types::{UnitRecord, UnitType};
use thiserror::Error;

/// Errors that can occur during parsing
#[derive(Error, Debug)]
pub enum ParseError {
    /// File is too small to contain valid data
    #[error("File too small to contain header (need at least 4 bytes, got {actual})")]
    FileTooSmall { actual: usize },

    /// Unexpected end of file while reading a record
    #[error(
        "Unexpected end of file at record {record_index} (need {needed} bytes, have {available})"
    )]
    UnexpectedEndOfFile {
        record_index: usize,
        needed: usize,
        available: usize,
    },

    /// Invalid unit data
    #[error("Invalid unit data at record {record_index}: {message}")]
    InvalidUnitData {
        record_index: usize,
        message: String,
    },

    /// Formation too large
    #[error("Formation contains too many units: {count} (maximum allowed: {max_allowed})")]
    FormationTooLarge { count: usize, max_allowed: usize },

    /// IO error when reading file
    #[error("Failed to read file: {0}")]
    IoError(#[from] std::io::Error),
}

/// Result type for parsing operations
pub type ParseResult<T> = Result<T, ParseError>;

/// Maximum number of units allowed in a formation
const MAX_UNITS: usize = 500;

/// Parser for Stronghold Kingdoms .cas formation files
pub struct AttackSetupParser;

impl AttackSetupParser {
    /// Parse a complete .cas formation file
    ///
    /// # File Format
    /// - First 4 bytes: u32 little-endian = number of units
    /// - Then `count` records with variable length based on unit type:
    ///   - Archers (type 92): 3 bytes (x, y, `unit_type`)
    ///   - Pikemen (type 93): 3 bytes (x, y, `unit_type`)  
    ///   - Catapults (type 94): 5 bytes (x, y, `unit_type`, `target_x`, `target_y`)
    ///   - Captains (type 100-105): 4-6 bytes depending on ability
    ///
    /// # Examples
    /// ```no_run
    /// use shk_parser::parser::AttackSetupParser;
    ///
    /// let data = std::fs::read("formation.cas")?;
    /// let units = AttackSetupParser::parse(&data)?;
    ///
    /// for unit in &units {
    ///     println!("{}", unit);
    /// }
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    ///
    /// # Errors
    ///
    /// Returns `ParseError` if:
    /// - File is too small to contain header
    /// - Unexpected end of file while reading records
    /// - Invalid unit data in any record
    ///
    /// # Panics
    ///
    /// This function may panic if the input data is malformed in ways that
    /// cause array indexing to fail (this should not happen with valid `.cas` files).
    pub fn parse(data: &[u8]) -> ParseResult<Vec<UnitRecord>> {
        if data.len() < 4 {
            return Err(ParseError::FileTooSmall { actual: data.len() });
        }

        // Safe array access without unwrap
        let count_bytes: [u8; 4] = [data[0], data[1], data[2], data[3]];
        let count = u32::from_le_bytes(count_bytes) as usize;

        if count > MAX_UNITS {
            return Err(ParseError::FormationTooLarge {
                count,
                max_allowed: MAX_UNITS,
            });
        }

        let mut records = Vec::with_capacity(count);
        let mut offset = 4;

        for i in 0..count {
            // Determine record size first to do a single bounds check
            if offset + 3 > data.len() {
                return Err(ParseError::UnexpectedEndOfFile {
                    record_index: i,
                    needed: 3,
                    available: data.len().saturating_sub(offset),
                });
            }

            let unit_type_raw = data[offset + 2];
            let record_size = UnitType::record_size_for_raw_type(unit_type_raw);

            // Single comprehensive bounds check
            if offset + record_size > data.len() {
                return Err(ParseError::UnexpectedEndOfFile {
                    record_index: i,
                    needed: record_size,
                    available: data.len().saturating_sub(offset),
                });
            }

            // Now we know access is safe
            let x = data[offset];
            let y = data[offset + 1];

            // Extract extra data if present
            let extra_data = if record_size > 3 {
                &data[offset + 3..offset + record_size]
            } else {
                &[]
            };

            // Parse the unit type with extra data
            let unit_type = UnitType::from_bytes(unit_type_raw, extra_data).map_err(|message| {
                ParseError::InvalidUnitData {
                    record_index: i,
                    message,
                }
            })?;

            records.push(UnitRecord::new(x, y, unit_type));
            offset += record_size;
        }

        Ok(records)
    }

    /// Parse from a file path
    ///
    /// # Examples
    /// ```no_run
    /// use shk_parser::parser::AttackSetupParser;
    ///
    /// let units = AttackSetupParser::parse_file("formation.cas")?;
    /// println!("Loaded {} units", units.len());
    /// # Ok::<(), Box<dyn std::error::Error>>(())
    /// ```
    ///
    /// # Errors
    ///
    /// Returns `ParseError` if:
    /// - File cannot be read (IO error)
    /// - File contents are invalid (same as `parse` method)
    pub fn parse_file<P: AsRef<std::path::Path>>(path: P) -> ParseResult<Vec<UnitRecord>> {
        let data = std::fs::read(path)?;
        Self::parse(&data)
    }
}

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

    #[test]
    fn test_empty_file() {
        let result = AttackSetupParser::parse(&[]);
        assert!(matches!(result, Err(ParseError::FileTooSmall { .. })));
    }

    #[test]
    fn test_file_too_small() {
        let result = AttackSetupParser::parse(&[1, 2]);
        assert!(matches!(result, Err(ParseError::FileTooSmall { .. })));
    }

    #[test]
    fn test_empty_formation() {
        let data = [0, 0, 0, 0]; // 0 units
        let result = AttackSetupParser::parse(&data).unwrap();
        assert_eq!(result.len(), 0);
    }
}