Skip to main content

icydb_core/db/codec/
cursor.rs

1///
2/// Cursor codec helpers.
3///
4/// This module owns the opaque wire-token format used for continuation cursors.
5/// It intentionally contains only token encoding/decoding logic and no query semantics.
6///
7
8///
9/// CursorDecodeError
10///
11
12#[derive(Debug, Eq, thiserror::Error, PartialEq)]
13pub enum CursorDecodeError {
14    #[error("cursor token is empty")]
15    Empty,
16
17    #[error("cursor token must have an even number of hex characters")]
18    OddLength,
19
20    #[error("invalid hex character at position {position}")]
21    InvalidHex { position: usize },
22}
23
24/// Encode raw cursor bytes as a lowercase hex token.
25#[must_use]
26pub fn encode_cursor(bytes: &[u8]) -> String {
27    let mut out = String::with_capacity(bytes.len() * 2);
28    for byte in bytes {
29        use std::fmt::Write as _;
30        let _ = write!(out, "{byte:02x}");
31    }
32    out
33}
34
35/// Decode a lowercase/uppercase hex cursor token into raw bytes.
36///
37/// The token may include surrounding whitespace, which is trimmed.
38pub fn decode_cursor(token: &str) -> Result<Vec<u8>, CursorDecodeError> {
39    let token = token.trim();
40
41    if token.is_empty() {
42        return Err(CursorDecodeError::Empty);
43    }
44
45    if !token.len().is_multiple_of(2) {
46        return Err(CursorDecodeError::OddLength);
47    }
48
49    let mut out = Vec::with_capacity(token.len() / 2);
50    let bytes = token.as_bytes();
51
52    for idx in (0..bytes.len()).step_by(2) {
53        let hi = decode_hex_nibble(bytes[idx])
54            .ok_or(CursorDecodeError::InvalidHex { position: idx + 1 })?;
55
56        let lo = decode_hex_nibble(bytes[idx + 1])
57            .ok_or(CursorDecodeError::InvalidHex { position: idx + 2 })?;
58
59        out.push((hi << 4) | lo);
60    }
61
62    Ok(out)
63}
64
65const fn decode_hex_nibble(byte: u8) -> Option<u8> {
66    match byte {
67        b'0'..=b'9' => Some(byte - b'0'),
68        b'a'..=b'f' => Some(byte - b'a' + 10),
69        b'A'..=b'F' => Some(byte - b'A' + 10),
70        _ => None,
71    }
72}
73
74///
75/// TESTS
76///
77
78#[cfg(test)]
79mod tests {
80    use super::{CursorDecodeError, decode_cursor, encode_cursor};
81
82    #[test]
83    fn decode_cursor_rejects_empty_and_whitespace_tokens() {
84        let err = decode_cursor("").expect_err("empty token should be rejected");
85        assert_eq!(err, CursorDecodeError::Empty);
86
87        let err = decode_cursor("   \n\t").expect_err("whitespace token should be rejected");
88        assert_eq!(err, CursorDecodeError::Empty);
89    }
90
91    #[test]
92    fn decode_cursor_rejects_odd_length_tokens() {
93        let err = decode_cursor("abc").expect_err("odd-length token should be rejected");
94        assert_eq!(err, CursorDecodeError::OddLength);
95    }
96
97    #[test]
98    fn decode_cursor_rejects_invalid_hex_with_position() {
99        let err = decode_cursor("0x").expect_err("invalid hex nibble should be rejected");
100        assert_eq!(err, CursorDecodeError::InvalidHex { position: 2 });
101    }
102
103    #[test]
104    fn decode_cursor_accepts_mixed_case_and_surrounding_whitespace() {
105        let bytes = decode_cursor("  0aFf10  ").expect("mixed-case hex token should decode");
106        assert_eq!(bytes, vec![0x0a, 0xff, 0x10]);
107    }
108
109    #[test]
110    fn encode_decode_cursor_round_trip_is_stable() {
111        let raw = vec![0x00, 0x01, 0x0a, 0xff];
112        let encoded = encode_cursor(&raw);
113        assert_eq!(encoded, "00010aff");
114
115        let decoded = decode_cursor(&encoded).expect("encoded token should decode");
116        assert_eq!(decoded, raw);
117    }
118}