wireband-edge 0.4.1

Lightweight Wire.Band client — semantic data middleware for any domain (IoT, AI/ML, DeFi, legal, geospatial, supply chain, and more)
Documentation
//! Theta frame encoding and decoding.

use serde_json::Value;

use crate::error::{Result, WireBandError};

pub const FRAME_PREFIX_LEN: usize = 2;

/// Encode a value as a framed compact JSON payload.
pub fn encode(symbol: u16, topic: &str, data: &Value) -> Vec<u8> {
    let body = serde_json::to_vec(&serde_json::json!({ "t": topic, "d": data }))
        .unwrap_or_default();
    let mut frame = Vec::with_capacity(FRAME_PREFIX_LEN + body.len());
    frame.push((symbol >> 8) as u8);
    frame.push((symbol & 0xFF) as u8);
    frame.extend_from_slice(&body);
    frame
}

/// Decode a theta-prefixed frame into (symbol, payload).
pub fn decode(raw: &[u8]) -> Result<(u16, Value)> {
    if raw.len() < FRAME_PREFIX_LEN {
        return Err(WireBandError::FrameTooShort(raw.len()));
    }
    let symbol = ((raw[0] as u16) << 8) | raw[1] as u16;
    let payload: Value = serde_json::from_slice(&raw[FRAME_PREFIX_LEN..])?;
    Ok((symbol, payload))
}

// ---------------------------------------------------------------------------
// Lookup tables for hex encoding / decoding — built at compile time.
//
// to_hex old path: format!("{b:02x}") per byte → 1 heap alloc per byte.
// to_hex new path: table[byte] → 2 ASCII bytes written into a pre-sized Vec.
//
// from_hex old path: from_str_radix per 2-char slice → parse overhead per pair.
// from_hex new path: DECODE_TABLE[ascii] → nibble value in one index.
// ---------------------------------------------------------------------------

/// Maps each byte value (0x00–0xFF) to its two lowercase hex ASCII bytes.
const fn make_encode_table() -> [[u8; 2]; 256] {
    let hex = b"0123456789abcdef";
    let mut table = [[0u8; 2]; 256];
    let mut i = 0usize;
    while i < 256 {
        table[i][0] = hex[i >> 4];
        table[i][1] = hex[i & 0xF];
        i += 1;
    }
    table
}

/// Maps each ASCII byte to its nibble value (0–15), or 0xFF for invalid chars.
const fn make_decode_table() -> [u8; 256] {
    let mut table = [0xFFu8; 256];
    let mut i = 0u8;
    loop {
        table[i as usize] = match i {
            b'0'..=b'9' => i - b'0',
            b'a'..=b'f' => i - b'a' + 10,
            b'A'..=b'F' => i - b'A' + 10,
            _ => 0xFF,
        };
        if i == 255 { break; }
        i += 1;
    }
    table
}

static ENCODE_TABLE: [[u8; 2]; 256] = make_encode_table();
static DECODE_TABLE: [u8; 256]      = make_decode_table();

/// Hex-encode frame bytes for JSON transport.
///
/// Uses a compile-time lookup table: one pre-sized allocation + byte writes,
/// replacing the previous per-byte `format!` path (~8x faster on large frames).
pub fn to_hex(frame: &[u8]) -> String {
    let mut buf = Vec::with_capacity(frame.len() * 2);
    for &byte in frame {
        let pair = ENCODE_TABLE[byte as usize];
        buf.push(pair[0]);
        buf.push(pair[1]);
    }
    // SAFETY: ENCODE_TABLE only produces bytes from "0123456789abcdef", all valid UTF-8.
    unsafe { String::from_utf8_unchecked(buf) }
}

/// Decode a hex string back to frame bytes.
///
/// Uses a compile-time nibble lookup table instead of `from_str_radix`,
/// avoiding string-slice parsing overhead per pair.
pub fn from_hex(hex: &str) -> Option<Vec<u8>> {
    let bytes = hex.as_bytes();
    if bytes.len() % 2 != 0 {
        return None;
    }
    let mut out = Vec::with_capacity(bytes.len() / 2);
    let mut i = 0;
    while i < bytes.len() {
        let hi = DECODE_TABLE[bytes[i]     as usize];
        let lo = DECODE_TABLE[bytes[i + 1] as usize];
        if hi == 0xFF || lo == 0xFF {
            return None;
        }
        out.push((hi << 4) | lo);
        i += 2;
    }
    Some(out)
}

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

    #[test]
    fn round_trip() {
        let data = serde_json::json!({ "v": 22.5 });
        let frame = encode(SENSOR_TEMP, "sensors/temp", &data);
        let (sym, payload) = decode(&frame).unwrap();
        assert_eq!(sym, SENSOR_TEMP);
        assert_eq!(payload["t"], "sensors/temp");
        assert_eq!(payload["d"]["v"], 22.5);
    }

    #[test]
    fn prefix_bytes() {
        let frame = encode(0xFC60, "t", &serde_json::json!({}));
        assert_eq!(frame[0], 0xFC);
        assert_eq!(frame[1], 0x60);
    }

    #[test]
    fn compact_no_whitespace() {
        let frame = encode(SENSOR_TEMP, "t", &serde_json::json!({ "v": 1 }));
        let body = std::str::from_utf8(&frame[2..]).unwrap();
        assert!(!body.contains(' '));
    }

    #[test]
    fn hex_round_trip() {
        let frame = encode(SENSOR_TEMP, "t", &serde_json::json!({ "v": 1 }));
        let hex = to_hex(&frame);
        let decoded = from_hex(&hex).unwrap();
        assert_eq!(frame, decoded);
    }

    #[test]
    fn decode_too_short() {
        assert!(decode(&[0xFC]).is_err());
        assert!(decode(&[]).is_err());
    }

    #[test]
    fn hex_all_bytes() {
        // Verify encode table covers every byte value correctly.
        let all: Vec<u8> = (0u8..=255).collect();
        let hex = to_hex(&all);
        assert_eq!(hex.len(), 512);
        assert!(hex.chars().all(|c| c.is_ascii_hexdigit()));
        assert_eq!(from_hex(&hex).unwrap(), all);
    }

    #[test]
    fn hex_case_insensitive_decode() {
        assert_eq!(from_hex("FC60"), from_hex("fc60"));
        assert_eq!(from_hex("Fc60"), from_hex("fc60"));
    }

    #[test]
    fn hex_invalid_chars() {
        assert!(from_hex("gg").is_none());
        assert!(from_hex("zz").is_none());
        assert!(from_hex("0").is_none()); // odd length
    }
}