o192 0.2.2

ORION-192: ordered, resilient, independent, URL-safe 192-bit IDs for distributed systems.
Documentation
//! Wire-format codec: sortable64 ↔ 24-byte binary.

use crate::alphabet::{ALPHABET, ID_SIZE_BYTES, ID_SIZE_CHARS, MAX_RELATIVE_MS};
use crate::error::OrionIdError;

/// Encode exactly 24 bytes into the canonical 32-character sortable64
/// string.
///
/// # Errors
///
/// Returns [`OrionIdError::InvalidLength`] if `bytes` is not exactly
/// [`ID_SIZE_BYTES`] long.
pub fn encode_sortable64(bytes: &[u8]) -> Result<String, OrionIdError> {
    if bytes.len() != ID_SIZE_BYTES {
        return Err(OrionIdError::InvalidLength);
    }

    let alphabet = ALPHABET.as_bytes();
    let mut out = String::with_capacity(ID_SIZE_CHARS);

    // `chunks_exact(3)` lets the optimiser unroll the bit-twiddling.
    for chunk in bytes.chunks_exact(3) {
        let n = (u32::from(chunk[0]) << 16) | (u32::from(chunk[1]) << 8) | u32::from(chunk[2]);
        out.push(char::from(alphabet[((n >> 18) & 63) as usize]));
        out.push(char::from(alphabet[((n >> 12) & 63) as usize]));
        out.push(char::from(alphabet[((n >> 6) & 63) as usize]));
        out.push(char::from(alphabet[(n & 63) as usize]));
    }

    Ok(out)
}

/// Decode a canonical 32-character ORION-192 string into 24 bytes.
///
/// # Errors
///
/// - [`OrionIdError::InvalidLength`] when `id` is not exactly
///   [`ID_SIZE_CHARS`] characters long.
/// - [`OrionIdError::InvalidCharacter`] when `id` contains a character
///   outside the sortable64 alphabet.
pub fn decode_sortable64(id: &str) -> Result<[u8; ID_SIZE_BYTES], OrionIdError> {
    // Length is checked on the byte slice because every sortable64
    // character is exactly one ASCII byte.
    if id.len() != ID_SIZE_CHARS {
        return Err(OrionIdError::InvalidLength);
    }

    let input = id.as_bytes();
    let mut out = [0u8; ID_SIZE_BYTES];
    let mut cursor = 0usize;

    for chunk in input.chunks_exact(4) {
        let v0 = u32::from(decode_value(chunk[0])?);
        let v1 = u32::from(decode_value(chunk[1])?);
        let v2 = u32::from(decode_value(chunk[2])?);
        let v3 = u32::from(decode_value(chunk[3])?);
        let n = (v0 << 18) | (v1 << 12) | (v2 << 6) | v3;
        out[cursor] = ((n >> 16) & 0xff) as u8;
        out[cursor + 1] = ((n >> 8) & 0xff) as u8;
        out[cursor + 2] = (n & 0xff) as u8;
        cursor += 3;
    }

    Ok(out)
}

/// Map a single ASCII byte to its 6-bit sortable64 value.
#[inline]
const fn decode_value(byte: u8) -> Result<u8, OrionIdError> {
    match byte {
        b'0'..=b'9' => Ok(byte - b'0'),
        b'A'..=b'Z' => Ok(10 + byte - b'A'),
        b'_' => Ok(36),
        b'a'..=b'z' => Ok(37 + byte - b'a'),
        b'~' => Ok(63),
        _ => Err(OrionIdError::InvalidCharacter),
    }
}

/// Read a 48-bit big-endian unsigned integer at `offset`.
#[inline]
pub(crate) fn read_uint48_be(bytes: &[u8; ID_SIZE_BYTES], offset: usize) -> u128 {
    (u128::from(bytes[offset]) << 40)
        | (u128::from(bytes[offset + 1]) << 32)
        | (u128::from(bytes[offset + 2]) << 24)
        | (u128::from(bytes[offset + 3]) << 16)
        | (u128::from(bytes[offset + 4]) << 8)
        | u128::from(bytes[offset + 5])
}

/// Write a 48-bit big-endian unsigned integer at `offset`.
#[inline]
pub(crate) fn write_uint48_be(
    out: &mut [u8; ID_SIZE_BYTES],
    offset: usize,
    value: u128,
) -> Result<(), OrionIdError> {
    if value > MAX_RELATIVE_MS {
        return Err(OrionIdError::TimestampOverflow);
    }
    out[offset] = ((value >> 40) & 0xff) as u8;
    out[offset + 1] = ((value >> 32) & 0xff) as u8;
    out[offset + 2] = ((value >> 24) & 0xff) as u8;
    out[offset + 3] = ((value >> 16) & 0xff) as u8;
    out[offset + 4] = ((value >> 8) & 0xff) as u8;
    out[offset + 5] = (value & 0xff) as u8;
    Ok(())
}

/// Lower-case hex encoding without depending on an external crate.
pub(crate) fn to_hex(bytes: &[u8]) -> String {
    const HEX: &[u8; 16] = b"0123456789abcdef";
    let mut out = String::with_capacity(bytes.len() * 2);
    for &byte in bytes {
        out.push(char::from(HEX[(byte >> 4) as usize]));
        out.push(char::from(HEX[(byte & 0x0f) as usize]));
    }
    out
}