use std::sync::LazyLock;
use regex::Regex;
use uuid::Uuid;
pub trait PathConverterTrait: Send + Sync {
fn regex(&self) -> &str;
fn to_rust(&self, value: &str) -> Option<String>;
}
#[derive(Clone, Copy, Debug, Default)]
pub struct IntConverter;
#[derive(Clone, Copy, Debug, Default)]
pub struct StrConverter;
#[derive(Clone, Copy, Debug, Default)]
pub struct SlugConverter;
#[derive(Clone, Copy, Debug, Default)]
pub struct UuidConverter;
#[derive(Clone, Copy, Debug, Default)]
pub struct PathConverter;
impl PathConverterTrait for IntConverter {
fn regex(&self) -> &str {
"[0-9]+"
}
fn to_rust(&self, value: &str) -> Option<String> {
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[0-9]+$").expect("valid int converter regex"));
RE.is_match(value)
.then(|| value.parse::<u64>().ok().map(|parsed| parsed.to_string()))
.flatten()
}
}
impl PathConverterTrait for StrConverter {
fn regex(&self) -> &str {
"[^/]+"
}
fn to_rust(&self, value: &str) -> Option<String> {
(!value.is_empty() && !value.contains('/')).then(|| value.to_owned())
}
}
impl PathConverterTrait for SlugConverter {
fn regex(&self) -> &str {
"[-a-zA-Z0-9_]+"
}
fn to_rust(&self, value: &str) -> Option<String> {
static RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[-a-zA-Z0-9_]+$").expect("valid slug converter regex"));
RE.is_match(value).then(|| value.to_owned())
}
}
impl PathConverterTrait for UuidConverter {
fn regex(&self) -> &str {
"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"
}
fn to_rust(&self, value: &str) -> Option<String> {
Uuid::parse_str(value).ok().map(|uuid| uuid.to_string())
}
}
impl PathConverterTrait for PathConverter {
fn regex(&self) -> &str {
".+"
}
fn to_rust(&self, value: &str) -> Option<String> {
(!value.is_empty()).then(|| value.to_owned())
}
}
static INT_CONVERTER: IntConverter = IntConverter;
static STR_CONVERTER: StrConverter = StrConverter;
static SLUG_CONVERTER: SlugConverter = SlugConverter;
static UUID_CONVERTER: UuidConverter = UuidConverter;
static PATH_CONVERTER: PathConverter = PathConverter;
#[must_use]
pub(crate) fn converter_for(name: &str) -> Option<&'static dyn PathConverterTrait> {
match name {
"int" => Some(&INT_CONVERTER),
"str" => Some(&STR_CONVERTER),
"slug" => Some(&SLUG_CONVERTER),
"uuid" => Some(&UUID_CONVERTER),
"path" => Some(&PATH_CONVERTER),
_ => None,
}
}
#[must_use]
pub(crate) fn normalize_with_converter(converter: &str, value: &str) -> Option<String> {
converter_for(converter)?.to_rust(value)
}
#[cfg(test)]
mod tests {
use super::{
IntConverter, PathConverter, PathConverterTrait, SlugConverter, StrConverter,
UuidConverter, converter_for,
};
#[test]
fn int_converter_validates_and_normalizes_numbers() {
let converter = IntConverter;
assert_eq!(converter.regex(), "[0-9]+");
assert_eq!(converter.to_rust("42"), Some("42".to_string()));
assert_eq!(converter.to_rust("007"), Some("7".to_string()));
assert_eq!(converter.to_rust("-1"), None);
assert_eq!(converter.to_rust("forty-two"), None);
}
#[test]
fn string_and_slug_converters_reject_invalid_segments() {
let string_converter = StrConverter;
let slug_converter = SlugConverter;
assert_eq!(string_converter.to_rust("users"), Some("users".to_string()));
assert_eq!(string_converter.to_rust("nested/path"), None);
assert_eq!(
slug_converter.to_rust("hello-world_2026"),
Some("hello-world_2026".to_string())
);
assert_eq!(slug_converter.to_rust("hello world"), None);
assert_eq!(slug_converter.to_rust("nested/path"), None);
}
#[test]
fn uuid_and_path_converters_handle_expected_shapes() {
let uuid_converter = UuidConverter;
let path_converter = PathConverter;
let value = "550e8400-e29b-41d4-a716-446655440000";
assert_eq!(uuid_converter.to_rust(value), Some(value.to_string()));
assert_eq!(uuid_converter.to_rust("not-a-uuid"), None);
assert_eq!(
path_converter.to_rust("media/avatars/user.png"),
Some("media/avatars/user.png".to_string())
);
assert_eq!(path_converter.to_rust(""), None);
}
#[test]
fn registry_exposes_default_converters() {
assert!(converter_for("int").is_some());
assert!(converter_for("str").is_some());
assert!(converter_for("slug").is_some());
assert!(converter_for("uuid").is_some());
assert!(converter_for("path").is_some());
assert!(converter_for("missing").is_none());
}
}