forge-codegen 0.8.2

TypeScript code generator for the Forge framework
Documentation
//! TypeScript API bindings generator.
//!
//! Generates `api.ts` with RPC functions, subscription factories,
//! and job/workflow store factories — all driven by `FunctionBinding`
//! and the shared `emit` module. No type mapping logic lives here.

use forge_core::util::{to_camel_case, to_pascal_case};

use crate::Error;
use crate::binding::{BindingSet, FunctionBinding};
use crate::emit::{self, Position};

pub fn generate(bindings: &BindingSet) -> Result<String, Error> {
    let mut output = String::from("// Auto-generated by FORGE - DO NOT EDIT\n\n");

    // Collect all type imports across every binding.
    let mut type_imports = Vec::new();
    for binding in bindings.all() {
        for arg in &binding.args {
            emit::collect_type_imports(&arg.rust_type, &mut type_imports);
        }
        emit::collect_type_imports(&binding.return_type, &mut type_imports);
    }
    type_imports.sort();
    type_imports.dedup();

    // Runtime imports — only import what's actually used.
    let mut helpers: Vec<&str> = vec!["getForgeClient"];
    if bindings.has_subscriptions() {
        helpers.push("createSubscriptionStore");
    }
    if bindings.has_jobs() {
        helpers.push("createJobStore");
    }
    if bindings.has_workflows() {
        helpers.push("createWorkflowStore");
    }
    output.push_str(&format!(
        "import {{ {} }} from \"@forge-rs/svelte\";\n",
        helpers.join(", ")
    ));

    if !type_imports.is_empty() {
        output.push_str("import type {\n");
        for t in &type_imports {
            output.push_str(&format!("  {},\n", t));
        }
        output.push_str("} from \"./types\";\n");
    }

    // Queries
    if !bindings.queries.is_empty() {
        output.push_str("\n// Queries\n");
        for b in &bindings.queries {
            output.push_str(&gen_rpc(b));
            output.push('\n');
        }
    }

    // Subscriptions (one per query)
    if bindings.has_subscriptions() {
        output.push_str("\n// Subscriptions\n");
        for b in &bindings.queries {
            output.push_str(&gen_subscription(b));
            output.push('\n');
        }
    }

    // Mutations
    if !bindings.mutations.is_empty() {
        output.push_str("\n// Mutations\n");
        for b in &bindings.mutations {
            output.push_str(&gen_rpc(b));
            output.push('\n');
        }
    }

    // Jobs
    if bindings.has_jobs() {
        output.push_str("\n// Jobs\n");
        for b in &bindings.jobs {
            output.push_str(&gen_store_factory(b, "createJobStore"));
            output.push('\n');
        }
    }

    // Workflows
    if bindings.has_workflows() {
        output.push_str("\n// Workflows\n");
        for b in &bindings.workflows {
            output.push_str(&gen_store_factory(b, "createWorkflowStore"));
            output.push('\n');
        }
    }

    Ok(output)
}

fn gen_rpc(b: &FunctionBinding) -> String {
    let ts_name = to_camel_case(&b.name);
    let result_type = emit::ts_type(&b.return_type, Position::Return);

    if !b.has_args() {
        return format!(
            "export const {} = (): Promise<{}> =>\n  getForgeClient().call(\"{}\", null);",
            ts_name, result_type, b.name
        );
    }

    let args_type = ts_args_type(b);
    // The ForgeClient.call() method auto-detects File/Blob arguments and
    // routes to the multipart upload endpoint when needed, so we always
    // emit "call" regardless of whether the function has Upload parameters.
    format!(
        "export const {} = (args: {}): Promise<{}> =>\n  getForgeClient().call(\"{}\", args);",
        ts_name, args_type, result_type, b.name
    )
}

fn gen_subscription(b: &FunctionBinding) -> String {
    let ts_name = to_camel_case(&b.name);
    let result_type = emit::ts_type(&b.return_type, Position::Return);

    if !b.has_args() {
        return format!(
            "export const {}Store$ = () =>\n  createSubscriptionStore<null, {}>(\"{}\", null);",
            ts_name, result_type, b.name
        );
    }

    let args_type = ts_args_type(b);
    format!(
        "export const {}Store$ = (args: {}) =>\n  createSubscriptionStore<{}, {}>(\"{}\", args);",
        ts_name, args_type, args_type, result_type, b.name
    )
}

fn gen_store_factory(b: &FunctionBinding, store_fn: &str) -> String {
    let factory_name = format!("track{}", to_pascal_case(&b.name));
    let output_type = emit::ts_type(&b.return_type, Position::Return);

    if !b.has_args() {
        return format!(
            "export const {} = () =>\n  {}<null, {}>(\"{}\", null);",
            factory_name, store_fn, output_type, b.name
        );
    }

    let args_type = ts_args_type(b);
    format!(
        "export const {} = (args: {}) =>\n  {}<{}, {}>(\"{}\", args);",
        factory_name, args_type, store_fn, args_type, output_type, b.name
    )
}

/// Build the TypeScript args type expression for a function binding.
///
/// Used by both `api.rs` and `reactive.rs` — this is the single place
/// that decides whether args are wrapped in an object literal type
/// or passed as a custom struct type.
pub(crate) fn ts_args_type(b: &FunctionBinding) -> String {
    if b.args.is_empty() {
        return "null".into();
    }

    // Custom Args/Input struct: pass the type directly.
    if b.is_custom_args {
        return b
            .args
            .first()
            .map(|arg| emit::ts_type(&arg.rust_type, Position::Arg))
            .unwrap_or_else(|| "null".into());
    }

    // Otherwise wrap in an object type: { name: type, ... }
    let fields: Vec<String> = b
        .args
        .iter()
        .map(|arg| {
            format!(
                "{}: {}",
                arg.name,
                emit::ts_type(&arg.rust_type, Position::Arg)
            )
        })
        .collect();
    format!("{{ {} }}", fields.join(", "))
}

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

    #[test]
    fn test_generate_empty_registry() {
        let registry = SchemaRegistry::new();
        let bindings = BindingSet::from_registry(&registry);
        let content = generate(&bindings).expect("empty registry should generate");
        assert!(content.contains("Auto-generated by FORGE"));
        assert!(content.contains("import { getForgeClient }"));
    }

    #[test]
    fn test_generate_with_functions() {
        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 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("bindings should generate");

        assert!(content.contains("export const getUser = (args:"));
        assert!(content.contains("getForgeClient().call(\"get_user\""));
        assert!(content.contains("export const getUserStore$ = (args:"));
        assert!(content.contains("createSubscriptionStore"));
        assert!(content.contains("export const createUser = (args:"));
        assert!(content.contains("getForgeClient().call(\"create_user\""));
    }

    #[test]
    fn test_generate_no_arg_subscription() {
        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 subscription should generate");

        assert!(content.contains("export const listUsersStore$ = () =>"));
        assert!(content.contains("createSubscriptionStore<null, User[]>(\"list_users\", null)"));
    }

    #[test]
    fn test_rust_type_to_ts() {
        assert_eq!(emit::ts_type(&RustType::String, Position::Arg), "string");
        assert_eq!(emit::ts_type(&RustType::I32, Position::Arg), "number");
        assert_eq!(emit::ts_type(&RustType::Bool, Position::Arg), "boolean");
        assert_eq!(emit::ts_type(&RustType::Uuid, Position::Arg), "string");
        assert_eq!(
            emit::ts_type(&RustType::Option(Box::new(RustType::String)), Position::Arg),
            "string | null"
        );
        assert_eq!(
            emit::ts_type(&RustType::Vec(Box::new(RustType::I32)), Position::Arg),
            "number[]"
        );
        assert_eq!(
            emit::ts_type(&RustType::Custom("()".into()), Position::Arg),
            "void"
        );
    }

    #[test]
    fn test_new_datetime_types() {
        assert_eq!(emit::ts_type(&RustType::Instant, Position::Arg), "string");
        assert_eq!(emit::ts_type(&RustType::LocalDate, Position::Arg), "string");
        assert_eq!(emit::ts_type(&RustType::LocalTime, Position::Arg), "string");
    }

    #[test]
    fn test_upload_type() {
        assert_eq!(
            emit::ts_type(&RustType::Upload, Position::Arg),
            "File | Blob"
        );
    }

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

        let mut func = FunctionDef::mutation("upload_avatar", RustType::Custom("User".into()));
        func.args.push(FunctionArg::new("user_id", RustType::Uuid));
        func.args.push(FunctionArg::new("file", RustType::Upload));
        registry.register_function(func);

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

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

        // Both upload and non-upload mutations use call() since the ForgeClient
        // auto-detects File/Blob arguments and routes to multipart endpoint.
        assert!(content.contains("getForgeClient().call(\"upload_avatar\""));
        assert!(content.contains("getForgeClient().call(\"update_name\""));
    }

    #[test]
    fn test_contains_upload() {
        assert!(emit::contains_upload(&RustType::Upload));
        assert!(emit::contains_upload(&RustType::Option(Box::new(
            RustType::Upload
        ))));
        assert!(emit::contains_upload(&RustType::Vec(Box::new(
            RustType::Upload
        ))));
        assert!(emit::contains_upload(&RustType::Custom("Upload".into())));
        assert!(!emit::contains_upload(&RustType::String));
        assert!(!emit::contains_upload(&RustType::Custom("User".into())));
    }

    #[test]
    fn test_bytes_return_type() {
        assert_eq!(emit::ts_type(&RustType::Bytes, Position::Return), "Blob");
        assert_eq!(emit::ts_type(&RustType::Bytes, Position::Arg), "Uint8Array");
    }

    #[test]
    fn test_to_camel_case() {
        assert_eq!(to_camel_case("get_user"), "getUser");
        assert_eq!(to_camel_case("create_project_task"), "createProjectTask");
        assert_eq!(to_camel_case("getUser"), "getUser");
    }

    #[test]
    fn test_to_pascal_case() {
        assert_eq!(to_pascal_case("export_users"), "ExportUsers");
        assert_eq!(
            to_pascal_case("account_verification"),
            "AccountVerification"
        );
        assert_eq!(to_pascal_case("ExportUsers"), "ExportUsers");
    }
}