use std::{fs, path::Path};
use console::style;
#[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 })
}
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 {
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}}`);
}}
"#
)
}
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(())
}