use std::collections::HashMap;
use std::fmt::Write;
#[derive(Debug, Clone, PartialEq)]
pub enum TsType {
String,
Number,
Boolean,
Null,
Optional(Box<TsType>),
Array(Box<TsType>),
Object(Vec<TsField>),
Named(String),
Void,
Raw(String),
}
#[derive(Debug, Clone, PartialEq)]
pub struct TsField {
pub name: String,
pub ty: TsType,
pub optional: bool,
}
impl TsField {
pub fn new(name: &str, ty: TsType) -> Self {
Self {
name: name.to_string(),
ty,
optional: false,
}
}
pub fn optional(name: &str, ty: TsType) -> Self {
Self {
name: name.to_string(),
ty,
optional: true,
}
}
}
#[derive(Debug, Clone)]
pub struct HandlerMeta {
pub args: Vec<TsField>,
pub returns: TsType,
pub streaming: bool,
pub stream_item: Option<TsType>,
}
impl HandlerMeta {
pub fn new(args: Vec<TsField>, returns: TsType) -> Self {
Self {
args,
returns,
streaming: false,
stream_item: None,
}
}
pub fn streaming(args: Vec<TsField>, item_type: TsType, final_type: TsType) -> Self {
Self {
args,
returns: final_type,
streaming: true,
stream_item: Some(item_type),
}
}
}
impl TsType {
pub fn render(&self) -> String {
match self {
TsType::String => "string".to_string(),
TsType::Number => "number".to_string(),
TsType::Boolean => "boolean".to_string(),
TsType::Null => "null".to_string(),
TsType::Void => "void".to_string(),
TsType::Optional(inner) => format!("{} | null", inner.render()),
TsType::Array(inner) => {
let inner_str = inner.render();
if inner_str.contains('|') {
format!("({inner_str})[]")
} else {
format!("{inner_str}[]")
}
}
TsType::Object(fields) => {
if fields.is_empty() {
return "Record<string, never>".to_string();
}
let mut s = "{ ".to_string();
for (i, field) in fields.iter().enumerate() {
if i > 0 {
s.push_str("; ");
}
if field.optional {
write!(s, "{}?: {}", field.name, field.ty.render()).unwrap();
} else {
write!(s, "{}: {}", field.name, field.ty.render()).unwrap();
}
}
s.push_str(" }");
s
}
TsType::Named(name) => name.clone(),
TsType::Raw(raw) => raw.clone(),
}
}
}
fn to_camel_case(s: &str) -> String {
let lower = s.to_lowercase();
let mut result = String::new();
let mut capitalize_next = false;
for c in lower.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
}
fn to_pascal_case(s: &str) -> String {
s.split('_')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => {
let upper: String = c.to_uppercase().collect();
let rest: String = chars.flat_map(|ch| ch.to_lowercase()).collect();
format!("{upper}{rest}")
}
}
})
.collect()
}
pub fn generate_ts_client(handler_metas: &HashMap<String, HandlerMeta>) -> String {
let mut output = String::new();
output.push_str("// Auto-generated by AllFrame. Do not edit manually.\n");
output.push_str("// Regenerate with: allframe generate-ts-client\n\n");
output.push_str("import { invoke } from \"@tauri-apps/api/core\";\n\n");
let has_streaming = handler_metas.values().any(|m| m.streaming);
if has_streaming {
output.push_str("import { listen, type UnlistenFn } from \"@tauri-apps/api/event\";\n\n");
}
output.push_str("/** @internal Unwrap CallResponse and parse the JSON result. */\n");
output.push_str("async function callHandler<T>(handler: string, args: Record<string, unknown> = {}): Promise<T> {\n");
output.push_str(" const response = await invoke<{ result: string }>(\"plugin:allframe-tauri|allframe_call\", { handler, args });\n");
output.push_str(" return JSON.parse(response.result) as T;\n");
output.push_str("}\n\n");
if has_streaming {
output.push_str("/** Observer for streaming handler updates. */\n");
output.push_str("export interface StreamObserver<T, F = void> {\n");
output.push_str(" next: (item: T) => void;\n");
output.push_str(" error?: (err: Error) => void;\n");
output.push_str(" complete?: (result: F) => void;\n");
output.push_str("}\n\n");
output.push_str("/** Subscription handle returned by streaming handlers. */\n");
output.push_str("export interface StreamSubscription {\n");
output.push_str(" unsubscribe: () => void;\n");
output.push_str("}\n\n");
output.push_str("/** @internal Start a streaming handler, wire events to observer. */\n");
output.push_str("async function callStreamHandler<T, F>(\n");
output.push_str(" handler: string,\n");
output.push_str(" args: Record<string, unknown>,\n");
output.push_str(" observer: StreamObserver<T, F>,\n");
output.push_str("): Promise<StreamSubscription> {\n");
output.push_str(" const { stream_id } = await invoke<{ stream_id: string }>(\"plugin:allframe-tauri|allframe_stream\", { handler, args });\n");
output.push_str(" const unlistens: UnlistenFn[] = [];\n");
output.push_str(" const eventBase = `allframe-tauri:stream:${handler}:${stream_id}`;\n");
output.push_str(" const cleanup = () => unlistens.forEach(fn => fn());\n");
output.push_str(" unlistens.push(await listen<string>(eventBase, (e) => observer.next(JSON.parse(e.payload) as T)));\n");
output.push_str(" unlistens.push(await listen<string>(`${eventBase}:complete`, (e) => { cleanup(); observer.complete?.(JSON.parse(e.payload) as F); }));\n");
output.push_str(" unlistens.push(await listen<string>(`${eventBase}:error`, (e) => { cleanup(); observer.error?.(new Error(e.payload)); }));\n");
output.push_str(" unlistens.push(await listen<void>(`${eventBase}:cancelled`, () => { cleanup(); observer.error?.(new Error('Stream cancelled')); }));\n");
output.push_str(" return {\n");
output.push_str(" unsubscribe: () => {\n");
output.push_str(" cleanup();\n");
output.push_str(" invoke(\"plugin:allframe-tauri|allframe_stream_cancel\", { streamId: stream_id }).catch(() => {});\n");
output.push_str(" },\n");
output.push_str(" };\n");
output.push_str("}\n\n");
output.push_str("/**\n");
output.push_str(" * Convert an AllFrame streaming handler to an RxJS Observable.\n");
output.push_str(" * Requires `rxjs` as a peer dependency: `bun add rxjs`\n");
output.push_str(" * @example\n");
output.push_str(" * const obs$ = await toObservable((observer) => streamChat({ prompt: \"Hi\" }, observer));\n");
output.push_str(" * obs$.subscribe(token => console.log(token));\n");
output.push_str(" */\n");
output.push_str("export async function toObservable<T>(\n");
output.push_str(" start: (observer: StreamObserver<T, unknown>) => Promise<StreamSubscription>,\n");
output.push_str("): Promise<import(\"rxjs\").Observable<T>> {\n");
output.push_str(" const { Observable } = await import(\"rxjs\");\n");
output.push_str(" const subPromise = new Promise<StreamSubscription>((resolve) => {\n");
output.push_str(" // Resolved inside the Observable constructor below\n");
output.push_str(" (subPromise as any).__resolve = resolve;\n");
output.push_str(" });\n");
output.push_str(" return new Observable<T>((subscriber) => {\n");
output.push_str(" start({\n");
output.push_str(" next: (item) => subscriber.next(item),\n");
output.push_str(" error: (err) => subscriber.error(err),\n");
output.push_str(" complete: () => subscriber.complete(),\n");
output.push_str(" }).then((s) => (subPromise as any).__resolve(s));\n");
output.push_str(" return () => { subPromise.then(s => s.unsubscribe()); };\n");
output.push_str(" });\n");
output.push_str("}\n\n");
}
let mut interfaces: Vec<(String, &[TsField])> = Vec::new();
let mut sorted_handlers: Vec<_> = handler_metas.iter().collect();
sorted_handlers.sort_by(|(a, _), (b, _)| a.cmp(b));
for (handler_name, meta) in &sorted_handlers {
let pascal = to_pascal_case(handler_name);
if !meta.args.is_empty() {
interfaces.push((format!("{pascal}Args"), &meta.args));
}
if let TsType::Object(fields) = &meta.returns {
interfaces.push((format!("{pascal}Response"), fields));
}
}
for (name, fields) in &interfaces {
writeln!(output, "export interface {name} {{").unwrap();
for field in *fields {
if field.optional {
writeln!(output, " {}?: {};", field.name, field.ty.render()).unwrap();
} else {
writeln!(output, " {}: {};", field.name, field.ty.render()).unwrap();
}
}
output.push_str("}\n\n");
}
for (handler_name, meta) in &sorted_handlers {
let fn_name = to_camel_case(handler_name);
let pascal = to_pascal_case(handler_name);
if meta.streaming {
let item_type = meta
.stream_item
.as_ref()
.map(|t| t.render())
.unwrap_or_else(|| "unknown".to_string());
let final_type = if let TsType::Object(_) = &meta.returns {
format!("{pascal}Response")
} else {
meta.returns.render()
};
if meta.args.is_empty() {
writeln!(
output,
"export async function {fn_name}(observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
)
.unwrap();
writeln!(
output,
" return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", {{}}, observer);",
)
.unwrap();
} else {
let args_type = format!("{pascal}Args");
writeln!(
output,
"export async function {fn_name}(args: {args_type}, observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
)
.unwrap();
writeln!(
output,
" return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", args, observer);",
)
.unwrap();
}
} else {
let return_type = if let TsType::Object(_) = &meta.returns {
format!("{pascal}Response")
} else {
meta.returns.render()
};
if meta.args.is_empty() {
writeln!(
output,
"export async function {fn_name}(): Promise<{return_type}> {{",
)
.unwrap();
writeln!(
output,
" return callHandler<{return_type}>(\"{handler_name}\");",
)
.unwrap();
} else {
let args_type = format!("{pascal}Args");
writeln!(
output,
"export async function {fn_name}(args: {args_type}): Promise<{return_type}> {{",
)
.unwrap();
writeln!(
output,
" return callHandler<{return_type}>(\"{handler_name}\", args);",
)
.unwrap();
}
}
output.push_str("}\n\n");
}
output.trim_end().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("get_user"), "getUser");
assert_eq!(to_camel_case("create_new_item"), "createNewItem");
assert_eq!(to_camel_case("hello"), "hello");
assert_eq!(to_camel_case("GET_USER"), "getUser");
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("get_user"), "GetUser");
assert_eq!(to_pascal_case("create_new_item"), "CreateNewItem");
assert_eq!(to_pascal_case("hello"), "Hello");
assert_eq!(to_pascal_case("GET_USER"), "GetUser");
}
#[test]
fn test_ts_type_render_primitives() {
assert_eq!(TsType::String.render(), "string");
assert_eq!(TsType::Number.render(), "number");
assert_eq!(TsType::Boolean.render(), "boolean");
assert_eq!(TsType::Null.render(), "null");
assert_eq!(TsType::Void.render(), "void");
}
#[test]
fn test_ts_type_render_optional() {
let opt = TsType::Optional(Box::new(TsType::String));
assert_eq!(opt.render(), "string | null");
}
#[test]
fn test_ts_type_render_array() {
let arr = TsType::Array(Box::new(TsType::Number));
assert_eq!(arr.render(), "number[]");
let arr_opt = TsType::Array(Box::new(TsType::Optional(Box::new(TsType::String))));
assert_eq!(arr_opt.render(), "(string | null)[]");
}
#[test]
fn test_ts_type_render_object() {
let obj = TsType::Object(vec![
TsField::new("id", TsType::Number),
TsField::new("name", TsType::String),
]);
assert_eq!(obj.render(), "{ id: number; name: string }");
}
#[test]
fn test_ts_type_render_named() {
assert_eq!(TsType::Named("UserResponse".to_string()).render(), "UserResponse");
}
#[test]
fn test_generate_no_args_handler() {
let mut metas = HashMap::new();
metas.insert(
"get_status".to_string(),
HandlerMeta::new(
vec![], TsType::String,
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("export async function getStatus(): Promise<string>"));
assert!(ts.contains("callHandler<string>(\"get_status\")"));
}
#[test]
fn test_generate_with_args_handler() {
let mut metas = HashMap::new();
metas.insert(
"greet".to_string(),
HandlerMeta::new(
vec![
TsField::new("name", TsType::String),
TsField::new("age", TsType::Number),
],
TsType::Object(vec![TsField::new("greeting", TsType::String)]),
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("export interface GreetArgs {"));
assert!(ts.contains(" name: string;"));
assert!(ts.contains(" age: number;"));
assert!(ts.contains("export interface GreetResponse {"));
assert!(ts.contains(" greeting: string;"));
assert!(ts.contains("export async function greet(args: GreetArgs): Promise<GreetResponse>"));
assert!(ts.contains("callHandler<GreetResponse>(\"greet\", args)"));
}
#[test]
fn test_generate_optional_field() {
let mut metas = HashMap::new();
metas.insert(
"search".to_string(),
HandlerMeta::new(
vec![
TsField::new("query", TsType::String),
TsField::optional("limit", TsType::Number),
],
TsType::Array(Box::new(TsType::String)),
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains(" query: string;"));
assert!(ts.contains(" limit?: number;"));
assert!(ts.contains("Promise<string[]>"));
}
#[test]
fn test_generate_multiple_handlers_sorted() {
let mut metas = HashMap::new();
metas.insert(
"delete_user".to_string(),
HandlerMeta::new(
vec![TsField::new("id", TsType::Number)],
TsType::Void,
),
);
metas.insert(
"create_user".to_string(),
HandlerMeta::new(
vec![TsField::new("name", TsType::String)],
TsType::Object(vec![TsField::new("id", TsType::Number)]),
),
);
let ts = generate_ts_client(&metas);
let create_pos = ts.find("createUser").unwrap();
let delete_pos = ts.find("deleteUser").unwrap();
assert!(create_pos < delete_pos);
}
#[test]
fn test_generate_named_return_type() {
let mut metas = HashMap::new();
metas.insert(
"get_user".to_string(),
HandlerMeta::new(
vec![TsField::new("id", TsType::Number)],
TsType::Named("User".to_string()),
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("Promise<User>"));
assert!(!ts.contains("export interface GetUserResponse"));
}
#[test]
fn test_generate_header_and_helper() {
let metas = HashMap::new();
let ts = generate_ts_client(&metas);
assert!(ts.contains("Auto-generated by AllFrame"));
assert!(ts.contains("import { invoke }"));
assert!(ts.contains("async function callHandler<T>"));
assert!(ts.contains("JSON.parse(response.result)"));
}
#[test]
fn test_generate_idempotent() {
let mut metas = HashMap::new();
metas.insert(
"greet".to_string(),
HandlerMeta::new(
vec![TsField::new("name", TsType::String)],
TsType::String,
),
);
let ts1 = generate_ts_client(&metas);
let ts2 = generate_ts_client(&metas);
assert_eq!(ts1, ts2);
}
#[test]
fn test_full_example_output() {
let mut metas = HashMap::new();
metas.insert(
"get_user".to_string(),
HandlerMeta::new(
vec![TsField::new("id", TsType::Number)],
TsType::Object(vec![
TsField::new("id", TsType::Number),
TsField::new("name", TsType::String),
TsField::optional("email", TsType::String),
]),
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("export interface GetUserArgs {\n id: number;\n}"));
assert!(ts.contains("export interface GetUserResponse {\n id: number;\n name: string;\n email?: string;\n}"));
assert!(ts.contains(
"export async function getUser(args: GetUserArgs): Promise<GetUserResponse>"
));
assert!(ts.contains("callHandler<GetUserResponse>(\"get_user\", args)"));
}
#[test]
fn test_generate_streaming_handler_no_args() {
let mut metas = HashMap::new();
metas.insert(
"stream_updates".to_string(),
HandlerMeta::streaming(vec![], TsType::String, TsType::Boolean),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("import { listen"));
assert!(ts.contains("export interface StreamObserver"));
assert!(ts.contains("export interface StreamSubscription"));
assert!(ts.contains("async function callStreamHandler"));
assert!(ts.contains("allframe_stream"));
assert!(ts.contains("export async function streamUpdates(observer: StreamObserver<string, boolean>): Promise<StreamSubscription>"));
assert!(ts.contains("callStreamHandler<string, boolean>(\"stream_updates\", {}, observer)"));
}
#[test]
fn test_generate_streaming_handler_with_args() {
let mut metas = HashMap::new();
metas.insert(
"stream_chat".to_string(),
HandlerMeta::streaming(
vec![TsField::new("prompt", TsType::String)],
TsType::Object(vec![TsField::new("token", TsType::String)]),
TsType::Object(vec![TsField::new("done", TsType::Boolean)]),
),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("export interface StreamChatArgs {"));
assert!(ts.contains(" prompt: string;"));
assert!(ts.contains("export interface StreamChatResponse {"));
assert!(ts.contains(" done: boolean;"));
assert!(ts.contains("export async function streamChat(args: StreamChatArgs, observer: StreamObserver<"));
assert!(ts.contains("callStreamHandler<"));
}
#[test]
fn test_generate_mixed_handlers() {
let mut metas = HashMap::new();
metas.insert(
"get_user".to_string(),
HandlerMeta::new(
vec![TsField::new("id", TsType::Number)],
TsType::String,
),
);
metas.insert(
"stream_data".to_string(),
HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("callHandler<string>(\"get_user\""));
assert!(ts.contains("callStreamHandler<string, void>(\"stream_data\""));
assert!(ts.contains("async function callHandler"));
assert!(ts.contains("async function callStreamHandler"));
}
#[test]
fn test_no_streaming_infrastructure_when_no_streaming_handlers() {
let mut metas = HashMap::new();
metas.insert(
"get_user".to_string(),
HandlerMeta::new(vec![], TsType::String),
);
let ts = generate_ts_client(&metas);
assert!(!ts.contains("StreamObserver"));
assert!(!ts.contains("StreamSubscription"));
assert!(!ts.contains("callStreamHandler"));
assert!(!ts.contains("listen"));
}
#[test]
fn test_handler_meta_new_defaults() {
let meta = HandlerMeta::new(vec![], TsType::String);
assert!(!meta.streaming);
assert!(meta.stream_item.is_none());
}
#[test]
fn test_handler_meta_streaming_constructor() {
let meta = HandlerMeta::streaming(vec![], TsType::Number, TsType::Boolean);
assert!(meta.streaming);
assert_eq!(meta.stream_item, Some(TsType::Number));
assert_eq!(meta.returns, TsType::Boolean);
}
#[test]
fn test_generate_rxjs_adapter() {
let mut metas = HashMap::new();
metas.insert(
"stream_data".to_string(),
HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
);
let ts = generate_ts_client(&metas);
assert!(ts.contains("export async function toObservable"));
assert!(ts.contains("import(\"rxjs\")"));
assert!(ts.contains("new Observable"));
assert!(ts.contains("subscriber.next"));
assert!(ts.contains("s.unsubscribe()"));
}
#[test]
fn test_no_rxjs_adapter_without_streaming() {
let mut metas = HashMap::new();
metas.insert(
"get_user".to_string(),
HandlerMeta::new(vec![], TsType::String),
);
let ts = generate_ts_client(&metas);
assert!(!ts.contains("toObservable"));
}
}