satay-codegen 0.1.0

Generate Rust client code from OpenAPI 3.1 documents
Documentation
use std::collections::BTreeSet;

use heck::{ToSnakeCase, ToUpperCamelCase};

pub(crate) fn type_ident(value: &str) -> String {
    let ident = value.to_upper_camel_case();
    sanitize_ident(&ident, "GeneratedType", IdentKind::Type)
}

pub(crate) fn variant_ident(value: &str) -> String {
    let ident = value.to_upper_camel_case();
    sanitize_ident(&ident, "Value", IdentKind::Type)
}

pub(crate) fn response_variant_ident(status: u16) -> String {
    match status {
        200 => "Ok".to_owned(),
        201 => "Created".to_owned(),
        202 => "Accepted".to_owned(),
        204 => "NoContent".to_owned(),
        400 => "BadRequest".to_owned(),
        401 => "Unauthorized".to_owned(),
        403 => "Forbidden".to_owned(),
        404 => "NotFound".to_owned(),
        409 => "Conflict".to_owned(),
        422 => "UnprocessableEntity".to_owned(),
        500 => "InternalServerError".to_owned(),
        _ => format!("Status{status}"),
    }
}

pub(crate) fn field_ident(value: &str) -> String {
    let ident = value.to_snake_case();
    sanitize_ident(&ident, "field", IdentKind::Value)
}

pub(crate) fn function_ident(value: &str) -> String {
    let ident = value.to_snake_case();
    sanitize_ident(&ident, "operation", IdentKind::Value)
}

#[derive(Debug, Clone, Copy)]
enum IdentKind {
    Type,
    Value,
}

fn sanitize_ident(value: &str, fallback: &str, kind: IdentKind) -> String {
    let mut ident = String::with_capacity(value.len().max(fallback.len()));
    for ch in value.chars() {
        if ch == '_' || ch.is_ascii_alphanumeric() {
            ident.push(ch);
        } else if !ident.ends_with('_') {
            ident.push('_');
        }
    }

    while ident.starts_with('_') {
        ident.remove(0);
    }
    while ident.ends_with('_') {
        ident.pop();
    }
    if ident.is_empty() {
        ident.push_str(fallback);
    }

    let starts_with_digit = ident
        .as_bytes()
        .first()
        .is_some_and(|byte| byte.is_ascii_digit());
    if starts_with_digit {
        match kind {
            IdentKind::Type => ident.insert_str(0, fallback),
            IdentKind::Value => ident.insert(0, '_'),
        }
    }

    if is_rust_keyword(&ident) {
        ident.push('_');
    }
    ident
}

pub(crate) fn unique_ident(candidate: String, used: &mut BTreeSet<String>) -> String {
    if used.insert(candidate.clone()) {
        return candidate;
    }

    for suffix in 2.. {
        let next = format!("{candidate}_{suffix}");
        if used.insert(next.clone()) {
            return next;
        }
    }
    unreachable!()
}

fn is_rust_keyword(value: &str) -> bool {
    matches!(
        value,
        "as" | "break"
            | "const"
            | "continue"
            | "crate"
            | "else"
            | "enum"
            | "extern"
            | "false"
            | "fn"
            | "for"
            | "if"
            | "impl"
            | "in"
            | "let"
            | "loop"
            | "match"
            | "mod"
            | "move"
            | "mut"
            | "pub"
            | "ref"
            | "return"
            | "self"
            | "Self"
            | "static"
            | "struct"
            | "super"
            | "trait"
            | "true"
            | "type"
            | "unsafe"
            | "use"
            | "where"
            | "while"
            | "async"
            | "await"
            | "dyn"
            | "abstract"
            | "become"
            | "box"
            | "do"
            | "final"
            | "macro"
            | "override"
            | "priv"
            | "typeof"
            | "unsized"
            | "virtual"
            | "yield"
            | "try"
    )
}

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

    #[test]
    fn escapes_rust_keywords_for_types_and_values() {
        assert_eq!(type_ident("type"), "Type");
        assert_eq!(field_ident("type"), "type_");
        assert_eq!(function_ident("async"), "async_");
        assert_eq!(variant_ident("Self"), "Self_");
    }

    #[test]
    fn recognizes_rust_keywords() {
        for keyword in [
            "as", "break", "const", "continue", "crate", "else", "enum", "extern", "false", "fn",
            "for", "if", "impl", "in", "let", "loop", "match", "mod", "move", "mut", "pub", "ref",
            "return", "self", "Self", "static", "struct", "super", "trait", "true", "type",
            "unsafe", "use", "where", "while", "async", "await", "dyn", "abstract", "become",
            "box", "do", "final", "macro", "override", "priv", "typeof", "unsized", "virtual",
            "yield", "try",
        ] {
            assert!(
                is_rust_keyword(keyword),
                "expected {keyword} to be a keyword"
            );
        }

        for non_keyword in ["Type", "self_", "async_", "satay"] {
            assert!(
                !is_rust_keyword(non_keyword),
                "expected {non_keyword} not to be a keyword"
            );
        }
    }

    #[test]
    fn prefixes_identifiers_that_start_with_digits() {
        assert_eq!(type_ident("123 status"), "GeneratedType123Status");
        assert_eq!(field_ident("123 status"), "_123_status");
        assert_eq!(function_ident("404"), "_404");
    }

    #[test]
    fn replaces_invalid_characters_and_uses_fallback_for_empty_names() {
        assert_eq!(field_ident("$skip"), "skip");
        assert_eq!(field_ident("user/id"), "user_id");
        assert_eq!(field_ident(" user--id "), "user_id");
        assert_eq!(field_ident(""), "field");
        assert_eq!(type_ident(""), "GeneratedType");
        assert_eq!(variant_ident(""), "Value");
    }

    #[test]
    fn allocates_stable_duplicate_suffixes() {
        let mut used = BTreeSet::new();

        assert_eq!(unique_ident("body".to_owned(), &mut used), "body");
        assert_eq!(unique_ident("body".to_owned(), &mut used), "body_2");
        assert_eq!(unique_ident("body".to_owned(), &mut used), "body_3");
    }
}