1use std::collections::HashMap;
23use std::fmt::Write;
24
25#[derive(Debug, Clone, PartialEq)]
27pub enum TsType {
28 String,
30 Number,
32 Boolean,
34 Null,
36 Optional(Box<TsType>),
38 Array(Box<TsType>),
40 Object(Vec<TsField>),
42 Named(String),
44 Void,
46 Raw(String),
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub struct TsField {
53 pub name: String,
55 pub ty: TsType,
57 pub optional: bool,
59}
60
61impl TsField {
62 pub fn new(name: &str, ty: TsType) -> Self {
64 Self {
65 name: name.to_string(),
66 ty,
67 optional: false,
68 }
69 }
70
71 pub fn optional(name: &str, ty: TsType) -> Self {
73 Self {
74 name: name.to_string(),
75 ty,
76 optional: true,
77 }
78 }
79}
80
81#[derive(Debug, Clone)]
83pub struct HandlerMeta {
84 pub args: Vec<TsField>,
86 pub returns: TsType,
88 pub streaming: bool,
90 pub stream_item: Option<TsType>,
92}
93
94impl HandlerMeta {
95 pub fn new(args: Vec<TsField>, returns: TsType) -> Self {
97 Self {
98 args,
99 returns,
100 streaming: false,
101 stream_item: None,
102 }
103 }
104
105 pub fn streaming(args: Vec<TsField>, item_type: TsType, final_type: TsType) -> Self {
107 Self {
108 args,
109 returns: final_type,
110 streaming: true,
111 stream_item: Some(item_type),
112 }
113 }
114}
115
116impl TsType {
117 pub fn render(&self) -> String {
119 match self {
120 TsType::String => "string".to_string(),
121 TsType::Number => "number".to_string(),
122 TsType::Boolean => "boolean".to_string(),
123 TsType::Null => "null".to_string(),
124 TsType::Void => "void".to_string(),
125 TsType::Optional(inner) => format!("{} | null", inner.render()),
126 TsType::Array(inner) => {
127 let inner_str = inner.render();
128 if inner_str.contains('|') {
129 format!("({inner_str})[]")
130 } else {
131 format!("{inner_str}[]")
132 }
133 }
134 TsType::Object(fields) => {
135 if fields.is_empty() {
136 return "Record<string, never>".to_string();
137 }
138 let mut s = "{ ".to_string();
139 for (i, field) in fields.iter().enumerate() {
140 if i > 0 {
141 s.push_str("; ");
142 }
143 if field.optional {
144 write!(s, "{}?: {}", field.name, field.ty.render()).unwrap();
145 } else {
146 write!(s, "{}: {}", field.name, field.ty.render()).unwrap();
147 }
148 }
149 s.push_str(" }");
150 s
151 }
152 TsType::Named(name) => name.clone(),
153 TsType::Raw(raw) => raw.clone(),
154 }
155 }
156}
157
158fn to_camel_case(s: &str) -> String {
163 let lower = s.to_lowercase();
164 let mut result = String::new();
165 let mut capitalize_next = false;
166
167 for c in lower.chars() {
168 if c == '_' {
169 capitalize_next = true;
170 } else if capitalize_next {
171 result.push(c.to_uppercase().next().unwrap_or(c));
172 capitalize_next = false;
173 } else {
174 result.push(c);
175 }
176 }
177
178 result
179}
180
181fn to_pascal_case(s: &str) -> String {
183 s.split('_')
184 .map(|word| {
185 let mut chars = word.chars();
186 match chars.next() {
187 None => String::new(),
188 Some(c) => {
189 let upper: String = c.to_uppercase().collect();
190 let rest: String = chars.flat_map(|ch| ch.to_lowercase()).collect();
191 format!("{upper}{rest}")
192 }
193 }
194 })
195 .collect()
196}
197
198pub fn generate_ts_client(handler_metas: &HashMap<String, HandlerMeta>) -> String {
200 let mut output = String::new();
201
202 output.push_str("// Auto-generated by AllFrame. Do not edit manually.\n");
204 output.push_str("// Regenerate with: allframe generate-ts-client\n\n");
205 output.push_str("import { invoke } from \"@tauri-apps/api/core\";\n\n");
206
207 let has_streaming = handler_metas.values().any(|m| m.streaming);
209 if has_streaming {
210 output.push_str("import { listen, type UnlistenFn } from \"@tauri-apps/api/event\";\n\n");
211 }
212
213 output.push_str("/** @internal Unwrap CallResponse and parse the JSON result. */\n");
215 output.push_str("async function callHandler<T>(handler: string, args: Record<string, unknown> = {}): Promise<T> {\n");
216 output.push_str(" const response = await invoke<{ result: string }>(\"plugin:allframe-tauri|allframe_call\", { handler, args });\n");
217 output.push_str(" return JSON.parse(response.result) as T;\n");
218 output.push_str("}\n\n");
219
220 if has_streaming {
222 output.push_str("/** Observer for streaming handler updates. */\n");
223 output.push_str("export interface StreamObserver<T, F = void> {\n");
224 output.push_str(" next: (item: T) => void;\n");
225 output.push_str(" error?: (err: Error) => void;\n");
226 output.push_str(" complete?: (result: F) => void;\n");
227 output.push_str("}\n\n");
228
229 output.push_str("/** Subscription handle returned by streaming handlers. */\n");
230 output.push_str("export interface StreamSubscription {\n");
231 output.push_str(" unsubscribe: () => void;\n");
232 output.push_str("}\n\n");
233
234 output.push_str("/** @internal Start a streaming handler, wire events to observer. */\n");
235 output.push_str("async function callStreamHandler<T, F>(\n");
236 output.push_str(" handler: string,\n");
237 output.push_str(" args: Record<string, unknown>,\n");
238 output.push_str(" observer: StreamObserver<T, F>,\n");
239 output.push_str("): Promise<StreamSubscription> {\n");
240 output.push_str(" const { stream_id } = await invoke<{ stream_id: string }>(\"plugin:allframe-tauri|allframe_stream\", { handler, args });\n");
241 output.push_str(" const unlistens: UnlistenFn[] = [];\n");
242 output.push_str(" const eventBase = `allframe-tauri:stream:${handler}:${stream_id}`;\n");
243 output.push_str(" const cleanup = () => unlistens.forEach(fn => fn());\n");
244 output.push_str(" unlistens.push(await listen<string>(eventBase, (e) => observer.next(JSON.parse(e.payload) as T)));\n");
245 output.push_str(" unlistens.push(await listen<string>(`${eventBase}:complete`, (e) => { cleanup(); observer.complete?.(JSON.parse(e.payload) as F); }));\n");
246 output.push_str(" unlistens.push(await listen<string>(`${eventBase}:error`, (e) => { cleanup(); observer.error?.(new Error(e.payload)); }));\n");
247 output.push_str(" unlistens.push(await listen<void>(`${eventBase}:cancelled`, () => { cleanup(); observer.error?.(new Error('Stream cancelled')); }));\n");
248 output.push_str(" return {\n");
249 output.push_str(" unsubscribe: () => {\n");
250 output.push_str(" cleanup();\n");
251 output.push_str(" invoke(\"plugin:allframe-tauri|allframe_stream_cancel\", { streamId: stream_id }).catch(() => {});\n");
252 output.push_str(" },\n");
253 output.push_str(" };\n");
254 output.push_str("}\n\n");
255
256 output.push_str("/**\n");
257 output.push_str(" * Convert an AllFrame streaming handler to an RxJS Observable.\n");
258 output.push_str(" * Requires `rxjs` as a peer dependency: `bun add rxjs`\n");
259 output.push_str(" * @example\n");
260 output.push_str(" * const obs$ = await toObservable((observer) => streamChat({ prompt: \"Hi\" }, observer));\n");
261 output.push_str(" * obs$.subscribe(token => console.log(token));\n");
262 output.push_str(" */\n");
263 output.push_str("export async function toObservable<T>(\n");
264 output.push_str(" start: (observer: StreamObserver<T, unknown>) => Promise<StreamSubscription>,\n");
265 output.push_str("): Promise<import(\"rxjs\").Observable<T>> {\n");
266 output.push_str(" const { Observable } = await import(\"rxjs\");\n");
267 output.push_str(" const subPromise = new Promise<StreamSubscription>((resolve) => {\n");
268 output.push_str(" // Resolved inside the Observable constructor below\n");
269 output.push_str(" (subPromise as any).__resolve = resolve;\n");
270 output.push_str(" });\n");
271 output.push_str(" return new Observable<T>((subscriber) => {\n");
272 output.push_str(" start({\n");
273 output.push_str(" next: (item) => subscriber.next(item),\n");
274 output.push_str(" error: (err) => subscriber.error(err),\n");
275 output.push_str(" complete: () => subscriber.complete(),\n");
276 output.push_str(" }).then((s) => (subPromise as any).__resolve(s));\n");
277 output.push_str(" return () => { subPromise.then(s => s.unsubscribe()); };\n");
278 output.push_str(" });\n");
279 output.push_str("}\n\n");
280 }
281
282 let mut interfaces: Vec<(String, &[TsField])> = Vec::new();
284
285 let mut sorted_handlers: Vec<_> = handler_metas.iter().collect();
287 sorted_handlers.sort_by(|(a, _), (b, _)| a.cmp(b));
288
289 for (handler_name, meta) in &sorted_handlers {
291 let pascal = to_pascal_case(handler_name);
292
293 if !meta.args.is_empty() {
295 interfaces.push((format!("{pascal}Args"), &meta.args));
296 }
297
298 if let TsType::Object(fields) = &meta.returns {
300 interfaces.push((format!("{pascal}Response"), fields));
301 }
302 }
303
304 for (name, fields) in &interfaces {
306 writeln!(output, "export interface {name} {{").unwrap();
307 for field in *fields {
308 if field.optional {
309 writeln!(output, " {}?: {};", field.name, field.ty.render()).unwrap();
310 } else {
311 writeln!(output, " {}: {};", field.name, field.ty.render()).unwrap();
312 }
313 }
314 output.push_str("}\n\n");
315 }
316
317 for (handler_name, meta) in &sorted_handlers {
319 let fn_name = to_camel_case(handler_name);
320 let pascal = to_pascal_case(handler_name);
321
322 if meta.streaming {
323 let item_type = meta
325 .stream_item
326 .as_ref()
327 .map(|t| t.render())
328 .unwrap_or_else(|| "unknown".to_string());
329
330 let final_type = if let TsType::Object(_) = &meta.returns {
331 format!("{pascal}Response")
332 } else {
333 meta.returns.render()
334 };
335
336 if meta.args.is_empty() {
337 writeln!(
338 output,
339 "export async function {fn_name}(observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
340 )
341 .unwrap();
342 writeln!(
343 output,
344 " return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", {{}}, observer);",
345 )
346 .unwrap();
347 } else {
348 let args_type = format!("{pascal}Args");
349 writeln!(
350 output,
351 "export async function {fn_name}(args: {args_type}, observer: StreamObserver<{item_type}, {final_type}>): Promise<StreamSubscription> {{",
352 )
353 .unwrap();
354 writeln!(
355 output,
356 " return callStreamHandler<{item_type}, {final_type}>(\"{handler_name}\", args, observer);",
357 )
358 .unwrap();
359 }
360 } else {
361 let return_type = if let TsType::Object(_) = &meta.returns {
363 format!("{pascal}Response")
364 } else {
365 meta.returns.render()
366 };
367
368 if meta.args.is_empty() {
369 writeln!(
370 output,
371 "export async function {fn_name}(): Promise<{return_type}> {{",
372 )
373 .unwrap();
374 writeln!(
375 output,
376 " return callHandler<{return_type}>(\"{handler_name}\");",
377 )
378 .unwrap();
379 } else {
380 let args_type = format!("{pascal}Args");
381 writeln!(
382 output,
383 "export async function {fn_name}(args: {args_type}): Promise<{return_type}> {{",
384 )
385 .unwrap();
386 writeln!(
387 output,
388 " return callHandler<{return_type}>(\"{handler_name}\", args);",
389 )
390 .unwrap();
391 }
392 }
393
394 output.push_str("}\n\n");
395 }
396
397 output.trim_end().to_string()
398}
399
400#[cfg(test)]
401mod tests {
402 use super::*;
403
404 #[test]
405 fn test_to_camel_case() {
406 assert_eq!(to_camel_case("get_user"), "getUser");
407 assert_eq!(to_camel_case("create_new_item"), "createNewItem");
408 assert_eq!(to_camel_case("hello"), "hello");
409 assert_eq!(to_camel_case("GET_USER"), "getUser");
410 }
411
412 #[test]
413 fn test_to_pascal_case() {
414 assert_eq!(to_pascal_case("get_user"), "GetUser");
415 assert_eq!(to_pascal_case("create_new_item"), "CreateNewItem");
416 assert_eq!(to_pascal_case("hello"), "Hello");
417 assert_eq!(to_pascal_case("GET_USER"), "GetUser");
418 }
419
420 #[test]
421 fn test_ts_type_render_primitives() {
422 assert_eq!(TsType::String.render(), "string");
423 assert_eq!(TsType::Number.render(), "number");
424 assert_eq!(TsType::Boolean.render(), "boolean");
425 assert_eq!(TsType::Null.render(), "null");
426 assert_eq!(TsType::Void.render(), "void");
427 }
428
429 #[test]
430 fn test_ts_type_render_optional() {
431 let opt = TsType::Optional(Box::new(TsType::String));
432 assert_eq!(opt.render(), "string | null");
433 }
434
435 #[test]
436 fn test_ts_type_render_array() {
437 let arr = TsType::Array(Box::new(TsType::Number));
438 assert_eq!(arr.render(), "number[]");
439
440 let arr_opt = TsType::Array(Box::new(TsType::Optional(Box::new(TsType::String))));
442 assert_eq!(arr_opt.render(), "(string | null)[]");
443 }
444
445 #[test]
446 fn test_ts_type_render_object() {
447 let obj = TsType::Object(vec![
448 TsField::new("id", TsType::Number),
449 TsField::new("name", TsType::String),
450 ]);
451 assert_eq!(obj.render(), "{ id: number; name: string }");
452 }
453
454 #[test]
455 fn test_ts_type_render_named() {
456 assert_eq!(TsType::Named("UserResponse".to_string()).render(), "UserResponse");
457 }
458
459 #[test]
460 fn test_generate_no_args_handler() {
461 let mut metas = HashMap::new();
462 metas.insert(
463 "get_status".to_string(),
464 HandlerMeta::new(
465 vec![], TsType::String,
466 ),
467 );
468
469 let ts = generate_ts_client(&metas);
470 assert!(ts.contains("export async function getStatus(): Promise<string>"));
471 assert!(ts.contains("callHandler<string>(\"get_status\")"));
472 }
473
474 #[test]
475 fn test_generate_with_args_handler() {
476 let mut metas = HashMap::new();
477 metas.insert(
478 "greet".to_string(),
479 HandlerMeta::new(
480 vec![
481 TsField::new("name", TsType::String),
482 TsField::new("age", TsType::Number),
483 ],
484 TsType::Object(vec![TsField::new("greeting", TsType::String)]),
485 ),
486 );
487
488 let ts = generate_ts_client(&metas);
489
490 assert!(ts.contains("export interface GreetArgs {"));
492 assert!(ts.contains(" name: string;"));
493 assert!(ts.contains(" age: number;"));
494
495 assert!(ts.contains("export interface GreetResponse {"));
497 assert!(ts.contains(" greeting: string;"));
498
499 assert!(ts.contains("export async function greet(args: GreetArgs): Promise<GreetResponse>"));
501 assert!(ts.contains("callHandler<GreetResponse>(\"greet\", args)"));
502 }
503
504 #[test]
505 fn test_generate_optional_field() {
506 let mut metas = HashMap::new();
507 metas.insert(
508 "search".to_string(),
509 HandlerMeta::new(
510 vec![
511 TsField::new("query", TsType::String),
512 TsField::optional("limit", TsType::Number),
513 ],
514 TsType::Array(Box::new(TsType::String)),
515 ),
516 );
517
518 let ts = generate_ts_client(&metas);
519 assert!(ts.contains(" query: string;"));
520 assert!(ts.contains(" limit?: number;"));
521 assert!(ts.contains("Promise<string[]>"));
522 }
523
524 #[test]
525 fn test_generate_multiple_handlers_sorted() {
526 let mut metas = HashMap::new();
527 metas.insert(
528 "delete_user".to_string(),
529 HandlerMeta::new(
530 vec![TsField::new("id", TsType::Number)],
531 TsType::Void,
532 ),
533 );
534 metas.insert(
535 "create_user".to_string(),
536 HandlerMeta::new(
537 vec![TsField::new("name", TsType::String)],
538 TsType::Object(vec![TsField::new("id", TsType::Number)]),
539 ),
540 );
541
542 let ts = generate_ts_client(&metas);
543
544 let create_pos = ts.find("createUser").unwrap();
546 let delete_pos = ts.find("deleteUser").unwrap();
547 assert!(create_pos < delete_pos);
548 }
549
550 #[test]
551 fn test_generate_named_return_type() {
552 let mut metas = HashMap::new();
553 metas.insert(
554 "get_user".to_string(),
555 HandlerMeta::new(
556 vec![TsField::new("id", TsType::Number)],
557 TsType::Named("User".to_string()),
558 ),
559 );
560
561 let ts = generate_ts_client(&metas);
562 assert!(ts.contains("Promise<User>"));
563 assert!(!ts.contains("export interface GetUserResponse"));
565 }
566
567 #[test]
568 fn test_generate_header_and_helper() {
569 let metas = HashMap::new();
570 let ts = generate_ts_client(&metas);
571 assert!(ts.contains("Auto-generated by AllFrame"));
572 assert!(ts.contains("import { invoke }"));
573 assert!(ts.contains("async function callHandler<T>"));
574 assert!(ts.contains("JSON.parse(response.result)"));
575 }
576
577 #[test]
578 fn test_generate_idempotent() {
579 let mut metas = HashMap::new();
580 metas.insert(
581 "greet".to_string(),
582 HandlerMeta::new(
583 vec![TsField::new("name", TsType::String)],
584 TsType::String,
585 ),
586 );
587
588 let ts1 = generate_ts_client(&metas);
589 let ts2 = generate_ts_client(&metas);
590 assert_eq!(ts1, ts2);
591 }
592
593 #[test]
594 fn test_full_example_output() {
595 let mut metas = HashMap::new();
596 metas.insert(
597 "get_user".to_string(),
598 HandlerMeta::new(
599 vec![TsField::new("id", TsType::Number)],
600 TsType::Object(vec![
601 TsField::new("id", TsType::Number),
602 TsField::new("name", TsType::String),
603 TsField::optional("email", TsType::String),
604 ]),
605 ),
606 );
607
608 let ts = generate_ts_client(&metas);
609
610 assert!(ts.contains("export interface GetUserArgs {\n id: number;\n}"));
612 assert!(ts.contains("export interface GetUserResponse {\n id: number;\n name: string;\n email?: string;\n}"));
613 assert!(ts.contains(
614 "export async function getUser(args: GetUserArgs): Promise<GetUserResponse>"
615 ));
616 assert!(ts.contains("callHandler<GetUserResponse>(\"get_user\", args)"));
617 }
618
619 #[test]
622 fn test_generate_streaming_handler_no_args() {
623 let mut metas = HashMap::new();
624 metas.insert(
625 "stream_updates".to_string(),
626 HandlerMeta::streaming(vec![], TsType::String, TsType::Boolean),
627 );
628
629 let ts = generate_ts_client(&metas);
630
631 assert!(ts.contains("import { listen"));
633 assert!(ts.contains("export interface StreamObserver"));
634 assert!(ts.contains("export interface StreamSubscription"));
635 assert!(ts.contains("async function callStreamHandler"));
636 assert!(ts.contains("allframe_stream"));
637
638 assert!(ts.contains("export async function streamUpdates(observer: StreamObserver<string, boolean>): Promise<StreamSubscription>"));
640 assert!(ts.contains("callStreamHandler<string, boolean>(\"stream_updates\", {}, observer)"));
641 }
642
643 #[test]
644 fn test_generate_streaming_handler_with_args() {
645 let mut metas = HashMap::new();
646 metas.insert(
647 "stream_chat".to_string(),
648 HandlerMeta::streaming(
649 vec![TsField::new("prompt", TsType::String)],
650 TsType::Object(vec![TsField::new("token", TsType::String)]),
651 TsType::Object(vec![TsField::new("done", TsType::Boolean)]),
652 ),
653 );
654
655 let ts = generate_ts_client(&metas);
656
657 assert!(ts.contains("export interface StreamChatArgs {"));
659 assert!(ts.contains(" prompt: string;"));
660
661 assert!(ts.contains("export interface StreamChatResponse {"));
663 assert!(ts.contains(" done: boolean;"));
664
665 assert!(ts.contains("export async function streamChat(args: StreamChatArgs, observer: StreamObserver<"));
667 assert!(ts.contains("callStreamHandler<"));
668 }
669
670 #[test]
671 fn test_generate_mixed_handlers() {
672 let mut metas = HashMap::new();
673 metas.insert(
674 "get_user".to_string(),
675 HandlerMeta::new(
676 vec![TsField::new("id", TsType::Number)],
677 TsType::String,
678 ),
679 );
680 metas.insert(
681 "stream_data".to_string(),
682 HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
683 );
684
685 let ts = generate_ts_client(&metas);
686
687 assert!(ts.contains("callHandler<string>(\"get_user\""));
689 assert!(ts.contains("callStreamHandler<string, void>(\"stream_data\""));
691 assert!(ts.contains("async function callHandler"));
693 assert!(ts.contains("async function callStreamHandler"));
694 }
695
696 #[test]
697 fn test_no_streaming_infrastructure_when_no_streaming_handlers() {
698 let mut metas = HashMap::new();
699 metas.insert(
700 "get_user".to_string(),
701 HandlerMeta::new(vec![], TsType::String),
702 );
703
704 let ts = generate_ts_client(&metas);
705
706 assert!(!ts.contains("StreamObserver"));
708 assert!(!ts.contains("StreamSubscription"));
709 assert!(!ts.contains("callStreamHandler"));
710 assert!(!ts.contains("listen"));
711 }
712
713 #[test]
714 fn test_handler_meta_new_defaults() {
715 let meta = HandlerMeta::new(vec![], TsType::String);
716 assert!(!meta.streaming);
717 assert!(meta.stream_item.is_none());
718 }
719
720 #[test]
721 fn test_handler_meta_streaming_constructor() {
722 let meta = HandlerMeta::streaming(vec![], TsType::Number, TsType::Boolean);
723 assert!(meta.streaming);
724 assert_eq!(meta.stream_item, Some(TsType::Number));
725 assert_eq!(meta.returns, TsType::Boolean);
726 }
727
728 #[test]
729 fn test_generate_rxjs_adapter() {
730 let mut metas = HashMap::new();
731 metas.insert(
732 "stream_data".to_string(),
733 HandlerMeta::streaming(vec![], TsType::String, TsType::Void),
734 );
735
736 let ts = generate_ts_client(&metas);
737
738 assert!(ts.contains("export async function toObservable"));
739 assert!(ts.contains("import(\"rxjs\")"));
740 assert!(ts.contains("new Observable"));
741 assert!(ts.contains("subscriber.next"));
742 assert!(ts.contains("s.unsubscribe()"));
743 }
744
745 #[test]
746 fn test_no_rxjs_adapter_without_streaming() {
747 let mut metas = HashMap::new();
748 metas.insert(
749 "get_user".to_string(),
750 HandlerMeta::new(vec![], TsType::String),
751 );
752
753 let ts = generate_ts_client(&metas);
754 assert!(!ts.contains("toObservable"));
755 }
756}