slave 0.12.0

CLI for slave, a Laravel-inspired Rust framework for MCP-first apps: scaffold an MCP backend with auth, roles, teams and SurrealDB migrations, add server-rendered web apps, and route them by domain or path
//! Validation and case conversion for user-supplied names.

use anyhow::{Result, bail};

/// Rust keywords (strict and reserved) that cannot be used as module or crate names.
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",
];

/// Crate names that would clash with the standard library or Cargo conventions.
const RESERVED_CRATE_NAMES: &[&str] = &["alloc", "core", "proc_macro", "std", "test"];

const MAX_NAME_LEN: usize = 64;

/// Validates an application name, which becomes both a directory and a Cargo package name.
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(())
}

/// Validates a PascalCase type name such as `User` or `UserTools`.
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(())
}

/// Validates a domain module name such as `user` or `blog_post`.
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(())
}

/// Validates a migration name such as `create_users_table`.
pub fn validate_migration_name(name: &str) -> Result<()> {
    validate_snake_case(name, "migration")
}

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(())
}

/// Converts a PascalCase identifier to snake_case, keeping acronyms together
/// (`UserController` → `user_controller`, `HTTPClient` → `http_client`).
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 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("").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());
    }
}