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