1#[must_use]
10pub fn encode_cursor(bytes: &[u8]) -> String {
11 let mut out = String::with_capacity(bytes.len() * 2);
12 for byte in bytes {
13 use std::fmt::Write as _;
14 let _ = write!(out, "{byte:02x}");
15 }
16 out
17}
18
19pub fn decode_cursor(token: &str) -> Result<Vec<u8>, String> {
24 let token = token.trim();
25 if token.is_empty() {
26 return Err("cursor token is empty".to_string());
27 }
28 if !token.len().is_multiple_of(2) {
29 return Err("cursor token must have an even number of hex characters".to_string());
30 }
31
32 let mut out = Vec::with_capacity(token.len() / 2);
33 let bytes = token.as_bytes();
34 for idx in (0..bytes.len()).step_by(2) {
35 let hi = decode_hex_nibble(bytes[idx])
36 .ok_or_else(|| format!("invalid hex character at position {}", idx + 1))?;
37 let lo = decode_hex_nibble(bytes[idx + 1])
38 .ok_or_else(|| format!("invalid hex character at position {}", idx + 2))?;
39 out.push((hi << 4) | lo);
40 }
41
42 Ok(out)
43}
44
45const fn decode_hex_nibble(byte: u8) -> Option<u8> {
46 match byte {
47 b'0'..=b'9' => Some(byte - b'0'),
48 b'a'..=b'f' => Some(byte - b'a' + 10),
49 b'A'..=b'F' => Some(byte - b'A' + 10),
50 _ => None,
51 }
52}
53
54#[cfg(test)]
59mod tests {
60 use super::{decode_cursor, encode_cursor};
61
62 #[test]
63 fn decode_cursor_rejects_empty_and_whitespace_tokens() {
64 let err = decode_cursor("").expect_err("empty token should be rejected");
65 assert_eq!(err, "cursor token is empty");
66
67 let err = decode_cursor(" \n\t").expect_err("whitespace token should be rejected");
68 assert_eq!(err, "cursor token is empty");
69 }
70
71 #[test]
72 fn decode_cursor_rejects_odd_length_tokens() {
73 let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
74 assert_eq!(
75 err,
76 "cursor token must have an even number of hex characters"
77 );
78 }
79
80 #[test]
81 fn decode_cursor_rejects_invalid_hex_with_position() {
82 let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
83 assert_eq!(err, "invalid hex character at position 2");
84 }
85
86 #[test]
87 fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
88 let bytes = decode_cursor(" 0aFf10 ").expect("mixed-case hex token should decode");
89 assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
90 }
91
92 #[test]
93 fn encode_decode_cursor_round_trip_is_stable() {
94 let raw = vec![0x00, 0x01, 0x0a, 0xff];
95 let encoded = encode_cursor(&raw);
96 assert_eq!(encoded, "00010aff");
97
98 let decoded = decode_cursor(&encoded).expect("encoded token should decode");
99 assert_eq!(decoded, raw);
100 }
101}