Skip to main content

uuid_suffix/
lib.rs

1#![doc = include_str!("../README.md")]
2
3use std::{fmt, str::FromStr};
4
5#[cfg(feature = "schemars")]
6use schemars::JsonSchema;
7#[cfg(feature = "serde")]
8use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
9use thiserror::Error;
10use uuid::Uuid;
11
12/// A parsed UUID suffix for efficient suffix matching against UUIDs.
13///
14/// Stores the UUID suffix as a `u128` value with a length field, enabling fast bitwise comparison.
15/// Accepts 1 to 32 hex characters (dashes are stripped during parsing, case-insensitive).
16///
17/// # Example
18///
19/// ```
20/// use uuid_suffix::UuidSuffix;
21///
22/// let suffix: UuidSuffix = "3f6a4e7".parse().unwrap();
23/// assert_eq!(format!("{}", suffix), "3f6a4e7");
24/// ```
25#[allow(clippy::len_without_is_empty)]
26#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialOrd, PartialEq)]
27pub struct UuidSuffix {
28    /// Number of hex digits (MIN_LEN to MAX_LEN).
29    len: u8,
30    /// The UUID suffix value, right-aligned (least significant bits).
31    value: u128,
32}
33
34impl UuidSuffix {
35    /// Minimum number of hex characters required.
36    pub const MIN_LEN: u8 = 1;
37    /// Maximum number of hex characters allowed (full UUID).
38    pub const MAX_LEN: u8 = 32;
39    /// Standard length for UUID suffixes (7 hex chars = 28 bits).
40    pub const STANDARD_LEN: u8 = 7;
41
42    /// Creates a UUID suffix from a UUID with the standard length (7 hex chars).
43    #[inline]
44    pub fn new(uuid: &Uuid) -> Self {
45        Self::with_len(uuid, Self::STANDARD_LEN)
46    }
47
48    /// Creates a max-length suffix (32 hex chars), equivalent to the full UUID.
49    #[inline]
50    pub fn full(uuid: &Uuid) -> Self {
51        Self::with_len(uuid, Self::MAX_LEN)
52    }
53
54    /// Creates a UUID suffix from a UUID with the specified length.
55    ///
56    /// # Panics
57    ///
58    /// Panics if `len` is outside `MIN_LEN..=MAX_LEN`.
59    #[inline]
60    pub fn with_len(uuid: &Uuid, len: u8) -> Self {
61        assert!((Self::MIN_LEN..=Self::MAX_LEN).contains(&len));
62        UuidSuffix {
63            value: uuid.as_u128() & Self::mask(len),
64            len,
65        }
66    }
67
68    /// Returns the number of hex digits in this UUID suffix.
69    #[inline]
70    pub fn len(&self) -> u8 {
71        self.len
72    }
73
74    /// Returns `true` if this is a max-length suffix (32 hex chars).
75    #[inline]
76    pub fn is_full(&self) -> bool {
77        self.len == Self::MAX_LEN
78    }
79
80    /// Converts to a [`Uuid`] if this is a max-length suffix.
81    #[inline]
82    pub fn to_uuid(&self) -> Option<Uuid> {
83        self.is_full().then(|| Uuid::from_u128(self.value))
84    }
85
86    /// Checks if this UUID suffix matches the suffix of the given UUID.
87    #[inline]
88    pub fn matches(&self, uuid: &Uuid) -> bool {
89        // Note: `Uuid::as_u128` packs the rightmost bytes of the UUID into the LSBs,
90        //       thus "read big-endian", which is what we need.
91        (uuid.as_u128() & Self::mask(self.len)) == self.value
92    }
93
94    /// Returns a bitmask for the given number of hex digits.
95    #[inline]
96    fn mask(len: u8) -> u128 {
97        if len == Self::MAX_LEN {
98            u128::MAX
99        } else {
100            (1u128 << (len as u32 * 4)) - 1
101        }
102    }
103}
104
105impl fmt::Display for UuidSuffix {
106    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
107        write!(f, "{:0>width$x}", self.value, width = self.len as usize)
108    }
109}
110
111impl FromStr for UuidSuffix {
112    type Err = ParseError;
113
114    #[inline]
115    fn from_str(s: &str) -> Result<Self, Self::Err> {
116        Self::try_from(s)
117    }
118}
119
120impl TryFrom<&[u8]> for UuidSuffix {
121    type Error = ParseError;
122
123    #[inline]
124    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
125        let mut buf = [0u8; UuidSuffix::MAX_LEN as usize];
126        let mut len = 0usize;
127
128        for &b in bytes {
129            if b == b'-' {
130                continue;
131            }
132            if len >= UuidSuffix::MAX_LEN as usize {
133                return Err(ParseError::TooLong);
134            }
135            if !b.is_ascii_hexdigit() {
136                return Err(ParseError::InvalidByte(b));
137            }
138            buf[len] = b.to_ascii_lowercase();
139            len += 1;
140        }
141
142        if len == 0 {
143            return Err(ParseError::Empty);
144        }
145
146        // SAFETY: buf contains only ASCII hex digits, which are valid UTF-8.
147        let s = unsafe { std::str::from_utf8_unchecked(&buf[..len]) };
148        let value =
149            u128::from_str_radix(s, 16).expect("input validated as hex digits, cannot fail");
150
151        Ok(UuidSuffix {
152            value,
153            len: len as u8,
154        })
155    }
156}
157
158impl TryFrom<&str> for UuidSuffix {
159    type Error = ParseError;
160
161    #[inline]
162    fn try_from(s: &str) -> Result<Self, Self::Error> {
163        Self::try_from(s.as_bytes())
164    }
165}
166
167/// Error returned when parsing a [`UuidSuffix`].
168#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
169pub enum ParseError {
170    /// The input is empty after stripping dashes.
171    #[error("expected 1-32 hex characters (UUID suffix), got empty input")]
172    Empty,
173
174    /// The input exceeds 32 hex characters.
175    #[error("expected 1-32 hex characters (UUID suffix), input too long")]
176    TooLong,
177
178    /// The input contains a non-hex byte.
179    #[error("expected hex digit (0-9, a-f), found 0x{0:02x}")]
180    InvalidByte(u8),
181}
182
183/// Error returned when resolving a UUID suffix.
184#[derive(Clone, Debug, Eq, Error, PartialEq)]
185pub enum ResolveError {
186    /// No UUID matched the pattern.
187    #[error("no UUID matched the pattern")]
188    NotFound,
189
190    /// Multiple UUIDs matched the pattern.
191    #[error("pattern is ambiguous, matched {} UUIDs", .0.len())]
192    Ambiguous(Vec<Uuid>),
193}
194
195#[cfg(feature = "serde")]
196impl Serialize for UuidSuffix {
197    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
198        serializer.collect_str(self)
199    }
200}
201
202#[cfg(feature = "serde")]
203impl<'de> Deserialize<'de> for UuidSuffix {
204    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
205        let s = <&str>::deserialize(deserializer)?;
206        s.parse().map_err(de::Error::custom)
207    }
208}
209
210#[cfg(feature = "schemars")]
211impl JsonSchema for UuidSuffix {
212    fn schema_name() -> String {
213        "UuidSuffix".to_owned()
214    }
215
216    fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
217        use schemars::schema::{InstanceType, Metadata, SchemaObject, StringValidation};
218
219        SchemaObject {
220            instance_type: Some(InstanceType::String.into()),
221            string: Some(Box::new(StringValidation {
222                pattern: Some("^[0-9a-fA-F-]{1,36}$".to_owned()),
223                ..Default::default()
224            })),
225            metadata: Some(Box::new(Metadata {
226                description: Some("UUID suffix (1-32 hex characters)".to_owned()),
227                ..Default::default()
228            })),
229            ..Default::default()
230        }
231        .into()
232    }
233}
234
235/// Resolves a [`UuidSuffix`] against a collection of UUIDs.
236///
237/// Returns the unique matching UUID, or an error if zero or multiple UUIDs match.
238pub fn resolve_uuid_suffix<'a, I>(iter: I, uuid_suffix: &UuidSuffix) -> Result<Uuid, ResolveError>
239where
240    I: IntoIterator<Item = &'a Uuid>,
241{
242    let mut iter = iter.into_iter().filter(|id| uuid_suffix.matches(id));
243
244    let first = *iter.next().ok_or(ResolveError::NotFound)?;
245
246    let Some(&second) = iter.next() else {
247        return Ok(first);
248    };
249
250    let mut matches = vec![first, second];
251    matches.extend(iter.copied());
252    Err(ResolveError::Ambiguous(matches))
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    #[test]
260    fn parse_normalizes() {
261        let lower: UuidSuffix = "abcd".parse().unwrap();
262        let upper: UuidSuffix = "ABCD".parse().unwrap();
263        let dashes: UuidSuffix = "ab-cd".parse().unwrap();
264        assert_eq!(lower, upper);
265        assert_eq!(lower, dashes);
266    }
267
268    #[test]
269    fn parse_rejects_invalid() {
270        assert!(matches!(UuidSuffix::try_from(""), Err(ParseError::Empty)));
271        assert!(matches!(
272            UuidSuffix::try_from("---"),
273            Err(ParseError::Empty)
274        ));
275        assert!(matches!(
276            UuidSuffix::try_from("0123456789abcdef0123456789abcdef0"),
277            Err(ParseError::TooLong)
278        ));
279        assert!(matches!(
280            UuidSuffix::try_from("ghij"),
281            Err(ParseError::InvalidByte(b'g'))
282        ));
283    }
284
285    #[test]
286    fn display() {
287        let suffix: UuidSuffix = "3f6a4e7".parse().unwrap();
288        assert_eq!(format!("{suffix}"), "3f6a4e7");
289
290        let suffix: UuidSuffix = "00abcd".parse().unwrap();
291        assert_eq!(format!("{suffix}"), "00abcd");
292    }
293
294    #[test]
295    fn matches_suffix() {
296        let uuid = Uuid::parse_str("01234567-89ab-7def-8000-aabbccddeeff").unwrap();
297        assert!(UuidSuffix::try_from("eeff").unwrap().matches(&uuid));
298        assert!(
299            UuidSuffix::try_from("0123456789ab7def8000aabbccddeeff")
300                .unwrap()
301                .matches(&uuid)
302        );
303        assert!(!UuidSuffix::try_from("ffff").unwrap().matches(&uuid));
304    }
305
306    #[test]
307    fn full_uuid_roundtrip() {
308        let uuid = Uuid::parse_str("01234567-89ab-7def-8000-aabbccddeeff").expect("valid UUID");
309
310        let suffix = UuidSuffix::full(&uuid);
311        assert!(suffix.is_full());
312        assert_eq!(suffix.to_uuid(), Some(uuid));
313
314        let partial: UuidSuffix = "aabbccddeeff".parse().expect("valid suffix");
315        assert!(!partial.is_full());
316        assert_eq!(partial.to_uuid(), None);
317    }
318
319    #[test]
320    fn resolve() {
321        let id1 = Uuid::parse_str("01234567-89ab-7def-8000-000011112222").unwrap();
322        let id2 = Uuid::parse_str("fedcba98-7654-7321-8000-000033332222").unwrap();
323        let ids = vec![id1, id2];
324
325        // Unique match
326        assert_eq!(
327            resolve_uuid_suffix(&ids, &"11112222".parse().unwrap()),
328            Ok(id1)
329        );
330        assert_eq!(
331            resolve_uuid_suffix(&ids, &"33332222".parse().unwrap()),
332            Ok(id2)
333        );
334
335        // Not found
336        assert!(matches!(
337            resolve_uuid_suffix(&ids, &"ffff".parse().unwrap()),
338            Err(ResolveError::NotFound)
339        ));
340
341        // Ambiguous (both end in 2222)
342        let result = resolve_uuid_suffix(&ids, &"2222".parse().unwrap());
343        assert!(matches!(result, Err(ResolveError::Ambiguous(v)) if v.len() == 2));
344    }
345}