Skip to main content

icydb_core/db/
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/// Encode raw cursor bytes as a lowercase hex token.
9#[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
19/// Decode a lowercase/uppercase hex cursor token into raw bytes.
20///
21/// The token may include surrounding whitespace, which is trimmed.
22/// Returns a descriptive error string for invalid tokens.
23pub 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///
55/// TESTS
56///
57
58#[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}