forge-codegen 0.10.2

TypeScript code generator for the Forge framework
Documentation
//! Dioxus API bindings generator.
//!
//! Generates `api.rs` with async functions and Dioxus hooks for
//! queries, mutations, jobs, and workflows. Uses `FunctionBinding`
//! and the shared `emit` module for type mapping.

use forge_core::schema::RustType;
use forge_core::util::to_pascal_case;

use super::builder;
use crate::Error;
use crate::binding::{BindingSet, FunctionBinding};
use crate::emit;

pub fn generate(bindings: &BindingSet) -> Result<String, Error> {
    let mut output = String::from(
        "// @generated by FORGE - DO NOT EDIT\n\n#![allow(dead_code, unused_imports, clippy::redundant_field_names, clippy::too_many_arguments, clippy::wrong_self_convention)]\n\n",
    );
    let needs_upload = bindings.all().any(|b| b.has_upload);
    output.push_str("use forge_dioxus::{\n");
    if needs_upload {
        output.push_str("    ForgeClient, ForgeClientError, ForgeUpload, Mutation, QueryState,\n");
    } else {
        output.push_str("    ForgeClient, ForgeClientError, Mutation, QueryState,\n");
    }
    output
        .push_str("    SubscriptionState, JobExecutionState, TokenPair, WorkflowExecutionState,\n");
    output.push_str("};\n\n");
    output.push_str("use super::types::*;\n");
    output.push_str(
        "use super::{\n    use_forge_query, use_forge_subscription,\n    use_forge_mutation, use_forge_job, use_forge_workflow,\n};\n\n",
    );

    for b in bindings.all() {
        let params = render_params_struct(b);
        if !params.is_empty() {
            output.push_str(&params);
            output.push('\n');
        }

        let rendered = render_binding(b);
        if !rendered.is_empty() {
            output.push_str(&rendered);
            output.push('\n');
        }
    }

    Ok(output)
}

fn render_binding(b: &FunctionBinding) -> String {
    let fn_name = &b.name;
    let return_type = emit::dioxus_type(&b.return_type);
    let fn_params = dioxus_params(b, true).unwrap_or_default();
    let hook_params_str = format_hook_params(dioxus_params(b, false));
    let call_args = dioxus_call_args(b);
    let arg_type = binding_arg_type(b);

    match b.kind {
        forge_core::schema::FunctionKind::Query => {
            format!(
                "pub async fn {fn_name}(client: &ForgeClient{fn_params}) -> Result<{return_type}, ForgeClientError> {{\n    client.call(\"{fn_name}\", {call_args}).await\n}}\n\npub fn use_{fn_name}{hook_params_str} -> QueryState<{return_type}> {{\n    use_forge_query(\"{fn_name}\", {call_args})\n}}\n\npub fn use_{fn_name}_subscription{hook_params_str} -> SubscriptionState<{return_type}> {{\n    use_forge_subscription(\"{fn_name}\", {call_args})\n}}"
            )
        }
        forge_core::schema::FunctionKind::Mutation => {
            let mutation_arg = arg_type.unwrap_or_else(|| "()".to_string());
            format!(
                "pub async fn {fn_name}(client: &ForgeClient{fn_params}) -> Result<{return_type}, ForgeClientError> {{\n    client.call(\"{fn_name}\", {call_args}).await\n}}\n\npub fn use_{fn_name}() -> Mutation<{mutation_arg}, {return_type}> {{\n    use_forge_mutation(\"{fn_name}\")\n}}"
            )
        }
        forge_core::schema::FunctionKind::Job => {
            format!(
                "pub fn use_{fn_name}{hook_params_str} -> JobExecutionState<{return_type}> {{\n    use_forge_job(\"{fn_name}\", {call_args})\n}}"
            )
        }
        forge_core::schema::FunctionKind::Workflow => {
            format!(
                "pub fn use_{fn_name}{hook_params_str} -> WorkflowExecutionState<{return_type}> {{\n    use_forge_workflow(\"{fn_name}\", {call_args})\n}}"
            )
        }
        forge_core::schema::FunctionKind::Cron => String::new(),
    }
}

fn format_hook_params(params: Option<String>) -> String {
    params
        .map(|p| format!("({p})"))
        .unwrap_or_else(|| "()".to_string())
}

fn render_params_struct(b: &FunctionBinding) -> String {
    if !should_generate_params_struct(b) {
        return String::new();
    }

    let struct_name = params_struct_name(b);
    let fields = b
        .args
        .iter()
        .map(|arg| {
            format!(
                "    pub {}: {},\n",
                arg.name,
                emit::dioxus_type(&arg.rust_type)
            )
        })
        .collect::<String>();

    let impl_block = render_struct_impl(&struct_name, &b.args);

    format!(
        "#[derive(Debug, Clone, PartialEq, serde::Serialize)]\npub struct {struct_name} {{\n{fields}}}\n{impl_block}"
    )
}

fn should_generate_params_struct(b: &FunctionBinding) -> bool {
    b.has_args() && !b.is_custom_args
}

fn params_struct_name(b: &FunctionBinding) -> String {
    format!("{}Params", to_pascal_case(&b.name))
}

fn binding_arg_type(b: &FunctionBinding) -> Option<String> {
    if b.args.is_empty() {
        return None;
    }

    if b.is_custom_args {
        return b.args.first().map(|arg| emit::dioxus_type(&arg.rust_type));
    }

    Some(params_struct_name(b))
}

/// When `with_client` is true, prepends `, args: T`; otherwise returns `args: T`.
fn dioxus_params(b: &FunctionBinding, with_client: bool) -> Option<String> {
    let arg_type = binding_arg_type(b)?;
    let params = format!("args: {arg_type}");

    if with_client {
        Some(format!(", {params}"))
    } else {
        Some(params)
    }
}

fn dioxus_call_args(b: &FunctionBinding) -> String {
    if b.args.is_empty() {
        return "()".into();
    }

    "args".into()
}

fn render_struct_impl(struct_name: &str, fields: &[forge_core::schema::FunctionArg]) -> 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 {
        let value = builder::value_expr(&field.name, &field.rust_type);
        if value == field.name {
            constructor_body.push_str(&format!("            {},\n", field.name));
        } else {
            constructor_body.push_str(&format!("            {}: {},\n", field.name, value));
        }
    }
    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!("impl {struct_name} {{\n{constructor}{setters}}}\n")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::binding::BindingSet;
    use forge_core::schema::{
        FunctionArg, FunctionDef, FunctionKind, RustType, SchemaRegistry, TableDef,
    };

    #[test]
    fn generates_query_with_subscription() {
        let registry = SchemaRegistry::new();
        let mut func = FunctionDef::query("get_user", RustType::Custom("User".into()));
        func.args.push(FunctionArg::new("id", RustType::Uuid));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("query bindings should generate");

        assert!(content.contains("pub struct GetUserParams"));
        assert!(
            content.contains("pub async fn get_user(client: &ForgeClient, args: GetUserParams)")
        );
        assert!(content.contains("pub fn use_get_user(args: GetUserParams) -> QueryState<User>"));
        assert!(content.contains("use_forge_query(\"get_user\", args)"));
        assert!(content.contains(
            "pub fn use_get_user_subscription(args: GetUserParams) -> SubscriptionState<User>"
        ));
        assert!(content.contains("use_forge_subscription(\"get_user\", args)"));
        assert!(!content.contains("use_get_user_signal"));
        assert!(!content.contains("use_get_user_query"));
        assert!(content.contains("impl GetUserParams {"));
        assert!(content.contains("pub fn new(id: impl Into<String>) -> Self"));
    }

    #[test]
    fn generates_mutation_with_typed_mutation_struct() {
        let registry = SchemaRegistry::new();
        let mut func = FunctionDef::mutation("create_user", RustType::Custom("User".into()));
        func.args.push(FunctionArg::new("name", RustType::String));
        func.args.push(FunctionArg::new("email", RustType::String));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("mutation bindings should generate");

        assert!(content.contains("pub struct CreateUserParams"));
        assert!(
            content
                .contains("pub async fn create_user(client: &ForgeClient, args: CreateUserParams)")
        );
        assert!(content.contains("client.call(\"create_user\", args).await"));
        assert!(content.contains("pub fn use_create_user() -> Mutation<CreateUserParams, User>"));
        assert!(content.contains("use_forge_mutation(\"create_user\")"));
        assert!(!content.contains("Pin<"));
        assert!(!content.contains("Box<dyn"));
    }

    #[test]
    fn generates_no_arg_query() {
        let registry = SchemaRegistry::new();
        let func = FunctionDef::query(
            "list_users",
            RustType::Vec(Box::new(RustType::Custom("User".into()))),
        );
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("no-arg query should generate");

        assert!(content.contains("pub async fn list_users(client: &ForgeClient)"));
        assert!(content.contains("client.call(\"list_users\", ()).await"));
        assert!(content.contains("pub fn use_list_users() -> QueryState<Vec<User>>"));
        assert!(
            content
                .contains("pub fn use_list_users_subscription() -> SubscriptionState<Vec<User>>")
        );
    }

    #[test]
    fn generates_job_hook() {
        let registry = SchemaRegistry::new();
        let mut func = FunctionDef::new(
            "send_email",
            FunctionKind::Job,
            RustType::Custom("()".into()),
        );
        func.args.push(FunctionArg::new("to", RustType::String));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("job bindings should generate");

        assert!(content.contains("pub struct SendEmailParams"));
        assert!(content.contains("pub fn use_send_email(args: SendEmailParams)"));
        assert!(content.contains("JobExecutionState"));
        assert!(!content.contains("use_send_email_signal"));
    }

    #[test]
    fn generates_workflow_hook() {
        let registry = SchemaRegistry::new();
        let func = FunctionDef::new(
            "onboard_user",
            FunctionKind::Workflow,
            RustType::Custom("User".into()),
        );
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("workflow bindings should generate");

        assert!(content.contains("use_onboard_user"));
        assert!(content.contains("WorkflowExecutionState<User>"));
    }

    #[test]
    fn custom_args_struct_passed_directly() {
        let registry = SchemaRegistry::new();

        let mut dto = TableDef::new("CreateUserArgs", "CreateUserArgs");
        dto.is_dto = true;
        registry.register_table(dto);

        let mut func = FunctionDef::mutation("create_user", RustType::Custom("User".into()));
        func.args.push(FunctionArg::new(
            "args",
            RustType::Custom("CreateUserArgs".into()),
        ));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("custom args bindings should generate");

        assert!(!content.contains("pub struct CreateUserParams"));
        assert!(
            content
                .contains("pub async fn create_user(client: &ForgeClient, args: CreateUserArgs)")
        );
        assert!(content.contains("client.call(\"create_user\", args).await"));
        assert!(content.contains("pub fn use_create_user() -> Mutation<CreateUserArgs, User>"));
    }

    #[test]
    fn generates_optional_builder_methods_for_params() {
        let registry = SchemaRegistry::new();
        let mut func = FunctionDef::mutation("update_user", RustType::Custom("User".into()));
        func.args.push(FunctionArg::new("id", RustType::Uuid));
        func.args.push(FunctionArg::new(
            "email",
            RustType::Option(Box::new(RustType::String)),
        ));
        registry.register_function(func);

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("optional param builders should generate");

        assert!(content.contains("pub struct UpdateUserParams"));
        assert!(content.contains("pub fn new(id: impl Into<String>) -> Self"));
        assert!(content.contains("email: None"));
        assert!(content.contains("pub fn email(mut self, email: impl Into<String>) -> Self"));
    }

    #[test]
    fn skips_cron_functions() {
        let registry = SchemaRegistry::new();
        registry.register_function(FunctionDef::new(
            "daily_cleanup",
            FunctionKind::Cron,
            RustType::Custom("()".into()),
        ));

        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("cron filtering should generate");

        assert!(!content.contains("daily_cleanup"));
    }
}