forge-codegen 0.0.1-alpha

TypeScript code generator for the Forge framework
Documentation
use std::path::PathBuf;

use forge_core::schema::{FunctionKind, RustType, SchemaRegistry};

use super::Error;

/// Generates TypeScript API bindings.
pub struct ApiGenerator {
    #[allow(dead_code)]
    output_dir: PathBuf,
}

impl ApiGenerator {
    /// Create a new API generator.
    pub fn new(output_dir: impl Into<PathBuf>) -> Self {
        Self {
            output_dir: output_dir.into(),
        }
    }

    /// Generate API bindings from the schema registry.
    pub fn generate(&self, registry: &SchemaRegistry) -> Result<String, Error> {
        let mut output = String::new();

        // Add header
        output.push_str(self.generate_header());

        // Add helper types and functions
        output.push_str(self.generate_helpers());

        // Generate bindings for each function
        let functions = registry.all_functions();
        if !functions.is_empty() {
            output.push_str("\n// ============================================================================\n");
            output.push_str("// Generated API Bindings\n");
            output.push_str("// ============================================================================\n\n");

            for func in &functions {
                // Add doc comment if present
                if let Some(doc) = &func.doc {
                    output.push_str("/**\n");
                    for line in doc.lines() {
                        output.push_str(" * ");
                        output.push_str(line);
                        output.push('\n');
                    }
                    output.push_str(" */\n");
                }

                // Skip non-client-callable functions (jobs, crons, workflows)
                if !func.kind.is_client_callable() {
                    continue;
                }

                // Generate the binding
                let ts_name = to_camel_case(&func.name);
                let creator = match func.kind {
                    FunctionKind::Query => "createQuery",
                    FunctionKind::Mutation => "createMutation",
                    FunctionKind::Action => "createAction",
                    // Jobs, crons, and workflows are not callable from frontend
                    FunctionKind::Job | FunctionKind::Cron | FunctionKind::Workflow => continue,
                };

                // Build args type
                let args_type = if func.args.is_empty() {
                    "Record<string, never>".to_string()
                } else {
                    let fields: Vec<String> = func
                        .args
                        .iter()
                        .map(|arg| {
                            let ts_type = rust_type_to_ts(&arg.rust_type);
                            format!("{}: {}", to_camel_case(&arg.name), ts_type)
                        })
                        .collect();
                    format!("{{ {} }}", fields.join("; "))
                };

                // Result type
                let result_type = rust_type_to_ts(&func.return_type);

                output.push_str(&format!(
                    "export const {} = {}<{}, {}>('{}');\n\n",
                    ts_name, creator, args_type, result_type, func.name
                ));
            }
        }

        Ok(output)
    }

    /// Generate the file header.
    fn generate_header(&self) -> &'static str {
        r#"// Auto-generated by FORGE - DO NOT EDIT
import type { ForgeClient } from './client';

"#
    }

    /// Generate helper types and factory functions.
    fn generate_helpers(&self) -> &'static str {
        r#"// ============================================================================
// API Function Types
// ============================================================================

// Query function type
export interface QueryFn<TArgs, TResult> {
  (client: ForgeClient, args: TArgs): Promise<TResult>;
  functionName: string;
  functionType: 'query';
}

// Mutation function type
export interface MutationFn<TArgs, TResult> {
  (client: ForgeClient, args: TArgs): Promise<TResult>;
  functionName: string;
  functionType: 'mutation';
}

// Action function type
export interface ActionFn<TArgs, TResult> {
  (client: ForgeClient, args: TArgs): Promise<TResult>;
  functionName: string;
  functionType: 'action';
}

// Create a query function binding
export function createQuery<TArgs, TResult>(
  name: string
): QueryFn<TArgs, TResult> {
  const fn = async (client: ForgeClient, args: TArgs): Promise<TResult> => {
    return client.call(name, args);
  };
  (fn as QueryFn<TArgs, TResult>).functionName = name;
  (fn as QueryFn<TArgs, TResult>).functionType = 'query';
  return fn as QueryFn<TArgs, TResult>;
}

// Create a mutation function binding
export function createMutation<TArgs, TResult>(
  name: string
): MutationFn<TArgs, TResult> {
  const fn = async (client: ForgeClient, args: TArgs): Promise<TResult> => {
    return client.call(name, args);
  };
  (fn as MutationFn<TArgs, TResult>).functionName = name;
  (fn as MutationFn<TArgs, TResult>).functionType = 'mutation';
  return fn as MutationFn<TArgs, TResult>;
}

// Create an action function binding
export function createAction<TArgs, TResult>(
  name: string
): ActionFn<TArgs, TResult> {
  const fn = async (client: ForgeClient, args: TArgs): Promise<TResult> => {
    return client.call(name, args);
  };
  (fn as ActionFn<TArgs, TResult>).functionName = name;
  (fn as ActionFn<TArgs, TResult>).functionType = 'action';
  return fn as ActionFn<TArgs, TResult>;
}

"#
    }
}

/// Convert RustType to TypeScript type string.
fn rust_type_to_ts(rust_type: &RustType) -> String {
    match rust_type {
        RustType::String => "string".to_string(),
        RustType::I32 | RustType::I64 | RustType::F32 | RustType::F64 => "number".to_string(),
        RustType::Bool => "boolean".to_string(),
        RustType::Uuid => "string".to_string(),
        RustType::DateTime => "string".to_string(),
        RustType::Date => "string".to_string(),
        RustType::Json => "unknown".to_string(),
        RustType::Bytes => "Uint8Array".to_string(),
        RustType::Option(inner) => format!("{} | null", rust_type_to_ts(inner)),
        RustType::Vec(inner) => format!("{}[]", rust_type_to_ts(inner)),
        RustType::Custom(name) => {
            // Handle unit type
            if name == "()" {
                return "void".to_string();
            }
            // Handle Vec<T>
            if let Some(inner) = name.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
                return format!("{}[]", inner);
            }
            name.clone()
        }
    }
}

/// Convert snake_case to camelCase.
fn to_camel_case(s: &str) -> String {
    let mut result = String::new();
    let mut capitalize_next = false;

    for c in s.chars() {
        if c == '_' {
            capitalize_next = true;
        } else if capitalize_next {
            result.push(c.to_uppercase().next().unwrap_or(c));
            capitalize_next = false;
        } else {
            result.push(c);
        }
    }

    result
}

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

    #[test]
    fn test_api_generator_creation() {
        let gen = ApiGenerator::new("/tmp/forge");
        assert_eq!(gen.output_dir, PathBuf::from("/tmp/forge"));
    }

    #[test]
    fn test_generate_empty_registry() {
        let gen = ApiGenerator::new("/tmp/forge");
        let registry = SchemaRegistry::new();
        let content = gen.generate(&registry).unwrap();
        assert!(content.contains("QueryFn"));
        assert!(content.contains("MutationFn"));
        assert!(content.contains("ActionFn"));
        assert!(content.contains("createQuery"));
        assert!(content.contains("createMutation"));
        assert!(content.contains("createAction"));
    }

    #[test]
    fn test_generate_with_functions() {
        let gen = ApiGenerator::new("/tmp/forge");
        let registry = SchemaRegistry::new();

        // Add a query function
        let mut func = FunctionDef::query("get_user", RustType::Custom("User".to_string()));
        func.args.push(FunctionArg::new("id", RustType::Uuid));
        registry.register_function(func);

        // Add a mutation function
        let mut func = FunctionDef::mutation("create_user", RustType::Custom("User".to_string()));
        func.args.push(FunctionArg::new("name", RustType::String));
        func.args.push(FunctionArg::new("email", RustType::String));
        registry.register_function(func);

        let content = gen.generate(&registry).unwrap();

        // Check that bindings are generated
        assert!(content.contains("export const getUser = createQuery"));
        assert!(content.contains("export const createUser = createMutation"));
        assert!(content.contains("id: string"));
        assert!(content.contains("name: string"));
        assert!(content.contains("email: string"));
    }

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

    #[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");
    }
}