#![doc = include_str!("../README.md")]
use std::{fmt, str::FromStr};
#[cfg(feature = "schemars")]
use schemars::JsonSchema;
#[cfg(feature = "serde")]
use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
use thiserror::Error;
use uuid::Uuid;
#[allow(clippy::len_without_is_empty)]
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialOrd, PartialEq)]
pub struct UuidSuffix {
len: u8,
value: u128,
}
impl UuidSuffix {
pub const MIN_LEN: u8 = 1;
pub const MAX_LEN: u8 = 32;
pub const STANDARD_LEN: u8 = 7;
#[inline]
pub fn new(uuid: &Uuid) -> Self {
Self::with_len(uuid, Self::STANDARD_LEN)
}
#[inline]
pub fn full(uuid: &Uuid) -> Self {
Self::with_len(uuid, Self::MAX_LEN)
}
#[inline]
pub fn with_len(uuid: &Uuid, len: u8) -> Self {
assert!((Self::MIN_LEN..=Self::MAX_LEN).contains(&len));
UuidSuffix {
value: uuid.as_u128() & Self::mask(len),
len,
}
}
#[inline]
pub fn len(&self) -> u8 {
self.len
}
#[inline]
pub fn is_full(&self) -> bool {
self.len == Self::MAX_LEN
}
#[inline]
pub fn to_uuid(&self) -> Option<Uuid> {
self.is_full().then(|| Uuid::from_u128(self.value))
}
#[inline]
pub fn matches(&self, uuid: &Uuid) -> bool {
(uuid.as_u128() & Self::mask(self.len)) == self.value
}
#[inline]
fn mask(len: u8) -> u128 {
if len == Self::MAX_LEN {
u128::MAX
} else {
(1u128 << (len as u32 * 4)) - 1
}
}
}
impl fmt::Display for UuidSuffix {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{:0>width$x}", self.value, width = self.len as usize)
}
}
impl FromStr for UuidSuffix {
type Err = ParseError;
#[inline]
fn from_str(s: &str) -> Result<Self, Self::Err> {
Self::try_from(s)
}
}
impl TryFrom<&[u8]> for UuidSuffix {
type Error = ParseError;
#[inline]
fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
let mut buf = [0u8; UuidSuffix::MAX_LEN as usize];
let mut len = 0usize;
for &b in bytes {
if b == b'-' {
continue;
}
if len >= UuidSuffix::MAX_LEN as usize {
return Err(ParseError::TooLong);
}
if !b.is_ascii_hexdigit() {
return Err(ParseError::InvalidByte(b));
}
buf[len] = b.to_ascii_lowercase();
len += 1;
}
if len == 0 {
return Err(ParseError::Empty);
}
let s = unsafe { std::str::from_utf8_unchecked(&buf[..len]) };
let value =
u128::from_str_radix(s, 16).expect("input validated as hex digits, cannot fail");
Ok(UuidSuffix {
value,
len: len as u8,
})
}
}
impl TryFrom<&str> for UuidSuffix {
type Error = ParseError;
#[inline]
fn try_from(s: &str) -> Result<Self, Self::Error> {
Self::try_from(s.as_bytes())
}
}
#[derive(Clone, Copy, Debug, Eq, Error, PartialEq)]
pub enum ParseError {
#[error("expected 1-32 hex characters (UUID suffix), got empty input")]
Empty,
#[error("expected 1-32 hex characters (UUID suffix), input too long")]
TooLong,
#[error("expected hex digit (0-9, a-f), found 0x{0:02x}")]
InvalidByte(u8),
}
#[derive(Clone, Debug, Eq, Error, PartialEq)]
pub enum ResolveError {
#[error("no UUID matched the pattern")]
NotFound,
#[error("pattern is ambiguous, matched {} UUIDs", .0.len())]
Ambiguous(Vec<Uuid>),
}
#[cfg(feature = "serde")]
impl Serialize for UuidSuffix {
fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
serializer.collect_str(self)
}
}
#[cfg(feature = "serde")]
impl<'de> Deserialize<'de> for UuidSuffix {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let s = <&str>::deserialize(deserializer)?;
s.parse().map_err(de::Error::custom)
}
}
#[cfg(feature = "schemars")]
impl JsonSchema for UuidSuffix {
fn schema_name() -> String {
"UuidSuffix".to_owned()
}
fn json_schema(_: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
use schemars::schema::{InstanceType, Metadata, SchemaObject, StringValidation};
SchemaObject {
instance_type: Some(InstanceType::String.into()),
string: Some(Box::new(StringValidation {
pattern: Some("^[0-9a-fA-F-]{1,36}$".to_owned()),
..Default::default()
})),
metadata: Some(Box::new(Metadata {
description: Some("UUID suffix (1-32 hex characters)".to_owned()),
..Default::default()
})),
..Default::default()
}
.into()
}
}
pub fn resolve_uuid_suffix<'a, I>(iter: I, uuid_suffix: &UuidSuffix) -> Result<Uuid, ResolveError>
where
I: IntoIterator<Item = &'a Uuid>,
{
let mut iter = iter.into_iter().filter(|id| uuid_suffix.matches(id));
let first = *iter.next().ok_or(ResolveError::NotFound)?;
let Some(&second) = iter.next() else {
return Ok(first);
};
let mut matches = vec![first, second];
matches.extend(iter.copied());
Err(ResolveError::Ambiguous(matches))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_normalizes() {
let lower: UuidSuffix = "abcd".parse().unwrap();
let upper: UuidSuffix = "ABCD".parse().unwrap();
let dashes: UuidSuffix = "ab-cd".parse().unwrap();
assert_eq!(lower, upper);
assert_eq!(lower, dashes);
}
#[test]
fn parse_rejects_invalid() {
assert!(matches!(UuidSuffix::try_from(""), Err(ParseError::Empty)));
assert!(matches!(
UuidSuffix::try_from("---"),
Err(ParseError::Empty)
));
assert!(matches!(
UuidSuffix::try_from("0123456789abcdef0123456789abcdef0"),
Err(ParseError::TooLong)
));
assert!(matches!(
UuidSuffix::try_from("ghij"),
Err(ParseError::InvalidByte(b'g'))
));
}
#[test]
fn display() {
let suffix: UuidSuffix = "3f6a4e7".parse().unwrap();
assert_eq!(format!("{suffix}"), "3f6a4e7");
let suffix: UuidSuffix = "00abcd".parse().unwrap();
assert_eq!(format!("{suffix}"), "00abcd");
}
#[test]
fn matches_suffix() {
let uuid = Uuid::parse_str("01234567-89ab-7def-8000-aabbccddeeff").unwrap();
assert!(UuidSuffix::try_from("eeff").unwrap().matches(&uuid));
assert!(
UuidSuffix::try_from("0123456789ab7def8000aabbccddeeff")
.unwrap()
.matches(&uuid)
);
assert!(!UuidSuffix::try_from("ffff").unwrap().matches(&uuid));
}
#[test]
fn full_uuid_roundtrip() {
let uuid = Uuid::parse_str("01234567-89ab-7def-8000-aabbccddeeff").expect("valid UUID");
let suffix = UuidSuffix::full(&uuid);
assert!(suffix.is_full());
assert_eq!(suffix.to_uuid(), Some(uuid));
let partial: UuidSuffix = "aabbccddeeff".parse().expect("valid suffix");
assert!(!partial.is_full());
assert_eq!(partial.to_uuid(), None);
}
#[test]
fn resolve() {
let id1 = Uuid::parse_str("01234567-89ab-7def-8000-000011112222").unwrap();
let id2 = Uuid::parse_str("fedcba98-7654-7321-8000-000033332222").unwrap();
let ids = vec![id1, id2];
assert_eq!(
resolve_uuid_suffix(&ids, &"11112222".parse().unwrap()),
Ok(id1)
);
assert_eq!(
resolve_uuid_suffix(&ids, &"33332222".parse().unwrap()),
Ok(id2)
);
assert!(matches!(
resolve_uuid_suffix(&ids, &"ffff".parse().unwrap()),
Err(ResolveError::NotFound)
));
let result = resolve_uuid_suffix(&ids, &"2222".parse().unwrap());
assert!(matches!(result, Err(ResolveError::Ambiguous(v)) if v.len() == 2));
}
}