bbqr 0.3.5

Implementaion of the bbqr spec in rust
Documentation
//! The header module contains the header structure and parsing logic

use std::fmt::{self, Display, Formatter};

use radix_fmt::radix;

use crate::{consts::HEADER_LENGTH, encode::Encoding, file_type::FileType};

/// Errors that can occur when parsing a header
#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum HeaderParseError {
    #[error("No data found")]
    Empty,

    #[error("Invalid encoding {0}")]
    InvalidEncoding(char),

    #[error("Invalid FileType {0}")]
    InvalidFileType(char),

    #[error("Invalid fixed header")]
    InvalidFixedHeader,

    #[error("Invalid header size, not long enough, expected {HEADER_LENGTH} bytes, got {0}")]
    InvalidHeaderSize(usize),

    #[error("Invalid header parts {0}")]
    InvalidHeaderParts(String),
}

/// The header structure, includes the encoding, file type, and number of parts
/// Each split part has an identical header that contains the encoding, file type, and number of parts
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Header {
    pub encoding: Encoding,
    pub file_type: FileType,
    pub num_parts: usize,
}

impl Display for Header {
    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
        let base_36_count = int_to_padded_base_36(self.num_parts);

        write!(
            f,
            "B${}{}{}",
            self.encoding.as_byte() as char,
            self.file_type.as_byte() as char,
            base_36_count
        )
    }
}

impl Header {
    /// Create a new header with the given encoding, file type, and number of parts
    pub(crate) fn new(encoding: Encoding, file_type: FileType, num_parts: usize) -> Self {
        Self {
            encoding,
            file_type,
            num_parts,
        }
    }

    /// Try to parse a header from a string
    pub fn try_from_str(header_str: &str) -> Result<Self, HeaderParseError> {
        if header_str.is_empty() {
            return Err(HeaderParseError::Empty);
        }

        // note: okay to work directyl with bytes here, since only ASCII is used in the protocol
        let first_header_bytes = header_str.as_bytes();

        let header_len = header_str.len();
        if header_len < HEADER_LENGTH {
            return Err(HeaderParseError::InvalidHeaderSize(header_len));
        }

        let fixed_header = &header_str[0..2];
        if fixed_header != "B$" {
            return Err(HeaderParseError::InvalidFixedHeader);
        }

        let encoding_byte = first_header_bytes[2];
        let encoding = Encoding::from_byte(encoding_byte)
            .ok_or(HeaderParseError::InvalidEncoding(encoding_byte as char))?;

        let file_type_byte = first_header_bytes[3];
        let file_type = FileType::from_byte(file_type_byte)
            .ok_or(HeaderParseError::InvalidFileType(file_type_byte as char))?;

        let num_parts_str = &header_str[4..6];
        let num_parts = usize::from_str_radix(num_parts_str, 36).map_err(|_| {
            HeaderParseError::InvalidHeaderParts(format!(
                "Invalid number of parts: {num_parts_str}",
            ))
        })?;

        let header = Header {
            encoding,
            file_type,
            num_parts,
        };

        Ok(header)
    }
}

pub(crate) fn int_to_padded_base_36(num: usize) -> String {
    // if less than 36 need padding
    if num < 36 {
        // using in format directly does not adding padding for some reason
        let base_36 = radix(num, 36).to_string().to_uppercase();
        return format!("{:0>2}", base_36);
    }

    format!("{:#}", radix(num, 36))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::encode::Encoding;
    use crate::file_type::FileType;
    use pretty_assertions::assert_eq;

    #[test]
    fn test_header_parse() {
        let header_str = "B$ZU0801EBB6HXMU5ARSYYW2WLJIDMYZLUYC4DIBYL5AYOWJGJCBJROGN6D5WFRHNB34OYIDHD25HKZ7KGWAFM43AAH6LKT43MZN53PLFXXTEKJ27DUYBDVM37BNUY3BK6MKSPZQPNUVHY733MDKXYEYRGMV3YF764TDAWROUFESL6CBW6OWLMIKCXAVR3JLOTCCDVQY6FA7SUFMZKVYWFGEXHZX33SW5MQ7YW3DEZVCORL474AVLWA2NRVLZLIBCBFWTZDNHZ3IZ5BTAJOBMS2KGAPTW22GB7UU6KEMKJ7TF56S7IGJWKYEEHJLBQVHBPOISVJSX7Y5MFUPDPLDKCU6HRVFQNNK2MQ4DFY3ZQGDPRWVU3JATYVXBUD45T4PNLHKKPBONPLGZPN3FUESULOIGFDXHT7IWK2MX3OIA6AIC6LONCCEX3VENJ2NAFN2HSXGV64YABDGQKREDMLULDPMRME3IRF6OCCGGL4U7L6PTM527KHP5YLPMMPPA5QASTKHGNC56DEVPXWS2GBGI3J4KZX754JNXLDPSWSWCUBR25GCUYL7JZUHG76KBLX7U5DRWY2BO7L7CC5273Q7VY6VZWCG47VY7H2G4QU4FI2VX7PD2PCUS3LOJWFLF36N5NHS6ECD2JELY36XTRE3Z72AMJ2FJM6Z77Q7LNRR3GPPZJQIS57VZ4IQMWABYVSHTCS3353R5DBIIFRUYB25RPO6UXODOH3Q43AET3BYZ2XKXFZI4XNPV2RO2KZWPEWZWNTZEWRODP5OW3STAPEQ6FLXNFEIBSLKIQF2O4VXJQOFWISXNAFJX66SYHHOU2T7KKLQZDGDQUGFMPXAE2KSDNJVVHYX2GUGUXFDFQZOTEQGFA7BHCS3PG2S64WADRUYXADVO2YGHEXOLRCQIB7WL5RA6QXB2UFHVM5U5NBVCDVRQXCAAIRBXNRVMHAFRUQJMIDVSKRDCRLSQF44DW3LWR3UVJ2OICNVVSOXBFPTGJOYYUT3SODNPYA4MIVJJ2FI55ZCRPTXOU6B5ZHEXJC7GBOGNAT5Y2SGT2WXDUDG3KLGXIYT3POQJO3K2JORO7AXZLIYTPFKNHOW6NSSQAVDBDXLNFHAMLV3NBVA4OQZ57KAA3O2Z2D2IXMSGYY5UWOXTRA23KPSEBEPP2E6R2CGFVK3BVR3PSEYKOOCV5K24PVNXRWULRNOVQKPGLTFWKD4NS7BIMAOSTCLIKAOAFKV4CZDHTRHXWHU";

        let header = Header::try_from_str(header_str).unwrap();
        assert_eq!(header.encoding, Encoding::Zlib);
        assert_eq!(header.file_type, FileType::UnicodeText);
        assert_eq!(header.num_parts, 8);
    }

    #[test]
    fn fails_on_bad_header() {
        let header_str = "B#888888";
        let header = Header::try_from_str(header_str);

        assert!(header.is_err());
        assert!(header.unwrap_err() == HeaderParseError::InvalidFixedHeader);
    }

    #[test]
    fn test_int_to_padded_base_36() {
        assert_eq!(int_to_padded_base_36(0), "00");
        assert_eq!(int_to_padded_base_36(2), "02");
        assert_eq!(int_to_padded_base_36(1), "01");
        assert_eq!(int_to_padded_base_36(35), "0Z");
        assert_eq!(int_to_padded_base_36(36), "10");
        assert_eq!(int_to_padded_base_36(37), "11");
        assert_eq!(int_to_padded_base_36(556), "FG");
        assert_eq!(int_to_padded_base_36(1295), "ZZ");
    }
}