rok-cli 0.3.2

Developer CLI for rok-based Axum applications
//! `rok make:ts-client` — generate a TypeScript API client from resource structs.

use std::{fs, path::Path};

use console::style;

// ── Resource scanner ──────────────────────────────────────────────────────────

#[derive(Debug)]
struct ResourceDef {
    name: String,
    fields: Vec<TsField>,
}

#[derive(Debug)]
struct TsField {
    name: String,
    ts_type: String,
    optional: bool,
}

fn rust_type_to_ts(rust_t: &str) -> &'static str {
    let t = rust_t
        .trim()
        .trim_start_matches("Option<")
        .trim_end_matches('>');
    match t {
        "String" | "&str" => "string",
        "i8" | "i16" | "i32" | "i64" | "i128" | "u8" | "u16" | "u32" | "u64" | "u128" | "f32"
        | "f64" | "isize" | "usize" => "number",
        "bool" => "boolean",
        "serde_json::Value" | "Value" => "unknown",
        _ => "unknown",
    }
}

fn parse_resource_file(path: &Path) -> Option<ResourceDef> {
    let content = fs::read_to_string(path).ok()?;
    let mut name: Option<String> = None;
    let mut fields: Vec<TsField> = Vec::new();
    let mut in_struct = false;

    for line in content.lines() {
        let trimmed = line.trim();

        if trimmed.starts_with("pub struct ") && trimmed.ends_with('{') {
            let n = trimmed
                .trim_start_matches("pub struct ")
                .trim_end_matches(" {")
                .to_string();
            name = Some(n);
            in_struct = true;
            continue;
        }

        if in_struct {
            if trimmed == "}" {
                break;
            }
            if let Some(rest) = trimmed.strip_prefix("pub ") {
                let parts: Vec<&str> = rest.splitn(2, ':').collect();
                if parts.len() == 2 {
                    let field_name = parts[0].trim().to_string();
                    let rust_t = parts[1].trim().trim_end_matches(',').trim();
                    let optional = rust_t.starts_with("Option<");
                    let ts_type = rust_type_to_ts(rust_t).to_string();
                    if !field_name.is_empty() {
                        fields.push(TsField {
                            name: field_name,
                            ts_type,
                            optional,
                        });
                    }
                }
            }
        }
    }

    name.map(|n| ResourceDef { name: n, fields })
}

// ── TypeScript generation ─────────────────────────────────────────────────────

fn gen_ts_interface(res: &ResourceDef) -> String {
    let mut lines = format!("export interface {} {{\n", res.name);
    for f in &res.fields {
        let opt = if f.optional { "?" } else { "" };
        lines.push_str(&format!("  {}{opt}: {};\n", f.name, f.ts_type));
    }
    lines.push_str("}\n");
    lines
}

fn ts_resource_name_to_path(name: &str) -> String {
    // PostResource → posts
    let base = name.trim_end_matches("Resource");
    let mut path = String::new();
    for (i, c) in base.chars().enumerate() {
        if c.is_uppercase() && i > 0 {
            path.push('-');
        }
        path.push(c.to_ascii_lowercase());
    }
    path + "s"
}

fn gen_ts_functions(res: &ResourceDef) -> String {
    let name = res.name.trim_end_matches("Resource");
    let route = ts_resource_name_to_path(&res.name);
    let iface = &res.name;

    format!(
        r#"
// ── {name} ────────────────────────────────────────────────────────────────────

export async function list{name}(base: string): Promise<{iface}[]> {{
  const res = await fetch(`${{base}}/api/v1/{route}`);
  if (!res.ok) throw new Error(`list{name} failed: ${{res.status}}`);
  return res.json();
}}

export async function get{name}(base: string, id: string | number): Promise<{iface}> {{
  const res = await fetch(`${{base}}/api/v1/{route}/${{id}}`);
  if (!res.ok) throw new Error(`get{name} failed: ${{res.status}}`);
  return res.json();
}}

export async function create{name}(base: string, body: Partial<{iface}>): Promise<{iface}> {{
  const res = await fetch(`${{base}}/api/v1/{route}`, {{
    method: "POST",
    headers: {{ "Content-Type": "application/json" }},
    body: JSON.stringify(body),
  }});
  if (!res.ok) throw new Error(`create{name} failed: ${{res.status}}`);
  return res.json();
}}

export async function update{name}(base: string, id: string | number, body: Partial<{iface}>): Promise<{iface}> {{
  const res = await fetch(`${{base}}/api/v1/{route}/${{id}}`, {{
    method: "PUT",
    headers: {{ "Content-Type": "application/json" }},
    body: JSON.stringify(body),
  }});
  if (!res.ok) throw new Error(`update{name} failed: ${{res.status}}`);
  return res.json();
}}

export async function delete{name}(base: string, id: string | number): Promise<void> {{
  const res = await fetch(`${{base}}/api/v1/{route}/${{id}}`, {{ method: "DELETE" }});
  if (!res.ok) throw new Error(`delete{name} failed: ${{res.status}}`);
}}
"#
    )
}

// ── Entry point ───────────────────────────────────────────────────────────────

pub fn make_ts_client(output: Option<&str>) -> anyhow::Result<()> {
    let resources_dir = Path::new("src/app/resources");
    if !resources_dir.exists() {
        anyhow::bail!("src/app/resources/ not found. Run `rok make:crud` first.");
    }

    let entries: Vec<_> = fs::read_dir(resources_dir)?
        .filter_map(|e| e.ok())
        .filter(|e| {
            e.path().extension().and_then(|s| s.to_str()) == Some("rs")
                && e.file_name()
                    .to_str()
                    .map(|n| n != "mod.rs")
                    .unwrap_or(false)
        })
        .collect();

    if entries.is_empty() {
        anyhow::bail!("No resource files found in src/app/resources/");
    }

    let mut interfaces =
        String::from("// Generated by rok make:ts-client\n// Do not edit manually.\n\n");
    let mut functions = String::new();

    let mut count = 0;
    for entry in entries {
        if let Some(res) = parse_resource_file(&entry.path()) {
            interfaces.push_str(&gen_ts_interface(&res));
            interfaces.push('\n');
            functions.push_str(&gen_ts_functions(&res));
            count += 1;
        }
    }

    let output_content = format!("{interfaces}{functions}");
    let out_path = output.unwrap_or("client.ts");

    fs::write(out_path, &output_content)?;
    println!(
        "{} Generated TypeScript client with {} resource(s) → {out_path}",
        style("").green().bold(),
        count
    );
    println!("  Run `tsc --noEmit {out_path}` to type-check.");

    Ok(())
}