use crate::setup::{Error, Result};
const ALPHABET: &[u8; 38] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ-.";
pub(super) fn encode(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 5);
let mut chunks = bytes.chunks_exact(3);
for chunk in &mut chunks {
let value = u32::from(chunk[0]) | (u32::from(chunk[1]) << 8) | (u32::from(chunk[2]) << 16);
push_chars(&mut out, value, 5);
}
match chunks.remainder() {
[] => {}
[b0] => push_chars(&mut out, u32::from(*b0), 2),
[b0, b1] => {
let value = u32::from(*b0) | (u32::from(*b1) << 8);
push_chars(&mut out, value, 4);
}
_ => unreachable!("chunks_exact remainder is < 3"),
}
out
}
pub(super) fn decode(s: &str) -> Result<Vec<u8>> {
let mut out = Vec::with_capacity(s.len() * 3 / 5 + 1);
let s_bytes = s.as_bytes();
let mut i = 0usize;
while i < s_bytes.len() {
let remaining = s_bytes.len() - i;
let take = if remaining >= 5 { 5 } else { remaining };
let chunk = &s_bytes[i..i + take];
let mut value: u64 = 0;
for (off, byte) in chunk.iter().enumerate().rev() {
let idx = alphabet_index(*byte, i + off)?;
value = value * 38 + u64::from(idx);
}
let byte_count = match take {
5 => 3,
4 => 2,
2 => 1,
1 | 3 => return Err(Error::InvalidBase38Char('?', i + take - 1)),
_ => unreachable!(),
};
if value >> (byte_count * 8) != 0 {
return Err(Error::Base38ChunkOutOfRange { position: i });
}
for shift in 0..byte_count {
out.push(((value >> (shift * 8)) & 0xff) as u8);
}
i += take;
}
Ok(out)
}
fn push_chars(out: &mut String, mut value: u32, count: usize) {
for _ in 0..count {
let idx = (value % 38) as usize;
out.push(ALPHABET[idx] as char);
value /= 38;
}
}
#[allow(clippy::cast_possible_truncation)] fn alphabet_index(byte: u8, position: usize) -> Result<u8> {
ALPHABET
.iter()
.position(|c| *c == byte)
.map(|idx| idx as u8)
.ok_or(Error::InvalidBase38Char(byte as char, position))
}
#[cfg(test)]
#[allow(clippy::unwrap_used)] mod tests {
use super::{decode, encode};
#[test]
fn encode_empty() {
assert_eq!(encode(&[]), "");
}
#[test]
fn encode_one_byte_zero() {
assert_eq!(encode(&[0]), "00");
}
#[test]
fn encode_one_byte_ff() {
assert_eq!(encode(&[0xFF]), "R6");
}
#[test]
fn encode_two_bytes_zero() {
assert_eq!(encode(&[0, 0]), "0000");
}
#[test]
fn encode_three_bytes_zero() {
assert_eq!(encode(&[0, 0, 0]), "00000");
}
#[test]
fn roundtrip_empty() {
let v = encode(&[]);
assert_eq!(decode(&v).unwrap(), &[] as &[u8]);
}
#[test]
fn roundtrip_small() {
for bytes in [
&[0u8][..],
&[0xFF][..],
&[0, 0][..],
&[0xFF, 0xFF][..],
&[0, 0, 0][..],
&[0xFF, 0xFF, 0xFF][..],
&[0x12, 0x34, 0x56][..],
&[1, 2, 3, 4, 5, 6][..],
&[1, 2, 3, 4, 5, 6, 7][..],
&[1, 2, 3, 4, 5, 6, 7, 8][..],
] {
let s = encode(bytes);
let back = decode(&s).unwrap();
assert_eq!(back, bytes, "roundtrip failed for {bytes:?}");
}
}
#[test]
fn decode_rejects_invalid_char() {
let err = decode("MT@").unwrap_err();
assert!(matches!(
err,
crate::setup::Error::InvalidBase38Char('@', 2)
));
}
#[test]
fn decode_rejects_chunk_of_three() {
let err = decode("ABC").unwrap_err();
assert!(matches!(err, crate::setup::Error::InvalidBase38Char(_, _)));
}
#[test]
fn decode_rejects_out_of_range_chunks() {
for input in ["S6", "OE71", "QLS18"] {
let err = decode(input).unwrap_err();
assert!(
matches!(err, crate::setup::Error::Base38ChunkOutOfRange { .. }),
"{input:?} must be rejected as out-of-range, got {err:?}"
);
}
assert_eq!(decode("R6").unwrap(), vec![0xFF]);
}
}