frame-cli 0.3.0

CLI for Frame — five intention-verbs over one application: frame new scaffolds it, frame run serves it, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! Application-name validation: one name must satisfy both the Rust crate
//! identifier rule and the Gleam project-name rule.

use super::NewError;

/// Validates an application name against both language rules.
///
/// # Errors
///
/// Returns the specific rule (or both) the name violates, with the exact
/// grammar that would pass.
pub fn validate_name(name: &str) -> Result<(), NewError> {
    let crate_error = crate_name_error(name);
    let gleam_error = gleam_name_error(name);
    match (crate_error, gleam_error) {
        (None, None) => Ok(()),
        (Some(crate_reason), None) => Err(NewError::InvalidCrateName {
            name: name.to_owned(),
            reason: crate_reason,
        }),
        (None, Some(gleam_reason)) => Err(NewError::InvalidGleamName {
            name: name.to_owned(),
            reason: gleam_reason,
        }),
        (Some(crate_reason), Some(gleam_reason)) => Err(NewError::InvalidBothNames {
            name: name.to_owned(),
            crate_reason,
            gleam_reason,
        }),
    }
}

fn crate_name_error(name: &str) -> Option<&'static str> {
    const KEYWORDS: &[&str] = &[
        "Self", "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", "union", "unsafe", "unsized", "use", "virtual", "where", "while",
        "yield",
    ];
    let mut chars = name.chars();
    if name == "_"
        || !chars
            .next()
            .is_some_and(|first| first == '_' || first.is_ascii_alphabetic())
        || !chars.all(|character| character == '_' || character.is_ascii_alphanumeric())
    {
        return Some("it must be an ASCII Rust identifier");
    }
    KEYWORDS
        .contains(&name)
        .then_some("Rust keywords are not crate identifiers")
}

fn gleam_name_error(name: &str) -> Option<&'static str> {
    let mut chars = name.chars();
    if !chars.next().is_some_and(|first| first.is_ascii_lowercase())
        || !chars.all(|character| {
            character == '_' || character.is_ascii_lowercase() || character.is_ascii_digit()
        })
    {
        Some(
            "it must start with a lowercase ASCII letter and contain only lowercase letters, digits, or underscores",
        )
    } else {
        None
    }
}

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

    #[test]
    fn invalid_names_report_crate_gleam_and_both_rules() {
        let crate_error = validate_name("type").err().map(|error| error.to_string());
        assert!(crate_error.is_some_and(|message| {
            message.contains("Rust crate identifier") && message.contains("[a-z][a-z0-9_]*")
        }));

        let gleam_error = validate_name("Hello").err().map(|error| error.to_string());
        assert!(gleam_error.is_some_and(|message| {
            message.contains("Gleam project name") && message.contains("[a-z][a-z0-9_]*")
        }));

        let both_error = validate_name("bad-name")
            .err()
            .map(|error| error.to_string());
        assert!(both_error.is_some_and(|message| {
            message.contains("fails both rules")
                && message.contains("Rust crate identifier")
                && message.contains("Gleam project name")
        }));
    }
}