cufflink-cli 0.7.13

CLI for the Cufflink CRUD microservice platform — deploy, init, and manage services
use crate::config::CliConfig;
use std::process::Command;

/// Parse the local manifest by running `cargo run -- --emit-manifest`.
fn load_local_manifest() -> eyre::Result<cufflink_types::ServiceManifest> {
    let output = Command::new("cargo")
        .args(["run", "--", "--emit-manifest"])
        .output()?;

    if !output.status.success() {
        eyre::bail!("Failed to build service. Run from a cufflink service directory.");
    }

    let stdout = String::from_utf8(output.stdout)?;
    let manifest: cufflink_types::ServiceManifest = serde_json::from_str(stdout.trim())?;
    Ok(manifest)
}

/// Generate the OpenAPI spec locally from the manifest (no deployment required).
fn generate_local_spec(tenant_slug: Option<&str>) -> eyre::Result<serde_json::Value> {
    let manifest = load_local_manifest()?;
    let slug = tenant_slug.unwrap_or("local");
    Ok(cufflink_types::openapi::generate_openapi(&manifest, slug))
}

/// Fetch the OpenAPI spec from the deployed platform.
async fn fetch_remote_spec(env: Option<&str>) -> eyre::Result<serde_json::Value> {
    let config = CliConfig::load_with_env(env)?;
    let manifest = load_local_manifest()?;

    let client = config.http_client();
    let resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!("{}/api/services", config.api_url),
        )
        .send()
        .await?;

    let body: serde_json::Value = resp.json().await?;
    let services = body["services"]
        .as_array()
        .ok_or_else(|| eyre::eyre!("No services found"))?;

    let service = services
        .iter()
        .find(|s| s["name"].as_str() == Some(&manifest.name))
        .ok_or_else(|| {
            eyre::eyre!(
                "Service '{}' not found. Deploy it first, or use --local.",
                manifest.name
            )
        })?;

    let service_id = service["id"]
        .as_str()
        .ok_or_else(|| eyre::eyre!("Service has no ID"))?;

    let spec_resp = config
        .auth_request(
            &client,
            reqwest::Method::GET,
            &format!(
                "{}/api/services/{}/openapi.json",
                config.api_url, service_id
            ),
        )
        .send()
        .await?;

    if !spec_resp.status().is_success() {
        let status = spec_resp.status();
        let body = spec_resp.text().await.unwrap_or_default();
        eyre::bail!("Failed to fetch OpenAPI spec ({}): {}", status, body);
    }

    Ok(spec_resp.json().await?)
}

/// Fetch and display the OpenAPI spec for the current service
pub async fn run(local: bool, tenant_slug: Option<&str>, env: Option<&str>) -> eyre::Result<()> {
    let spec = if local {
        generate_local_spec(tenant_slug)?
    } else {
        fetch_remote_spec(env).await?
    };

    println!("{}", serde_json::to_string_pretty(&spec)?);
    Ok(())
}

/// Fetch OpenAPI spec and write to a file
pub async fn save(
    output_path: &str,
    local: bool,
    tenant_slug: Option<&str>,
    env: Option<&str>,
) -> eyre::Result<()> {
    let spec = if local {
        generate_local_spec(tenant_slug)?
    } else {
        fetch_remote_spec(env).await?
    };

    std::fs::write(output_path, serde_json::to_string_pretty(&spec)?)?;
    println!("OpenAPI spec written to {}", output_path);
    Ok(())
}

/// Generate a TypeScript client from the OpenAPI spec
pub async fn generate_client(
    output_dir: &str,
    local: bool,
    tenant_slug: Option<&str>,
    env: Option<&str>,
) -> eyre::Result<()> {
    let manifest = load_local_manifest()?;
    let service_name = &manifest.name;

    let spec = if local {
        let slug = tenant_slug.unwrap_or("local");
        cufflink_types::openapi::generate_openapi(&manifest, slug)
    } else {
        fetch_remote_spec(env).await?
    };

    // Write the spec to a file
    let spec_path = format!("{}/openapi.json", output_dir);
    std::fs::create_dir_all(output_dir)?;
    std::fs::write(&spec_path, serde_json::to_string_pretty(&spec)?)?;

    // Generate TypeScript client directly (no external dependency needed)
    let ts_client = generate_typescript_client(&spec, service_name);
    let client_path = format!("{}/client.ts", output_dir);
    std::fs::write(&client_path, ts_client)?;

    println!("Generated TypeScript client:");
    println!("  {}", spec_path);
    println!("  {}", client_path);

    Ok(())
}

fn generate_typescript_client(spec: &serde_json::Value, service_name: &str) -> String {
    let mut out = String::new();

    // Header
    out.push_str(&format!(
        "// Auto-generated TypeScript client for {}\n",
        service_name
    ));
    out.push_str("// Generated by cufflink CLI\n\n");

    // Types from schemas
    if let Some(schemas) = spec["components"]["schemas"].as_object() {
        for (name, schema) in schemas {
            if name == "Pagination" {
                continue;
            }
            out.push_str(&format!("export interface {} {{\n", name));
            if let Some(props) = schema["properties"].as_object() {
                for (prop_name, prop_def) in props {
                    let ts_type = openapi_type_to_ts(prop_def);
                    let nullable = prop_def["nullable"].as_bool().unwrap_or(false);
                    if nullable {
                        out.push_str(&format!("  {}: {} | null;\n", prop_name, ts_type));
                    } else {
                        out.push_str(&format!("  {}: {};\n", prop_name, ts_type));
                    }
                }
            }
            out.push_str("}\n\n");
        }

        // Pagination type
        out.push_str("export interface Pagination {\n");
        out.push_str("  current_page: number;\n");
        out.push_str("  per_page: number;\n");
        out.push_str("  total_records: number;\n");
        out.push_str("  total_pages: number;\n");
        out.push_str("}\n\n");
    }

    // Client class
    out.push_str("export class CufflinkClient {\n");
    out.push_str("  private baseUrl: string;\n");
    out.push_str("  private headers: Record<string, string>;\n\n");
    out.push_str("  constructor(baseUrl: string, headers: Record<string, string> = {}) {\n");
    out.push_str("    this.baseUrl = baseUrl.replace(/\\/$/, '');\n");
    out.push_str("    this.headers = { 'Content-Type': 'application/json', ...headers };\n");
    out.push_str("  }\n\n");

    out.push_str(
        "  private async request<T>(method: string, path: string, body?: unknown): Promise<T> {\n",
    );
    out.push_str("    const resp = await fetch(`${this.baseUrl}${path}`, {\n");
    out.push_str("      method,\n");
    out.push_str("      headers: this.headers,\n");
    out.push_str("      body: body ? JSON.stringify(body) : undefined,\n");
    out.push_str("    });\n");
    out.push_str(
        "    if (!resp.ok) throw new Error(`HTTP ${resp.status}: ${await resp.text()}`);\n",
    );
    out.push_str("    return resp.json();\n");
    out.push_str("  }\n\n");

    // Generate methods from paths
    if let Some(paths) = spec["paths"].as_object() {
        for (path, operations) in paths {
            if let Some(ops) = operations.as_object() {
                for (method, op) in ops {
                    let operation_id = op["operationId"].as_str().unwrap_or("unknown");
                    let fn_name = to_camel_case(operation_id);

                    // Check if this is a custom route (tagged with "custom")
                    let is_custom = op["tags"]
                        .as_array()
                        .map(|tags| tags.iter().any(|t| t.as_str() == Some("custom")))
                        .unwrap_or(false);

                    if is_custom {
                        // Custom WASM handler route
                        let has_body = op.get("requestBody").is_some();

                        if has_body {
                            out.push_str(&format!(
                                "  async {}(data?: Record<string, unknown>): Promise<Record<string, unknown>> {{\n",
                                fn_name
                            ));
                            out.push_str(&format!(
                                "    return this.request('{}', '{}', data);\n",
                                method.to_uppercase(),
                                path
                            ));
                        } else {
                            out.push_str(&format!(
                                "  async {}(params?: Record<string, string>): Promise<Record<string, unknown>> {{\n",
                                fn_name
                            ));
                            out.push_str(
                                "    const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
                            );
                            out.push_str(&format!(
                                "    return this.request('GET', `{}${{query}}`);\n",
                                path
                            ));
                        }
                        out.push_str("  }\n\n");
                    } else {
                        match method.as_str() {
                            "get" if !path.contains(":id") => {
                                // List endpoint
                                let schema_ref = op["responses"]["200"]["content"]
                                    ["application/json"]["schema"]["properties"]["results"]
                                    ["items"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let return_type = schema_ref.unwrap_or("unknown");

                                out.push_str(&format!(
                                "  async {}(params?: Record<string, string>): Promise<{{ results: {}[]; pagination: Pagination }}> {{\n",
                                fn_name, return_type
                            ));
                                out.push_str(
                                "    const query = params ? '?' + new URLSearchParams(params).toString() : '';\n"
                            );
                                out.push_str(&format!(
                                    "    return this.request('GET', `{}${{query}}`);\n",
                                    path
                                ));
                                out.push_str("  }\n\n");
                            }
                            "post" => {
                                // Create endpoint
                                let create_ref = op["requestBody"]["content"]["application/json"]
                                    ["schema"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let body_type = create_ref.unwrap_or("unknown");

                                let response_ref = op["responses"]["200"]["content"]
                                    ["application/json"]["schema"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let return_type = response_ref.unwrap_or("unknown");

                                out.push_str(&format!(
                                    "  async {}(data: {}): Promise<{}> {{\n",
                                    fn_name, body_type, return_type
                                ));
                                out.push_str(&format!(
                                    "    return this.request('POST', '{}', data);\n",
                                    path
                                ));
                                out.push_str("  }\n\n");
                            }
                            "get" if path.contains(":id") => {
                                // Get by ID
                                let response_ref = op["responses"]["200"]["content"]
                                    ["application/json"]["schema"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let return_type = response_ref.unwrap_or("unknown");
                                let base_path = path.replace("/:id", "");

                                out.push_str(&format!(
                                    "  async {}(id: string): Promise<{}> {{\n",
                                    fn_name, return_type
                                ));
                                out.push_str(&format!(
                                    "    return this.request('GET', `{}/${{id}}`);\n",
                                    base_path
                                ));
                                out.push_str("  }\n\n");
                            }
                            "put" => {
                                // Update
                                let body_ref = op["requestBody"]["content"]["application/json"]
                                    ["schema"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let body_type = body_ref.unwrap_or("unknown");

                                let response_ref = op["responses"]["200"]["content"]
                                    ["application/json"]["schema"]["$ref"]
                                    .as_str()
                                    .and_then(|r| r.split('/').next_back());
                                let return_type = response_ref.unwrap_or("unknown");
                                let base_path = path.replace("/:id", "");

                                out.push_str(&format!(
                                    "  async {}(id: string, data: {}): Promise<{}> {{\n",
                                    fn_name, body_type, return_type
                                ));
                                out.push_str(&format!(
                                    "    return this.request('PUT', `{}/${{id}}`, data);\n",
                                    base_path
                                ));
                                out.push_str("  }\n\n");
                            }
                            "delete" => {
                                // Delete
                                let base_path = path.replace("/:id", "");

                                out.push_str(&format!(
                                    "  async {}(id: string): Promise<void> {{\n",
                                    fn_name
                                ));
                                out.push_str(&format!(
                                    "    await this.request('DELETE', `{}/${{id}}`);\n",
                                    base_path
                                ));
                                out.push_str("  }\n\n");
                            }
                            _ => {}
                        }
                    } // end else (non-custom)
                }
            }
        }
    }

    out.push_str("}\n");
    out
}

fn openapi_type_to_ts(prop: &serde_json::Value) -> &str {
    match prop["type"].as_str() {
        Some("string") => match prop["format"].as_str() {
            Some("uuid") => "string",
            Some("date-time") => "string",
            Some("date") => "string",
            _ => "string",
        },
        Some("integer") => "number",
        Some("number") => "number",
        Some("boolean") => "boolean",
        Some("object") => "Record<string, unknown>",
        Some("array") => "unknown[]",
        _ => "unknown",
    }
}

fn to_camel_case(s: &str) -> String {
    let mut result = String::new();
    let mut capitalize_next = false;
    for (i, c) in s.chars().enumerate() {
        if c == '_' {
            capitalize_next = true;
        } else if capitalize_next {
            result.push(c.to_ascii_uppercase());
            capitalize_next = false;
        } else if i == 0 {
            result.push(c.to_ascii_lowercase());
        } else {
            result.push(c);
        }
    }
    result
}