rjango 0.1.1

A full-stack Rust backend framework inspired by Django
Documentation
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AppTemplateConfig {
    pub app_name: String,
    pub include_urls: bool,
    pub include_tests: bool,
}

impl Default for AppTemplateConfig {
    fn default() -> Self {
        Self::new("app")
    }
}

impl AppTemplateConfig {
    #[must_use]
    pub fn new(app_name: impl Into<String>) -> Self {
        let app_name = canonical_name(app_name.into(), "app");

        Self {
            app_name,
            include_urls: true,
            include_tests: true,
        }
    }

    #[must_use]
    pub fn with_urls(mut self, value: bool) -> Self {
        self.include_urls = value;
        self
    }

    #[must_use]
    pub fn with_tests(mut self, value: bool) -> Self {
        self.include_tests = value;
        self
    }

    #[must_use]
    pub fn module_name(&self) -> String {
        normalize_identifier(&self.app_name, "app")
    }

    #[must_use]
    pub fn model_struct_name(&self) -> String {
        format!("{}Model", pascal_case(&self.module_name()))
    }

    #[must_use]
    pub fn scaffold_files(&self) -> Vec<&'static str> {
        let mut files = vec!["mod.rs", "models.rs", "views.rs"];
        if self.include_urls {
            files.push("urls.rs");
        }
        if self.include_tests {
            files.push("tests.rs");
        }
        files
    }
}

pub(crate) fn normalize_identifier(name: &str, fallback: &str) -> String {
    let mut normalized = String::new();
    let mut last_was_separator = false;

    for ch in name.chars() {
        if ch.is_ascii_alphanumeric() {
            normalized.push(ch.to_ascii_lowercase());
            last_was_separator = false;
        } else if !normalized.is_empty() && !last_was_separator {
            normalized.push('_');
            last_was_separator = true;
        }
    }

    let normalized = normalized.trim_matches('_');
    if normalized.is_empty() {
        return String::from(fallback);
    }

    if normalized
        .chars()
        .next()
        .is_some_and(|first| first.is_ascii_digit())
    {
        format!("{fallback}_{normalized}")
    } else {
        normalized.to_owned()
    }
}

fn canonical_name(name: String, fallback: &str) -> String {
    let trimmed = name.trim();
    if trimmed.is_empty() {
        String::from(fallback)
    } else {
        trimmed.to_owned()
    }
}

fn pascal_case(name: &str) -> String {
    let mut result = String::new();

    for segment in name.split('_').filter(|segment| !segment.is_empty()) {
        let mut chars = segment.chars();
        if let Some(first) = chars.next() {
            result.extend(first.to_uppercase());
            result.extend(chars);
        }
    }

    if result.is_empty() {
        String::from("App")
    } else {
        result
    }
}

#[cfg(test)]
mod tests {
    use super::AppTemplateConfig;

    #[test]
    fn default_config_enables_standard_app_files() {
        let config = AppTemplateConfig::default();

        assert_eq!(config.app_name, "app");
        assert_eq!(config.module_name(), "app");
        assert_eq!(
            config.scaffold_files(),
            vec!["mod.rs", "models.rs", "views.rs", "urls.rs", "tests.rs"]
        );
    }

    #[test]
    fn module_name_is_normalized_for_rust_modules() {
        let config = AppTemplateConfig::new("Blog Posts");

        assert_eq!(config.module_name(), "blog_posts");
        assert_eq!(config.model_struct_name(), "BlogPostsModel");
    }

    #[test]
    fn file_list_honors_optional_urls_and_tests() {
        let config = AppTemplateConfig::new("billing")
            .with_urls(false)
            .with_tests(false);

        assert_eq!(
            config.scaffold_files(),
            vec!["mod.rs", "models.rs", "views.rs"]
        );
    }

    #[test]
    fn module_name_prefixes_leading_digits() {
        let config = AppTemplateConfig::new("123 reports");

        assert_eq!(config.module_name(), "app_123_reports");
    }
}