#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum HexDecodeError {
#[error("hex string must have an even number of characters, got {0}")]
OddLength(usize),
#[error("invalid hex character {0:?}")]
InvalidCharacter(char),
#[error("expected {expected} bytes, got {actual}")]
UnexpectedLength {
expected: usize,
actual: usize,
},
}
#[must_use]
pub fn is_hex(input: &str) -> bool {
!input.is_empty() && input.bytes().all(|b| b.is_ascii_hexdigit())
}
pub fn decode(input: &str) -> Result<Vec<u8>, HexDecodeError> {
if !input.len().is_multiple_of(2) {
return Err(HexDecodeError::OddLength(input.len()));
}
let bytes = input.as_bytes();
let mut out = Vec::with_capacity(input.len() / 2);
let mut index = 0;
while index < bytes.len() {
let hi = nibble(bytes.get(index).copied().unwrap_or_default())?;
let lo = nibble(bytes.get(index + 1).copied().unwrap_or_default())?;
out.push((hi << 4) | lo);
index += 2;
}
Ok(out)
}
pub fn decode_exact<const N: usize>(input: &str) -> Result<[u8; N], HexDecodeError> {
let bytes = decode(input)?;
<[u8; N]>::try_from(bytes.as_slice()).map_err(|_| HexDecodeError::UnexpectedLength {
expected: N,
actual: bytes.len(),
})
}
#[must_use]
pub fn encode(bytes: &[u8]) -> String {
use std::fmt::Write as _;
bytes
.iter()
.fold(String::with_capacity(bytes.len() * 2), |mut acc, byte| {
let _unused = write!(acc, "{byte:02x}");
acc
})
}
fn nibble(byte: u8) -> Result<u8, HexDecodeError> {
match byte {
b'0'..=b'9' => Ok(byte - b'0'),
b'a'..=b'f' => Ok(byte - b'a' + 10),
b'A'..=b'F' => Ok(byte - b'A' + 10),
_ => Err(HexDecodeError::InvalidCharacter(char::from(byte))),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn round_trips_lowercase() {
let bytes = [0x00u8, 0x0f, 0xa1, 0xff];
assert_eq!(encode(&bytes), "000fa1ff");
assert_eq!(decode("000fa1ff").unwrap(), bytes);
}
#[test]
fn accepts_uppercase_input() {
assert_eq!(decode("A1B2").unwrap(), vec![0xa1, 0xb2]);
}
#[test]
fn rejects_odd_length() {
assert_eq!(decode("abc").unwrap_err(), HexDecodeError::OddLength(3));
}
#[test]
fn rejects_non_hex_character() {
assert_eq!(
decode("zz").unwrap_err(),
HexDecodeError::InvalidCharacter('z')
);
}
#[test]
fn decode_exact_enforces_length() {
assert!(decode_exact::<2>("a1b2").is_ok());
assert_eq!(
decode_exact::<4>("a1b2").unwrap_err(),
HexDecodeError::UnexpectedLength {
expected: 4,
actual: 2
}
);
}
#[test]
fn is_hex_rejects_empty_and_garbage() {
assert!(is_hex("00ff"));
assert!(!is_hex(""));
assert!(!is_hex("00 ff"));
}
}