#[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");
}
}