rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
use std::sync::LazyLock;

use regex::Regex;
use uuid::Uuid;

/// Shared behavior for Django-style path converters.
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());
    }
}