use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[non_exhaustive]
pub enum IDPolicy {
#[serde(rename = "uuid")]
#[default]
UUID,
#[serde(rename = "opaque")]
OPAQUE,
}
impl IDPolicy {
#[must_use]
pub fn enforces_uuid(self) -> bool {
self == Self::UUID
}
#[must_use]
pub const fn as_str(self) -> &'static str {
match self {
Self::UUID => "uuid",
Self::OPAQUE => "opaque",
}
}
}
impl std::fmt::Display for IDPolicy {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.as_str())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IDValidationError {
pub value: String,
pub policy: IDPolicy,
pub message: String,
}
impl std::fmt::Display for IDValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for IDValidationError {}
pub fn validate_id(id: &str, policy: IDPolicy) -> Result<(), IDValidationError> {
match policy {
IDPolicy::UUID => validate_uuid_format(id),
IDPolicy::OPAQUE => Ok(()), }
}
fn validate_uuid_format(id: &str) -> Result<(), IDValidationError> {
if id.len() != 36 {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!(
"ID must be a valid UUID (36 characters), got {} characters",
id.len()
),
});
}
let parts: Vec<&str> = id.split('-').collect();
if parts.len() != 5 {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: "ID must be a valid UUID with format XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX"
.to_string(),
});
}
let expected_lengths = [8, 4, 4, 4, 12];
for (i, (part, &expected_len)) in parts.iter().zip(&expected_lengths).enumerate() {
if part.len() != expected_len {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!(
"UUID segment {} has invalid length: expected {}, got {}",
i,
expected_len,
part.len()
),
});
}
}
for (i, part) in parts.iter().enumerate() {
if !part.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(IDValidationError {
value: id.to_string(),
policy: IDPolicy::UUID,
message: format!("UUID segment {i} contains non-hexadecimal characters: '{part}'"),
});
}
}
Ok(())
}
#[allow(dead_code)] pub fn validate_ids(ids: &[&str], policy: IDPolicy) -> Result<(), IDValidationError> {
for id in ids {
validate_id(id, policy)?;
}
Ok(())
}
pub trait IdValidator: Send + Sync {
fn validate(&self, value: &str) -> Result<(), IDValidationError>;
fn format_name(&self) -> &'static str;
}
#[derive(Debug, Clone, Copy)]
pub struct UuidIdValidator;
impl IdValidator for UuidIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
validate_uuid_format(value)
}
fn format_name(&self) -> &'static str {
"UUID"
}
}
#[derive(Debug, Clone, Copy)]
pub struct NumericIdValidator;
impl IdValidator for NumericIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
value.parse::<i64>().map_err(|_| IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} (parseable as 64-bit integer)",
self.format_name()
),
})?;
Ok(())
}
fn format_name(&self) -> &'static str {
"integer"
}
}
#[derive(Debug, Clone, Copy)]
pub struct UlidIdValidator;
impl IdValidator for UlidIdValidator {
fn validate(&self, value: &str) -> Result<(), IDValidationError> {
if value.len() != 26 {
return Err(IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} ({} characters), got {}",
self.format_name(),
26,
value.len()
),
});
}
if !value.chars().all(|c| {
c.is_ascii_digit()
|| (c.is_ascii_uppercase() && c != 'I' && c != 'L' && c != 'O' && c != 'U')
}) {
return Err(IDValidationError {
value: value.to_string(),
policy: IDPolicy::OPAQUE,
message: format!(
"ID must be a valid {} (Crockford base32: 0-9, A-Z except I, L, O, U)",
self.format_name()
),
});
}
Ok(())
}
fn format_name(&self) -> &'static str {
"ULID"
}
}
#[derive(Debug, Clone, Copy)]
pub struct OpaqueIdValidator;
impl IdValidator for OpaqueIdValidator {
fn validate(&self, _value: &str) -> Result<(), IDValidationError> {
Ok(()) }
fn format_name(&self) -> &'static str {
"opaque"
}
}
#[derive(Debug, Clone)]
pub struct IDValidationProfile {
pub name: String,
pub validator: ValidationProfileType,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum ValidationProfileType {
Uuid(UuidIdValidator),
Numeric(NumericIdValidator),
Ulid(UlidIdValidator),
Opaque(OpaqueIdValidator),
}
impl ValidationProfileType {
pub fn as_validator(&self) -> &dyn IdValidator {
match self {
Self::Uuid(v) => v,
Self::Numeric(v) => v,
Self::Ulid(v) => v,
Self::Opaque(v) => v,
}
}
}
impl IDValidationProfile {
#[must_use]
pub fn uuid() -> Self {
Self {
name: "uuid".to_string(),
validator: ValidationProfileType::Uuid(UuidIdValidator),
}
}
#[must_use]
pub fn numeric() -> Self {
Self {
name: "numeric".to_string(),
validator: ValidationProfileType::Numeric(NumericIdValidator),
}
}
#[must_use]
pub fn ulid() -> Self {
Self {
name: "ulid".to_string(),
validator: ValidationProfileType::Ulid(UlidIdValidator),
}
}
#[must_use]
pub fn opaque() -> Self {
Self {
name: "opaque".to_string(),
validator: ValidationProfileType::Opaque(OpaqueIdValidator),
}
}
#[must_use]
pub fn by_name(name: &str) -> Option<Self> {
match name.to_lowercase().as_str() {
"uuid" => Some(Self::uuid()),
"numeric" | "integer" => Some(Self::numeric()),
"ulid" => Some(Self::ulid()),
"opaque" | "string" => Some(Self::opaque()),
_ => None,
}
}
pub fn validate(&self, value: &str) -> Result<(), IDValidationError> {
self.validator.as_validator().validate(value)
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
#[test]
fn test_validate_valid_uuid() {
let result = validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("valid UUID should pass: {e}"));
}
#[test]
fn test_validate_valid_uuid_uppercase() {
let result = validate_id("550E8400-E29B-41D4-A716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("uppercase UUID should pass: {e}"));
}
#[test]
fn test_validate_valid_uuid_mixed_case() {
let result = validate_id("550e8400-E29b-41d4-A716-446655440000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("mixed-case UUID should pass: {e}"));
}
#[test]
fn test_validate_nil_uuid() {
let result = validate_id("00000000-0000-0000-0000-000000000000", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("nil UUID should pass: {e}"));
}
#[test]
fn test_validate_max_uuid() {
let result = validate_id("ffffffff-ffff-ffff-ffff-ffffffffffff", IDPolicy::UUID);
result.unwrap_or_else(|e| panic!("max UUID should pass: {e}"));
}
#[test]
fn test_validate_uuid_wrong_length() {
let result = validate_id("550e8400-e29b-41d4-a716", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"short UUID string should fail with Validation error, got: {result:?}"
);
let err = result.unwrap_err();
assert_eq!(err.policy, IDPolicy::UUID);
assert!(err.message.contains("36 characters"));
}
#[test]
fn test_validate_uuid_extra_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-446655440000x", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"extra chars should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_validate_uuid_missing_hyphens() {
let result = validate_id("550e8400e29b41d4a716446655440000", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID without hyphens should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("36 characters"));
}
#[test]
fn test_validate_uuid_wrong_segment_lengths() {
let result = validate_id("550e840-e29b-41d4-a716-4466554400001", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID with wrong segment lengths should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("segment"));
}
#[test]
fn test_validate_uuid_non_hex_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-44665544000g", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"UUID with non-hex chars should fail, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("non-hexadecimal"));
}
#[test]
fn test_validate_uuid_special_chars() {
let result = validate_id("550e8400-e29b-41d4-a716-4466554400@0", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"special chars should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_validate_uuid_empty_string() {
let result = validate_id("", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"empty string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_opaque_accepts_any_string() {
validate_id("not-a-uuid", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("anything", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("12345", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validate_id("special@chars!#$%", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
}
#[test]
fn test_opaque_accepts_empty_string() {
validate_id("", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
}
#[test]
fn test_opaque_accepts_uuid() {
validate_id("550e8400-e29b-41d4-a716-446655440000", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept UUID string: {e}"));
}
#[test]
fn test_validate_multiple_valid_uuids() {
let ids = vec![
"550e8400-e29b-41d4-a716-446655440000",
"6ba7b810-9dad-11d1-80b4-00c04fd430c8",
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
];
validate_ids(&ids, IDPolicy::UUID)
.unwrap_or_else(|e| panic!("all valid UUIDs should pass: {e}"));
}
#[test]
fn test_validate_multiple_fails_on_first_invalid() {
let ids = vec![
"550e8400-e29b-41d4-a716-446655440000",
"invalid-id",
"6ba7b811-9dad-11d1-80b4-00c04fd430c8",
];
let result = validate_ids(&ids, IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"batch with invalid ID should fail, got: {result:?}"
);
assert_eq!(result.unwrap_err().value, "invalid-id");
}
#[test]
fn test_validate_multiple_opaque_all_pass() {
let ids = vec!["anything", "goes", "here", "12345"];
validate_ids(&ids, IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept all strings: {e}"));
}
#[test]
fn test_policy_enforces_uuid() {
assert!(IDPolicy::UUID.enforces_uuid());
assert!(!IDPolicy::OPAQUE.enforces_uuid());
}
#[test]
fn test_policy_as_str() {
assert_eq!(IDPolicy::UUID.as_str(), "uuid");
assert_eq!(IDPolicy::OPAQUE.as_str(), "opaque");
}
#[test]
fn test_policy_default() {
assert_eq!(IDPolicy::default(), IDPolicy::UUID);
}
#[test]
fn test_policy_display() {
assert_eq!(format!("{}", IDPolicy::UUID), "uuid");
assert_eq!(format!("{}", IDPolicy::OPAQUE), "opaque");
}
#[test]
fn test_security_prevent_sql_injection_via_uuid() {
let result = validate_id("'; DROP TABLE users; --", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"SQL injection string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_security_prevent_path_traversal_via_uuid() {
let result = validate_id("../../etc/passwd", IDPolicy::UUID);
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"path traversal string should fail UUID validation, got: {result:?}"
);
}
#[test]
fn test_security_opaque_policy_accepts_any_format() {
validate_id("'; DROP TABLE users; --", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept SQL injection string: {e}"));
validate_id("../../etc/passwd", IDPolicy::OPAQUE)
.unwrap_or_else(|e| panic!("opaque should accept path traversal string: {e}"));
}
#[test]
fn test_validation_error_contains_policy_info() {
let err = validate_id("invalid", IDPolicy::UUID).unwrap_err();
assert_eq!(err.policy, IDPolicy::UUID);
assert_eq!(err.value, "invalid");
assert!(!err.message.is_empty());
}
#[test]
fn test_uuid_validator_valid() {
let validator = UuidIdValidator;
let result = validator.validate("550e8400-e29b-41d4-a716-446655440000");
result.unwrap_or_else(|e| panic!("valid UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_uuid_validator_invalid() {
let validator = UuidIdValidator;
let result = validator.validate("not-a-uuid");
assert!(
matches!(
result,
Err(IDValidationError {
policy: IDPolicy::UUID,
..
})
),
"invalid string should fail UuidIdValidator, got: {result:?}"
);
assert_eq!(result.unwrap_err().value, "not-a-uuid");
}
#[test]
fn test_uuid_validator_format_name() {
let validator = UuidIdValidator;
assert_eq!(validator.format_name(), "UUID");
}
#[test]
fn test_uuid_validator_nil_uuid() {
let validator = UuidIdValidator;
validator
.validate("00000000-0000-0000-0000-000000000000")
.unwrap_or_else(|e| panic!("nil UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_uuid_validator_uppercase() {
let validator = UuidIdValidator;
validator
.validate("550E8400-E29B-41D4-A716-446655440000")
.unwrap_or_else(|e| panic!("uppercase UUID should pass UuidIdValidator: {e}"));
}
#[test]
fn test_numeric_validator_valid_positive() {
let validator = NumericIdValidator;
validator
.validate("12345")
.unwrap_or_else(|e| panic!("positive int should pass: {e}"));
validator.validate("0").unwrap_or_else(|e| panic!("zero should pass: {e}"));
validator
.validate("9223372036854775807")
.unwrap_or_else(|e| panic!("i64::MAX should pass: {e}"));
}
#[test]
fn test_numeric_validator_valid_negative() {
let validator = NumericIdValidator;
validator
.validate("-1")
.unwrap_or_else(|e| panic!("negative int should pass: {e}"));
validator
.validate("-12345")
.unwrap_or_else(|e| panic!("negative int should pass: {e}"));
validator
.validate("-9223372036854775808")
.unwrap_or_else(|e| panic!("i64::MIN should pass: {e}"));
}
#[test]
fn test_numeric_validator_invalid_float() {
let validator = NumericIdValidator;
let result = validator.validate("123.45");
assert!(
matches!(result, Err(IDValidationError { .. })),
"float string should fail NumericIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert_eq!(err.value, "123.45");
}
#[test]
fn test_numeric_validator_invalid_non_numeric() {
let validator = NumericIdValidator;
let result = validator.validate("abc123");
assert!(
matches!(result, Err(IDValidationError { .. })),
"non-numeric string should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_overflow() {
let validator = NumericIdValidator;
let result = validator.validate("9223372036854775808");
assert!(
matches!(result, Err(IDValidationError { .. })),
"i64 overflow should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_empty_string() {
let validator = NumericIdValidator;
let result = validator.validate("");
assert!(
matches!(result, Err(IDValidationError { .. })),
"empty string should fail NumericIdValidator, got: {result:?}"
);
}
#[test]
fn test_numeric_validator_format_name() {
let validator = NumericIdValidator;
assert_eq!(validator.format_name(), "integer");
}
#[test]
fn test_ulid_validator_valid() {
let validator = UlidIdValidator;
validator
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("valid ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_valid_all_digits() {
let validator = UlidIdValidator;
validator
.validate("01234567890123456789012345")
.unwrap_or_else(|e| panic!("all-digit ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_valid_all_uppercase() {
let validator = UlidIdValidator;
validator
.validate("ABCDEFGHJKMNPQRSTVWXYZ0123")
.unwrap_or_else(|e| panic!("all-uppercase ULID should pass: {e}"));
}
#[test]
fn test_ulid_validator_invalid_length_short() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5F");
assert!(
matches!(result, Err(IDValidationError { .. })),
"short ULID should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("26 characters"));
}
#[test]
fn test_ulid_validator_invalid_length_long() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAVA");
assert!(
matches!(result, Err(IDValidationError { .. })),
"long ULID should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("26 characters"));
}
#[test]
fn test_ulid_validator_invalid_lowercase() {
let validator = UlidIdValidator;
let result = validator.validate("01arz3ndektsv4rrffq69g5fav");
assert!(
matches!(result, Err(IDValidationError { .. })),
"lowercase should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_i() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAI");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'I' should fail UlidIdValidator, got: {result:?}"
);
let err = result.unwrap_err();
assert!(err.message.contains("Crockford base32"));
}
#[test]
fn test_ulid_validator_invalid_char_l() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAL");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'L' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_o() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAO");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'O' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_char_u() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FAU");
assert!(
matches!(result, Err(IDValidationError { .. })),
"char 'U' should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_invalid_special_chars() {
let validator = UlidIdValidator;
let result = validator.validate("01ARZ3NDEKTSV4RRFFQ69G5FA-");
assert!(
matches!(result, Err(IDValidationError { .. })),
"special char should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_empty_string() {
let validator = UlidIdValidator;
let result = validator.validate("");
assert!(
matches!(result, Err(IDValidationError { .. })),
"empty string should fail UlidIdValidator, got: {result:?}"
);
}
#[test]
fn test_ulid_validator_format_name() {
let validator = UlidIdValidator;
assert_eq!(validator.format_name(), "ULID");
}
#[test]
fn test_opaque_validator_any_string() {
let validator = OpaqueIdValidator;
validator
.validate("anything")
.unwrap_or_else(|e| panic!("opaque should accept any string: {e}"));
validator
.validate("12345")
.unwrap_or_else(|e| panic!("opaque should accept digits: {e}"));
validator
.validate("special@chars!#$%")
.unwrap_or_else(|e| panic!("opaque should accept special chars: {e}"));
validator
.validate("")
.unwrap_or_else(|e| panic!("opaque should accept empty string: {e}"));
}
#[test]
fn test_opaque_validator_malicious_strings() {
let validator = OpaqueIdValidator;
validator
.validate("'; DROP TABLE users; --")
.unwrap_or_else(|e| panic!("opaque should accept SQL injection: {e}"));
validator
.validate("../../etc/passwd")
.unwrap_or_else(|e| panic!("opaque should accept path traversal: {e}"));
validator
.validate("<script>alert('xss')</script>")
.unwrap_or_else(|e| panic!("opaque should accept XSS: {e}"));
}
#[test]
fn test_opaque_validator_uuid() {
let validator = OpaqueIdValidator;
validator
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("opaque should accept UUID: {e}"));
}
#[test]
fn test_opaque_validator_format_name() {
let validator = OpaqueIdValidator;
assert_eq!(validator.format_name(), "opaque");
}
#[test]
fn test_validators_trait_object() {
let validators: Vec<Box<dyn IdValidator>> = vec![
Box::new(UuidIdValidator),
Box::new(NumericIdValidator),
Box::new(UlidIdValidator),
Box::new(OpaqueIdValidator),
];
for validator in validators {
let name = validator.format_name();
assert!(!name.is_empty());
}
}
#[test]
fn test_validator_selection_by_id_format() {
let uuid = "550e8400-e29b-41d4-a716-446655440000";
let numeric = "12345";
let ulid = "01ARZ3NDEKTSV4RRFFQ69G5FAV";
let uuid_validator = UuidIdValidator;
let numeric_validator = NumericIdValidator;
let ulid_validator = UlidIdValidator;
uuid_validator
.validate(uuid)
.unwrap_or_else(|e| panic!("UUID validator should accept UUID: {e}"));
numeric_validator
.validate(numeric)
.unwrap_or_else(|e| panic!("numeric validator should accept number: {e}"));
ulid_validator
.validate(ulid)
.unwrap_or_else(|e| panic!("ULID validator should accept ULID: {e}"));
assert!(
matches!(uuid_validator.validate(numeric), Err(IDValidationError { .. })),
"UUID validator should reject numeric ID"
);
assert!(
matches!(numeric_validator.validate(uuid), Err(IDValidationError { .. })),
"numeric validator should reject UUID"
);
assert!(
matches!(ulid_validator.validate(numeric), Err(IDValidationError { .. })),
"ULID validator should reject numeric ID"
);
}
#[test]
fn test_id_validation_profile_uuid() {
let profile = IDValidationProfile::uuid();
assert_eq!(profile.name, "uuid");
profile
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile should accept valid UUID: {e}"));
assert!(
matches!(profile.validate("not-a-uuid"), Err(IDValidationError { .. })),
"UUID profile should reject invalid string"
);
}
#[test]
fn test_id_validation_profile_numeric() {
let profile = IDValidationProfile::numeric();
assert_eq!(profile.name, "numeric");
profile
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
assert!(
matches!(profile.validate("not-a-number"), Err(IDValidationError { .. })),
"numeric profile should reject non-number"
);
}
#[test]
fn test_id_validation_profile_ulid() {
let profile = IDValidationProfile::ulid();
assert_eq!(profile.name, "ulid");
profile
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("ULID profile should accept valid ULID: {e}"));
assert!(
matches!(profile.validate("not-a-ulid"), Err(IDValidationError { .. })),
"ULID profile should reject invalid string"
);
}
#[test]
fn test_id_validation_profile_opaque() {
let profile = IDValidationProfile::opaque();
assert_eq!(profile.name, "opaque");
profile
.validate("anything")
.unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
profile
.validate("12345")
.unwrap_or_else(|e| panic!("opaque profile should accept digits: {e}"));
profile
.validate("special@chars!#$%")
.unwrap_or_else(|e| panic!("opaque profile should accept special chars: {e}"));
}
#[test]
fn test_id_validation_profile_by_name() {
assert!(IDValidationProfile::by_name("uuid").is_some(), "uuid profile should exist");
assert!(
IDValidationProfile::by_name("numeric").is_some(),
"numeric profile should exist"
);
assert!(IDValidationProfile::by_name("ulid").is_some(), "ulid profile should exist");
assert!(IDValidationProfile::by_name("opaque").is_some(), "opaque profile should exist");
assert!(
IDValidationProfile::by_name("UUID").is_some(),
"UUID (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("NUMERIC").is_some(),
"NUMERIC (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("ULID").is_some(),
"ULID (uppercase) should resolve"
);
assert!(
IDValidationProfile::by_name("integer").is_some(),
"integer alias should resolve"
);
assert!(IDValidationProfile::by_name("string").is_some(), "string alias should resolve");
assert!(
IDValidationProfile::by_name("invalid").is_none(),
"unknown name should return None"
);
}
#[test]
fn test_id_validation_profile_by_name_uuid_validation() {
let profile = IDValidationProfile::by_name("uuid").unwrap();
assert_eq!(profile.name, "uuid");
profile
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile by name should accept valid UUID: {e}"));
}
#[test]
fn test_id_validation_profile_by_name_numeric_validation() {
let profile = IDValidationProfile::by_name("numeric").unwrap();
assert_eq!(profile.name, "numeric");
profile
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile by name should accept number: {e}"));
}
#[test]
fn test_id_validation_profile_by_name_integer_alias() {
let profile_numeric = IDValidationProfile::by_name("numeric").unwrap();
let profile_integer = IDValidationProfile::by_name("integer").unwrap();
profile_numeric
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile should accept number: {e}"));
profile_integer
.validate("12345")
.unwrap_or_else(|e| panic!("integer alias should accept number: {e}"));
assert!(
matches!(profile_numeric.validate("not-a-number"), Err(IDValidationError { .. })),
"numeric profile should reject non-number"
);
assert!(
matches!(profile_integer.validate("not-a-number"), Err(IDValidationError { .. })),
"integer alias should reject non-number"
);
}
#[test]
fn test_id_validation_profile_by_name_string_alias() {
let profile_opaque = IDValidationProfile::by_name("opaque").unwrap();
let profile_string = IDValidationProfile::by_name("string").unwrap();
profile_opaque
.validate("anything")
.unwrap_or_else(|e| panic!("opaque profile should accept any string: {e}"));
profile_string
.validate("anything")
.unwrap_or_else(|e| panic!("string alias should accept any string: {e}"));
}
#[test]
fn test_validation_profile_type_as_validator() {
let uuid_type = ValidationProfileType::Uuid(UuidIdValidator);
uuid_type
.as_validator()
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("UUID profile type should accept valid UUID: {e}"));
let numeric_type = ValidationProfileType::Numeric(NumericIdValidator);
numeric_type
.as_validator()
.validate("12345")
.unwrap_or_else(|e| panic!("numeric profile type should accept number: {e}"));
let ulid_type = ValidationProfileType::Ulid(UlidIdValidator);
ulid_type
.as_validator()
.validate("01ARZ3NDEKTSV4RRFFQ69G5FAV")
.unwrap_or_else(|e| panic!("ULID profile type should accept valid ULID: {e}"));
let opaque_type = ValidationProfileType::Opaque(OpaqueIdValidator);
opaque_type
.as_validator()
.validate("any_value")
.unwrap_or_else(|e| panic!("opaque profile type should accept any string: {e}"));
}
#[test]
fn test_id_validation_profile_clone() {
let profile1 = IDValidationProfile::uuid();
let profile2 = profile1.clone();
assert_eq!(profile1.name, profile2.name);
profile1
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("original profile should accept valid UUID: {e}"));
profile2
.validate("550e8400-e29b-41d4-a716-446655440000")
.unwrap_or_else(|e| panic!("cloned profile should accept valid UUID: {e}"));
}
#[test]
fn test_all_profiles_available() {
let profiles = [
IDValidationProfile::uuid(),
IDValidationProfile::numeric(),
IDValidationProfile::ulid(),
IDValidationProfile::opaque(),
];
assert_eq!(profiles.len(), 4);
assert_eq!(profiles[0].name, "uuid");
assert_eq!(profiles[1].name, "numeric");
assert_eq!(profiles[2].name, "ulid");
assert_eq!(profiles[3].name, "opaque");
}
}