use anyhow::{Result, bail};
const RUST_KEYWORDS: &[&str] = &[
"abstract", "as", "async", "await", "become", "box", "break", "const", "continue", "crate",
"do", "dyn", "else", "enum", "extern", "false", "final", "fn", "for", "gen", "if", "impl",
"in", "let", "loop", "macro", "match", "mod", "move", "mut", "override", "priv", "pub", "ref",
"return", "self", "static", "struct", "super", "trait", "true", "try", "type", "typeof",
"unsafe", "unsized", "use", "virtual", "where", "while", "yield",
];
const RESERVED_CRATE_NAMES: &[&str] = &["alloc", "core", "proc_macro", "std", "test"];
const MAX_NAME_LEN: usize = 64;
pub fn validate_project_name(name: &str) -> Result<()> {
if name.is_empty() {
bail!("project name cannot be empty");
}
if name.len() > MAX_NAME_LEN {
bail!("project name `{name}` is longer than {MAX_NAME_LEN} characters");
}
if !name.starts_with(|c: char| c.is_ascii_alphabetic()) {
bail!("project name `{name}` must start with a letter");
}
if let Some(c) = name
.chars()
.find(|c| !(c.is_ascii_alphanumeric() || *c == '-' || *c == '_'))
{
bail!(
"project name `{name}` contains invalid character `{c}`; \
use only letters, digits, `-` and `_`"
);
}
let crate_name = name.replace('-', "_").to_ascii_lowercase();
if RUST_KEYWORDS.contains(&crate_name.as_str())
|| RESERVED_CRATE_NAMES.contains(&crate_name.as_str())
{
bail!("project name `{name}` is a reserved Rust name");
}
Ok(())
}
pub fn validate_type_name(name: &str, kind: &str) -> Result<()> {
if name.is_empty() {
bail!("{kind} name cannot be empty");
}
if name.len() > MAX_NAME_LEN {
bail!("{kind} name `{name}` is longer than {MAX_NAME_LEN} characters");
}
if !name.starts_with(|c: char| c.is_ascii_uppercase()) {
bail!("{kind} name `{name}` must start with an uppercase letter (PascalCase)");
}
if !name.chars().all(|c| c.is_ascii_alphanumeric()) {
bail!("{kind} name `{name}` must contain only letters and digits (PascalCase)");
}
let module = to_snake_case(name);
if RUST_KEYWORDS.contains(&module.as_str()) {
bail!("{kind} name `{name}` would produce the reserved module name `{module}`");
}
Ok(())
}
pub fn validate_module_name(name: &str) -> Result<()> {
validate_snake_case(name, "module")?;
if RUST_KEYWORDS.contains(&name) {
bail!("module name `{name}` is a reserved Rust keyword");
}
Ok(())
}
pub fn validate_migration_name(name: &str) -> Result<()> {
validate_snake_case(name, "migration")?;
if matches!(to_pascal_case(name).as_str(), "Migration" | "Schema") {
bail!("migration name `{name}` clashes with a framework type; choose a more specific name");
}
Ok(())
}
fn validate_snake_case(name: &str, kind: &str) -> Result<()> {
if name.is_empty() {
bail!("{kind} name cannot be empty");
}
if name.len() > MAX_NAME_LEN {
bail!("{kind} name `{name}` is longer than {MAX_NAME_LEN} characters");
}
if !name.starts_with(|c: char| c.is_ascii_lowercase())
|| !name
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_')
{
bail!("{kind} name `{name}` must be snake_case, e.g. `blog_post`");
}
Ok(())
}
pub fn to_pascal_case(name: &str) -> String {
name.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let mut chars = part.chars();
chars
.next()
.map(|first| first.to_ascii_uppercase().to_string() + chars.as_str())
.unwrap_or_default()
})
.collect()
}
pub fn to_snake_case(name: &str) -> String {
let chars: Vec<char> = name.chars().collect();
let mut out = String::with_capacity(name.len() + 4);
for (i, &c) in chars.iter().enumerate() {
if c.is_ascii_uppercase() && i > 0 {
let prev = chars[i - 1];
let next_is_lower = chars.get(i + 1).is_some_and(|n| n.is_ascii_lowercase());
if prev.is_ascii_lowercase()
|| prev.is_ascii_digit()
|| (prev.is_ascii_uppercase() && next_is_lower)
{
out.push('_');
}
}
out.push(c.to_ascii_lowercase());
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snake_case_conversion() {
assert_eq!(to_snake_case("User"), "user");
assert_eq!(to_snake_case("UserController"), "user_controller");
assert_eq!(to_snake_case("HTTPClient"), "http_client");
assert_eq!(to_snake_case("OAuth2Token"), "o_auth2_token");
assert_eq!(to_snake_case("Api"), "api");
}
#[test]
fn project_names() {
assert!(validate_project_name("my-app").is_ok());
assert!(validate_project_name("my_app2").is_ok());
assert!(validate_project_name("").is_err());
assert!(validate_project_name("1app").is_err());
assert!(validate_project_name("my app").is_err());
assert!(validate_project_name("../evil").is_err());
assert!(validate_project_name("std").is_err());
assert!(validate_project_name("self").is_err());
}
#[test]
fn type_names() {
assert!(validate_type_name("User", "model").is_ok());
assert!(validate_type_name("UserController", "controller").is_ok());
assert!(validate_type_name("user", "model").is_err());
assert!(validate_type_name("User-Model", "model").is_err());
assert!(validate_type_name("../User", "model").is_err());
assert!(validate_type_name("Type", "model").is_err());
}
#[test]
fn pascal_case_conversion() {
assert_eq!(to_pascal_case("create_users_table"), "CreateUsersTable");
assert_eq!(to_pascal_case("add_2fa_to_users"), "Add2faToUsers");
assert_eq!(to_pascal_case("a__b"), "AB");
}
#[test]
fn migration_names() {
assert!(validate_migration_name("create_users_table").is_ok());
assert!(validate_migration_name("add_email_to_users").is_ok());
assert!(validate_migration_name("CreateUsers").is_err());
assert!(validate_migration_name("1_init").is_err());
assert!(validate_migration_name("create-users").is_err());
assert!(validate_migration_name("migration").is_err());
assert!(validate_migration_name("").is_err());
}
#[test]
fn module_names() {
assert!(validate_module_name("user").is_ok());
assert!(validate_module_name("blog_post2").is_ok());
assert!(validate_module_name("User").is_err());
assert!(validate_module_name("blog-post").is_err());
assert!(validate_module_name("2fa").is_err());
assert!(validate_module_name("mod").is_err());
assert!(validate_module_name("../x").is_err());
}
}