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#[allow(clippy::len_without_is_empty)]
26#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialOrd, PartialEq)]
27pub struct UuidSuffix {
28 len: u8,
30 value: u128,
32}
33
34impl UuidSuffix {
35 pub const MIN_LEN: u8 = 1;
37 pub const MAX_LEN: u8 = 32;
39 pub const STANDARD_LEN: u8 = 7;
41
42 #[inline]
44 pub fn new(uuid: &Uuid) -> Self {
45 Self::with_len(uuid, Self::STANDARD_LEN)
46 }
47
48 #[inline]
50 pub fn full(uuid: &Uuid) -> Self {
51 Self::with_len(uuid, Self::MAX_LEN)
52 }
53
54 #[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 #[inline]
70 pub fn len(&self) -> u8 {
71 self.len
72 }
73
74 #[inline]
76 pub fn is_full(&self) -> bool {
77 self.len == Self::MAX_LEN
78 }
79
80 #[inline]
82 pub fn to_uuid(&self) -> Option<Uuid> {
83 self.is_full().then(|| Uuid::from_u128(self.value))
84 }
85
86 #[inline]
88 pub fn matches(&self, uuid: &Uuid) -> bool {
89 (uuid.as_u128() & Self::mask(self.len)) == self.value
92 }
93
94 #[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 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#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
169pub enum ParseError {
170 #[error("expected 1-32 hex characters (UUID suffix), got empty input")]
172 Empty,
173
174 #[error("expected 1-32 hex characters (UUID suffix), input too long")]
176 TooLong,
177
178 #[error("expected hex digit (0-9, a-f), found 0x{0:02x}")]
180 InvalidByte(u8),
181}
182
183#[derive(Clone, Debug, Eq, Error, PartialEq)]
185pub enum ResolveError {
186 #[error("no UUID matched the pattern")]
188 NotFound,
189
190 #[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
235pub 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 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 assert!(matches!(
337 resolve_uuid_suffix(&ids, &"ffff".parse().unwrap()),
338 Err(ResolveError::NotFound)
339 ));
340
341 let result = resolve_uuid_suffix(&ids, &"2222".parse().unwrap());
343 assert!(matches!(result, Err(ResolveError::Ambiguous(v)) if v.len() == 2));
344 }
345}