forge-codegen 0.8.4

TypeScript code generator for the Forge framework
Documentation
//! Dioxus (Rust frontend) type definitions generator.
//!
//! Generates `types.rs` with Rust structs and enums for the frontend,
//! using the shared `emit` module for all type mapping.

use forge_core::schema::{FieldDef, RustType, SchemaRegistry, TableDef};

use crate::Error;
use crate::emit::{self, contains_json, contains_upload};

pub fn generate(registry: &SchemaRegistry) -> Result<String, Error> {
    let mut output = String::from(
        "// Auto-generated by FORGE - DO NOT EDIT\n\n#![allow(dead_code, unused_imports, clippy::redundant_field_names, clippy::too_many_arguments)]\n\n",
    );
    output.push_str("use serde::{Deserialize, Serialize};\n");

    // Conditional imports based on usage.
    let tables = registry.all_tables();
    let uses_upload = tables
        .iter()
        .any(|t| t.fields.iter().any(|f| contains_upload(&f.rust_type)));
    let uses_json = tables
        .iter()
        .any(|t| t.fields.iter().any(|f| contains_json(&f.rust_type)));

    if uses_upload {
        output.push_str("use forge_dioxus::ForgeUpload;\n");
    }
    if uses_json {
        output.push_str("use serde_json::Value as JsonValue;\n");
    }
    if uses_upload || uses_json {
        output.push('\n');
    }

    // Structs.
    let mut sorted_tables = tables;
    sorted_tables.sort_by(|a, b| a.struct_name.cmp(&b.struct_name));
    for table in sorted_tables {
        output.push_str(&render_struct(&table));
        output.push('\n');
    }

    // Enums.
    let mut enums = registry.all_enums();
    enums.sort_by(|a, b| a.name.cmp(&b.name));
    for enum_def in enums {
        output.push_str("#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]\n");
        output.push_str("#[serde(rename_all = \"snake_case\")]\n");
        output.push_str(&format!("pub enum {} {{\n", enum_def.name));
        for variant in enum_def.variants {
            output.push_str(&format!("    {},\n", variant.name));
        }
        output.push_str("}\n\n");
    }

    Ok(output)
}

fn render_struct(table: &TableDef) -> String {
    let has_upload = table.fields.iter().any(|f| contains_upload(&f.rust_type));

    // Upload fields are not PartialEq-comparable.
    let derives = if has_upload {
        "#[derive(Debug, Clone)]\n"
    } else {
        "#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]\n"
    };

    let mut output = String::new();
    output.push_str(derives);
    output.push_str(&format!("pub struct {} {{\n", table.struct_name));
    for field in &table.fields {
        output.push_str(&format!(
            "    pub {}: {},\n",
            field.name,
            emit::dioxus_type(&field.rust_type)
        ));
    }
    output.push_str("}\n");
    if table.is_dto {
        output.push_str(&render_struct_impl(&table.struct_name, &table.fields));
    }
    output
}

fn render_struct_impl(struct_name: &str, fields: &[FieldDef]) -> String {
    if fields.is_empty() {
        return String::new();
    }

    let required_fields: Vec<_> = fields
        .iter()
        .filter(|field| !matches!(field.rust_type, RustType::Option(_)))
        .collect();
    let optional_fields: Vec<_> = fields
        .iter()
        .filter(|field| matches!(field.rust_type, RustType::Option(_)))
        .collect();

    let constructor_params = required_fields
        .iter()
        .map(|field| format!("{}: {}", field.name, builder_param_type(&field.rust_type)))
        .collect::<Vec<_>>()
        .join(", ");

    let mut constructor_body = String::new();
    for field in &required_fields {
        constructor_body.push_str(&format!(
            "            {}: {},\n",
            field.name,
            builder_value_expr(&field.name, &field.rust_type)
        ));
    }
    for field in &optional_fields {
        constructor_body.push_str(&format!("            {}: None,\n", field.name));
    }

    let constructor = if constructor_params.is_empty() {
        format!(
            "    pub fn new() -> Self {{\n        Self {{\n{constructor_body}        }}\n    }}\n"
        )
    } else {
        format!(
            "    pub fn new({constructor_params}) -> Self {{\n        Self {{\n{constructor_body}        }}\n    }}\n"
        )
    };

    let mut setters = String::new();
    for field in optional_fields {
        let RustType::Option(inner) = &field.rust_type else {
            continue;
        };

        setters.push_str(&format!(
            "\n    pub fn {field_name}(mut self, {field_name}: {param_type}) -> Self {{\n        self.{field_name} = Some({value_expr});\n        self\n    }}\n",
            field_name = field.name,
            param_type = builder_param_type(inner),
            value_expr = builder_value_expr(&field.name, inner),
        ));
    }

    format!("\nimpl {struct_name} {{\n{constructor}{setters}}}\n")
}

fn builder_param_type(rust_type: &RustType) -> String {
    let ty = emit::dioxus_type(rust_type);
    if ty == "String" {
        "impl Into<String>".into()
    } else {
        ty
    }
}

fn builder_value_expr(name: &str, rust_type: &RustType) -> String {
    if emit::dioxus_type(rust_type) == "String" {
        format!("{name}.into()")
    } else {
        name.to_string()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use forge_core::schema::{EnumDef, EnumVariant, FieldDef, RustType, TableDef};

    #[test]
    fn maps_timestamp_alias_to_string() {
        assert_eq!(
            emit::dioxus_type(&RustType::Custom("Timestamp".into())),
            "String"
        );
    }

    #[test]
    fn generates_struct_with_fields() {
        let registry = SchemaRegistry::new();
        let mut table = TableDef::new("users", "User");
        table.fields.push(FieldDef::new("id", RustType::Uuid));
        table.fields.push(FieldDef::new("email", RustType::String));
        table.fields.push(FieldDef::new(
            "age",
            RustType::Option(Box::new(RustType::I32)),
        ));
        registry.register_table(table);

        let output = generate(&registry).expect("struct generation should succeed");
        assert!(output.contains("pub struct User {"));
        assert!(output.contains("pub id: String,"));
        assert!(output.contains("pub email: String,"));
        assert!(output.contains("pub age: Option<i32>,"));
    }

    #[test]
    fn upload_struct_skips_partial_eq() {
        let registry = SchemaRegistry::new();
        let mut table = TableDef::new("uploads", "FileUpload");
        table.fields.push(FieldDef::new("file", RustType::Upload));
        registry.register_table(table);

        let output = generate(&registry).expect("upload struct generation should succeed");
        assert!(output.contains("#[derive(Debug, Clone)]"));
        assert!(!output.contains("PartialEq"));
        assert!(output.contains("ForgeUpload"));
    }

    #[test]
    fn generates_enums() {
        let registry = SchemaRegistry::new();
        let mut enum_def = EnumDef::new("Status");
        enum_def.variants.push(EnumVariant::new("Active"));
        enum_def.variants.push(EnumVariant::new("Inactive"));
        registry.register_enum(enum_def);

        let output = generate(&registry).expect("enum generation should succeed");
        assert!(output.contains("pub enum Status {"));
        assert!(output.contains("    Active,"));
        assert!(output.contains("    Inactive,"));
        assert!(output.contains("serde(rename_all = \"snake_case\")"));
    }

    #[test]
    fn generates_dto_constructor_and_optional_builders() {
        let registry = SchemaRegistry::new();
        let mut dto = TableDef::new("update_user_input", "UpdateUserInput");
        dto.is_dto = true;
        dto.fields.push(FieldDef::new("id", RustType::Uuid));
        dto.fields.push(FieldDef::new(
            "email",
            RustType::Option(Box::new(RustType::String)),
        ));
        registry.register_table(dto);

        let output = generate(&registry).expect("dto helpers should generate");
        assert!(output.contains("impl UpdateUserInput {"));
        assert!(output.contains("pub fn new(id: impl Into<String>) -> Self"));
        assert!(output.contains("email: None"));
        assert!(output.contains("pub fn email(mut self, email: impl Into<String>) -> Self"));
    }
}