Skip to main content

khive_types/
id.rs

1//! 128-bit identifier — wire format is canonical hyphenated UUID, nil sentinel at all-zeros.
2
3// REASON: `manual_range_contains` fires on the `b'0'..=b'9'` byte-range matches
4// in `hex_val`. The range-contains form (`c >= b'0' && c <= b'9'`) is less
5// readable for byte-literal matching and offers no correctness benefit here.
6#![allow(clippy::manual_range_contains)]
7
8use core::fmt;
9use core::str::FromStr;
10
11/// Return whether every byte is an ASCII digit or a lowercase hexadecimal letter.
12///
13/// The empty string returns `true`; callers retain their own length requirements.
14#[inline]
15pub fn is_lowercase_hex(value: &str) -> bool {
16    value
17        .bytes()
18        .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte))
19}
20
21/// A 128-bit opaque identifier stored as 16 bytes, formatted as a hyphenated UUID string.
22#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
23pub struct Id128([u8; 16]);
24
25impl Id128 {
26    /// The all-zeros nil identifier, used as a sentinel for "no record".
27    pub const NIL: Self = Self([0; 16]);
28
29    /// Construct an `Id128` from its raw 16-byte representation.
30    #[inline]
31    pub const fn from_bytes(bytes: [u8; 16]) -> Self {
32        Self(bytes)
33    }
34
35    /// Return a reference to the underlying 16-byte array.
36    #[inline]
37    pub const fn as_bytes(&self) -> &[u8; 16] {
38        &self.0
39    }
40
41    /// Return `true` if all 16 bytes are zero (the nil sentinel).
42    #[inline]
43    pub const fn is_nil(&self) -> bool {
44        let b = &self.0;
45        let mut i = 0;
46        while i < 16 {
47            if b[i] != 0 {
48                return false;
49            }
50            i += 1;
51        }
52        true
53    }
54
55    /// Construct an `Id128` from a `u128` in big-endian byte order.
56    #[inline]
57    pub const fn from_u128(v: u128) -> Self {
58        Self(v.to_be_bytes())
59    }
60
61    /// Convert the identifier back to its `u128` big-endian representation.
62    #[inline]
63    pub const fn to_u128(&self) -> u128 {
64        u128::from_be_bytes(self.0)
65    }
66}
67
68const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";
69
70impl fmt::Display for Id128 {
71    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72        let b = &self.0;
73        let mut buf = [0u8; 36];
74        let mut pos = 0;
75
76        // Groups: 4 bytes, 2 bytes, 2 bytes, 2 bytes, 6 bytes
77        let groups: &[(usize, usize)] = &[(0, 4), (4, 6), (6, 8), (8, 10), (10, 16)];
78        for (gi, &(start, end)) in groups.iter().enumerate() {
79            if gi > 0 {
80                buf[pos] = b'-';
81                pos += 1;
82            }
83            for i in start..end {
84                buf[pos] = HEX_CHARS[(b[i] >> 4) as usize];
85                buf[pos + 1] = HEX_CHARS[(b[i] & 0x0f) as usize];
86                pos += 2;
87            }
88        }
89        f.write_str(core::str::from_utf8(&buf[..pos]).expect("hex chars are valid utf8"))
90    }
91}
92
93impl fmt::Debug for Id128 {
94    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
95        write!(f, "Id128({self})")
96    }
97}
98
99/// Error returned when an `Id128` string cannot be parsed.
100#[derive(Clone, Copy, Debug, PartialEq, Eq)]
101pub enum ParseIdError {
102    /// The input was not 32 hex chars or 36 chars with hyphens.
103    InvalidLength,
104    /// The input contained a character that is not a valid hex digit.
105    InvalidHex,
106}
107
108impl ParseIdError {
109    /// Exact `Display` text for [`ParseIdError::InvalidLength`].
110    ///
111    /// Callers that classify parse failures by message text (e.g. the
112    /// `propose` changeset error-shape split) match on this constant rather
113    /// than on prose heuristics. The pinning test in this module fails loudly
114    /// if the wording ever changes.
115    pub const INVALID_LENGTH_TEXT: &'static str = "expected UUID: 32 hex chars or 36 with hyphens";
116
117    /// Exact `Display` text for [`ParseIdError::InvalidHex`]. See
118    /// [`ParseIdError::INVALID_LENGTH_TEXT`] for the matching contract.
119    pub const INVALID_HEX_TEXT: &'static str = "invalid hex character in UUID";
120}
121
122impl fmt::Display for ParseIdError {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        match self {
125            Self::InvalidLength => f.write_str(Self::INVALID_LENGTH_TEXT),
126            Self::InvalidHex => f.write_str(Self::INVALID_HEX_TEXT),
127        }
128    }
129}
130
131#[cfg(feature = "std")]
132impl std::error::Error for ParseIdError {}
133
134fn hex_val(c: u8) -> Option<u8> {
135    match c {
136        b'0'..=b'9' => Some(c - b'0'),
137        b'a'..=b'f' => Some(c - b'a' + 10),
138        b'A'..=b'F' => Some(c - b'A' + 10),
139        _ => None,
140    }
141}
142
143fn parse_hex_bytes(hex: &[u8]) -> Result<[u8; 16], ParseIdError> {
144    if hex.len() != 32 {
145        return Err(ParseIdError::InvalidLength);
146    }
147    let mut bytes = [0u8; 16];
148    for i in 0..16 {
149        let hi = hex_val(hex[i * 2]).ok_or(ParseIdError::InvalidHex)?;
150        let lo = hex_val(hex[i * 2 + 1]).ok_or(ParseIdError::InvalidHex)?;
151        bytes[i] = (hi << 4) | lo;
152    }
153    Ok(bytes)
154}
155
156impl FromStr for Id128 {
157    type Err = ParseIdError;
158
159    fn from_str(s: &str) -> Result<Self, Self::Err> {
160        let b = s.as_bytes();
161        match b.len() {
162            32 => Ok(Self(parse_hex_bytes(b)?)),
163            36 => {
164                // Strip hyphens at positions 8, 13, 18, 23
165                if b[8] != b'-' || b[13] != b'-' || b[18] != b'-' || b[23] != b'-' {
166                    return Err(ParseIdError::InvalidHex);
167                }
168                let mut hex = [0u8; 32];
169                hex[..8].copy_from_slice(&b[..8]);
170                hex[8..12].copy_from_slice(&b[9..13]);
171                hex[12..16].copy_from_slice(&b[14..18]);
172                hex[16..20].copy_from_slice(&b[19..23]);
173                hex[20..32].copy_from_slice(&b[24..36]);
174                Ok(Self(parse_hex_bytes(&hex)?))
175            }
176            _ => Err(ParseIdError::InvalidLength),
177        }
178    }
179}
180
181impl Default for Id128 {
182    #[inline]
183    fn default() -> Self {
184        Self::NIL
185    }
186}
187
188#[cfg(feature = "serde")]
189impl serde::Serialize for Id128 {
190    fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
191        use alloc::string::ToString;
192        serializer.serialize_str(&self.to_string())
193    }
194}
195
196#[cfg(feature = "serde")]
197impl<'de> serde::Deserialize<'de> for Id128 {
198    fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
199        // Deserialize into owned String so this works when the deserializer
200        // holds owned data (e.g. serde_json::Value) and cannot lend a &str.
201        let s = alloc::string::String::deserialize(deserializer)?;
202        s.parse().map_err(serde::de::Error::custom)
203    }
204}
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209    use alloc::format;
210    use alloc::string::ToString;
211
212    #[test]
213    fn nil() {
214        assert!(Id128::NIL.is_nil());
215        assert!(!Id128::from_u128(1).is_nil());
216    }
217
218    #[test]
219    fn roundtrip_u128() {
220        let v: u128 = 0xdeadbeef_12345678_9abcdef0_11223344;
221        let id = Id128::from_u128(v);
222        assert_eq!(id.to_u128(), v);
223    }
224
225    #[test]
226    fn display_is_hyphenated_uuid() {
227        let id = Id128::from_u128(0xabcdef0123456789abcdef0123456789);
228        let s = format!("{id}");
229        assert_eq!(s.len(), 36);
230        assert_eq!(s, "abcdef01-2345-6789-abcd-ef0123456789");
231    }
232
233    #[test]
234    fn parse_hyphenated() {
235        let id: Id128 = "abcdef01-2345-6789-abcd-ef0123456789".parse().unwrap();
236        assert_eq!(id.to_u128(), 0xabcdef0123456789abcdef0123456789);
237    }
238
239    #[test]
240    fn parse_simple() {
241        let id: Id128 = "abcdef0123456789abcdef0123456789".parse().unwrap();
242        assert_eq!(id.to_u128(), 0xabcdef0123456789abcdef0123456789);
243    }
244
245    #[test]
246    fn display_parse_roundtrip() {
247        let id = Id128::from_u128(0xabcdef0123456789abcdef0123456789);
248        let s = format!("{id}");
249        let parsed: Id128 = s.parse().unwrap();
250        assert_eq!(parsed, id);
251    }
252
253    #[test]
254    fn parse_errors() {
255        assert_eq!("abc".parse::<Id128>(), Err(ParseIdError::InvalidLength));
256        assert_eq!(
257            "zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz".parse::<Id128>(),
258            Err(ParseIdError::InvalidHex)
259        );
260        // Wrong hyphen positions
261        assert_eq!(
262            "abcdef01-2345-6789-abcd-ef012345678".parse::<Id128>(),
263            Err(ParseIdError::InvalidLength)
264        );
265    }
266
267    /// Pin the `ParseIdError` Display wording: downstream error-shape
268    /// classification (propose changeset identifier failures) matches on
269    /// these exact strings, so a wording change must break this test at the
270    /// source rather than silently drift a caller's heuristic.
271    #[test]
272    fn parse_error_display_is_pinned() {
273        assert_eq!(
274            ParseIdError::InvalidLength.to_string(),
275            ParseIdError::INVALID_LENGTH_TEXT
276        );
277        assert_eq!(
278            ParseIdError::InvalidHex.to_string(),
279            ParseIdError::INVALID_HEX_TEXT
280        );
281    }
282
283    #[test]
284    fn ordering() {
285        let a = Id128::from_u128(1);
286        let b = Id128::from_u128(2);
287        assert!(a < b);
288    }
289
290    #[test]
291    fn lowercase_hex_accepts_digits_and_a_through_f_only() {
292        assert!(is_lowercase_hex("0123456789abcdef"));
293        assert!(is_lowercase_hex(""));
294        assert!(!is_lowercase_hex("abcdeF"));
295        assert!(!is_lowercase_hex("abcdeg"));
296        assert!(!is_lowercase_hex("ab cd"));
297        assert!(!is_lowercase_hex("0x12"));
298        assert!(!is_lowercase_hex("ab\u{e9}"));
299    }
300
301    /// C1 regression: Id128 must deserialize from an owned serde_json::Value string,
302    /// not only from a borrowed &str.  Previously used `<&str>::deserialize` which
303    /// fails when the deserializer holds owned data (e.g. Value-backed deserializer).
304    #[cfg(feature = "serde")]
305    #[test]
306    fn deserialize_from_owned_value() {
307        use alloc::string::ToString;
308        let uuid_str = "abcdef01-2345-6789-abcd-ef0123456789";
309        // serde_json::from_value takes a Value (owned), exercising the owned-string path.
310        let val = serde_json::Value::String(uuid_str.to_string());
311        let id: Id128 = serde_json::from_value(val).expect("Id128 must deserialize from Value");
312        assert_eq!(format!("{id}"), uuid_str);
313    }
314}