Skip to main content

allframe_core/router/
ts_codegen.rs

1//! TypeScript client code generation from Router handler metadata
2//!
3//! Generates typed async functions that call AllFrame handlers via IPC,
4//! eliminating stringly-typed `allframe_call` invocations.
5//!
6//! # Example
7//!
8//! ```rust
9//! use allframe_core::router::{Router, TsField, TsType};
10//!
11//! let mut router = Router::new();
12//! router.register("get_user", || async { r#"{"id":1}"#.to_string() });
13//! router.describe_handler("get_user", vec![], TsType::Object(vec![
14//!     TsField::new("id", TsType::Number),
15//!     TsField::new("name", TsType::String),
16//! ]));
17//!
18//! let ts_code = router.generate_ts_client();
19//! assert!(ts_code.contains("export async function getUser()"));
20//! ```
21
22use std::collections::HashMap;
23use std::fmt::Write;
24
25/// TypeScript type representation
26#[derive(Debug, Clone, PartialEq)]
27pub enum TsType {
28    /// `string`
29    String,
30    /// `number`
31    Number,
32    /// `boolean`
33    Boolean,
34    /// `null`
35    Null,
36    /// `T | null`
37    Optional(Box<TsType>),
38    /// `T[]`
39    Array(Box<TsType>),
40    /// `{ field: Type, ... }`
41    Object(Vec<TsField>),
42    /// Named interface reference (e.g., `UserResponse`)
43    Named(String),
44    /// `void`
45    Void,
46    /// Raw TS type string (escape hatch)
47    Raw(String),
48}
49
50/// A field in a TS object/interface
51#[derive(Debug, Clone, PartialEq)]
52pub struct TsField {
53    /// Field name
54    pub name: String,
55    /// Field type
56    pub ty: TsType,
57    /// Whether the field is optional (renders as `name?: Type`)
58    pub optional: bool,
59}
60
61impl TsField {
62    /// Create a required field
63    pub fn new(name: &str, ty: TsType) -> Self {
64        Self {
65            name: name.to_string(),
66            ty,
67            optional: false,
68        }
69    }
70
71    /// Create an optional field
72    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/// Metadata describing a handler's argument and return types
82#[derive(Debug, Clone)]
83pub struct HandlerMeta {
84    /// Argument fields (empty = no args)
85    pub args: Vec<TsField>,
86    /// Return type
87    pub returns: TsType,
88}
89
90impl TsType {
91    /// Render this type as a TypeScript type string
92    pub fn render(&self) -> String {
93        match self {
94            TsType::String => "string".to_string(),
95            TsType::Number => "number".to_string(),
96            TsType::Boolean => "boolean".to_string(),
97            TsType::Null => "null".to_string(),
98            TsType::Void => "void".to_string(),
99            TsType::Optional(inner) => format!("{} | null", inner.render()),
100            TsType::Array(inner) => {
101                let inner_str = inner.render();
102                if inner_str.contains('|') {
103                    format!("({inner_str})[]")
104                } else {
105                    format!("{inner_str}[]")
106                }
107            }
108            TsType::Object(fields) => {
109                if fields.is_empty() {
110                    return "Record<string, never>".to_string();
111                }
112                let mut s = "{ ".to_string();
113                for (i, field) in fields.iter().enumerate() {
114                    if i > 0 {
115                        s.push_str("; ");
116                    }
117                    if field.optional {
118                        write!(s, "{}?: {}", field.name, field.ty.render()).unwrap();
119                    } else {
120                        write!(s, "{}: {}", field.name, field.ty.render()).unwrap();
121                    }
122                }
123                s.push_str(" }");
124                s
125            }
126            TsType::Named(name) => name.clone(),
127            TsType::Raw(raw) => raw.clone(),
128        }
129    }
130}
131
132/// Convert a snake_case handler name to camelCase for TS function name.
133///
134/// Lowercases the entire input first, then capitalizes the first char after
135/// each underscore. Handles ALL_CAPS input correctly.
136fn to_camel_case(s: &str) -> String {
137    let lower = s.to_lowercase();
138    let mut result = String::new();
139    let mut capitalize_next = false;
140
141    for c in lower.chars() {
142        if c == '_' {
143            capitalize_next = true;
144        } else if capitalize_next {
145            result.push(c.to_uppercase().next().unwrap_or(c));
146            capitalize_next = false;
147        } else {
148            result.push(c);
149        }
150    }
151
152    result
153}
154
155/// Convert a snake_case name to PascalCase for TS interface name
156fn to_pascal_case(s: &str) -> String {
157    s.split('_')
158        .map(|word| {
159            let mut chars = word.chars();
160            match chars.next() {
161                None => String::new(),
162                Some(c) => {
163                    let upper: String = c.to_uppercase().collect();
164                    let rest: String = chars.flat_map(|ch| ch.to_lowercase()).collect();
165                    format!("{upper}{rest}")
166                }
167            }
168        })
169        .collect()
170}
171
172/// Generate a complete TypeScript client module from handler metadata
173pub fn generate_ts_client(handler_metas: &HashMap<String, HandlerMeta>) -> String {
174    let mut output = String::new();
175
176    // Header
177    output.push_str("// Auto-generated by AllFrame. Do not edit manually.\n");
178    output.push_str("// Regenerate with: allframe generate-ts-client\n\n");
179    output.push_str("import { invoke } from \"@tauri-apps/api/core\";\n\n");
180
181    // Internal helper that unwraps CallResponse and parses JSON
182    output.push_str("/** @internal Unwrap CallResponse and parse the JSON result. */\n");
183    output.push_str("async function callHandler<T>(handler: string, args: Record<string, unknown> = {}): Promise<T> {\n");
184    output.push_str("  const response = await invoke<{ result: string }>(\"plugin:allframe|allframe_call\", { handler, args });\n");
185    output.push_str("  return JSON.parse(response.result) as T;\n");
186    output.push_str("}\n\n");
187
188    // Collect interfaces to generate
189    let mut interfaces: Vec<(String, &[TsField])> = Vec::new();
190
191    // Sort handlers by name for deterministic output
192    let mut sorted_handlers: Vec<_> = handler_metas.iter().collect();
193    sorted_handlers.sort_by_key(|(name, _)| (*name).clone());
194
195    // First pass: collect interfaces from Object types
196    for (handler_name, meta) in &sorted_handlers {
197        let pascal = to_pascal_case(handler_name);
198
199        // Args interface
200        if !meta.args.is_empty() {
201            interfaces.push((format!("{pascal}Args"), &meta.args));
202        }
203
204        // Return interface (if Object type)
205        if let TsType::Object(fields) = &meta.returns {
206            interfaces.push((format!("{pascal}Response"), fields));
207        }
208    }
209
210    // Generate interfaces
211    for (name, fields) in &interfaces {
212        writeln!(output, "export interface {name} {{").unwrap();
213        for field in *fields {
214            if field.optional {
215                writeln!(output, "  {}?: {};", field.name, field.ty.render()).unwrap();
216            } else {
217                writeln!(output, "  {}: {};", field.name, field.ty.render()).unwrap();
218            }
219        }
220        output.push_str("}\n\n");
221    }
222
223    // Generate functions
224    for (handler_name, meta) in &sorted_handlers {
225        let fn_name = to_camel_case(handler_name);
226        let pascal = to_pascal_case(handler_name);
227
228        // Determine return type string
229        let return_type = if let TsType::Object(_) = &meta.returns {
230            format!("{pascal}Response")
231        } else {
232            meta.returns.render()
233        };
234
235        if meta.args.is_empty() {
236            writeln!(
237                output,
238                "export async function {fn_name}(): Promise<{return_type}> {{",
239            )
240            .unwrap();
241            writeln!(
242                output,
243                "  return callHandler<{return_type}>(\"{handler_name}\");",
244            )
245            .unwrap();
246        } else {
247            let args_type = format!("{pascal}Args");
248            writeln!(
249                output,
250                "export async function {fn_name}(args: {args_type}): Promise<{return_type}> {{",
251            )
252            .unwrap();
253            writeln!(
254                output,
255                "  return callHandler<{return_type}>(\"{handler_name}\", args);",
256            )
257            .unwrap();
258        }
259
260        output.push_str("}\n\n");
261    }
262
263    output.trim_end().to_string()
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_to_camel_case() {
272        assert_eq!(to_camel_case("get_user"), "getUser");
273        assert_eq!(to_camel_case("create_new_item"), "createNewItem");
274        assert_eq!(to_camel_case("hello"), "hello");
275        assert_eq!(to_camel_case("GET_USER"), "getUser");
276    }
277
278    #[test]
279    fn test_to_pascal_case() {
280        assert_eq!(to_pascal_case("get_user"), "GetUser");
281        assert_eq!(to_pascal_case("create_new_item"), "CreateNewItem");
282        assert_eq!(to_pascal_case("hello"), "Hello");
283        assert_eq!(to_pascal_case("GET_USER"), "GetUser");
284    }
285
286    #[test]
287    fn test_ts_type_render_primitives() {
288        assert_eq!(TsType::String.render(), "string");
289        assert_eq!(TsType::Number.render(), "number");
290        assert_eq!(TsType::Boolean.render(), "boolean");
291        assert_eq!(TsType::Null.render(), "null");
292        assert_eq!(TsType::Void.render(), "void");
293    }
294
295    #[test]
296    fn test_ts_type_render_optional() {
297        let opt = TsType::Optional(Box::new(TsType::String));
298        assert_eq!(opt.render(), "string | null");
299    }
300
301    #[test]
302    fn test_ts_type_render_array() {
303        let arr = TsType::Array(Box::new(TsType::Number));
304        assert_eq!(arr.render(), "number[]");
305
306        // Union in array gets parens
307        let arr_opt = TsType::Array(Box::new(TsType::Optional(Box::new(TsType::String))));
308        assert_eq!(arr_opt.render(), "(string | null)[]");
309    }
310
311    #[test]
312    fn test_ts_type_render_object() {
313        let obj = TsType::Object(vec![
314            TsField::new("id", TsType::Number),
315            TsField::new("name", TsType::String),
316        ]);
317        assert_eq!(obj.render(), "{ id: number; name: string }");
318    }
319
320    #[test]
321    fn test_ts_type_render_named() {
322        assert_eq!(TsType::Named("UserResponse".to_string()).render(), "UserResponse");
323    }
324
325    #[test]
326    fn test_generate_no_args_handler() {
327        let mut metas = HashMap::new();
328        metas.insert(
329            "get_status".to_string(),
330            HandlerMeta {
331                args: vec![],
332                returns: TsType::String,
333            },
334        );
335
336        let ts = generate_ts_client(&metas);
337        assert!(ts.contains("export async function getStatus(): Promise<string>"));
338        assert!(ts.contains("callHandler<string>(\"get_status\")"));
339    }
340
341    #[test]
342    fn test_generate_with_args_handler() {
343        let mut metas = HashMap::new();
344        metas.insert(
345            "greet".to_string(),
346            HandlerMeta {
347                args: vec![
348                    TsField::new("name", TsType::String),
349                    TsField::new("age", TsType::Number),
350                ],
351                returns: TsType::Object(vec![TsField::new("greeting", TsType::String)]),
352            },
353        );
354
355        let ts = generate_ts_client(&metas);
356
357        // Should generate args interface
358        assert!(ts.contains("export interface GreetArgs {"));
359        assert!(ts.contains("  name: string;"));
360        assert!(ts.contains("  age: number;"));
361
362        // Should generate response interface
363        assert!(ts.contains("export interface GreetResponse {"));
364        assert!(ts.contains("  greeting: string;"));
365
366        // Should generate function using callHandler
367        assert!(ts.contains("export async function greet(args: GreetArgs): Promise<GreetResponse>"));
368        assert!(ts.contains("callHandler<GreetResponse>(\"greet\", args)"));
369    }
370
371    #[test]
372    fn test_generate_optional_field() {
373        let mut metas = HashMap::new();
374        metas.insert(
375            "search".to_string(),
376            HandlerMeta {
377                args: vec![
378                    TsField::new("query", TsType::String),
379                    TsField::optional("limit", TsType::Number),
380                ],
381                returns: TsType::Array(Box::new(TsType::String)),
382            },
383        );
384
385        let ts = generate_ts_client(&metas);
386        assert!(ts.contains("  query: string;"));
387        assert!(ts.contains("  limit?: number;"));
388        assert!(ts.contains("Promise<string[]>"));
389    }
390
391    #[test]
392    fn test_generate_multiple_handlers_sorted() {
393        let mut metas = HashMap::new();
394        metas.insert(
395            "delete_user".to_string(),
396            HandlerMeta {
397                args: vec![TsField::new("id", TsType::Number)],
398                returns: TsType::Void,
399            },
400        );
401        metas.insert(
402            "create_user".to_string(),
403            HandlerMeta {
404                args: vec![TsField::new("name", TsType::String)],
405                returns: TsType::Object(vec![TsField::new("id", TsType::Number)]),
406            },
407        );
408
409        let ts = generate_ts_client(&metas);
410
411        // create_user should come before delete_user (sorted)
412        let create_pos = ts.find("createUser").unwrap();
413        let delete_pos = ts.find("deleteUser").unwrap();
414        assert!(create_pos < delete_pos);
415    }
416
417    #[test]
418    fn test_generate_named_return_type() {
419        let mut metas = HashMap::new();
420        metas.insert(
421            "get_user".to_string(),
422            HandlerMeta {
423                args: vec![TsField::new("id", TsType::Number)],
424                returns: TsType::Named("User".to_string()),
425            },
426        );
427
428        let ts = generate_ts_client(&metas);
429        assert!(ts.contains("Promise<User>"));
430        // Named types don't generate interfaces
431        assert!(!ts.contains("export interface GetUserResponse"));
432    }
433
434    #[test]
435    fn test_generate_header_and_helper() {
436        let metas = HashMap::new();
437        let ts = generate_ts_client(&metas);
438        assert!(ts.contains("Auto-generated by AllFrame"));
439        assert!(ts.contains("import { invoke }"));
440        assert!(ts.contains("async function callHandler<T>"));
441        assert!(ts.contains("JSON.parse(response.result)"));
442    }
443
444    #[test]
445    fn test_generate_idempotent() {
446        let mut metas = HashMap::new();
447        metas.insert(
448            "greet".to_string(),
449            HandlerMeta {
450                args: vec![TsField::new("name", TsType::String)],
451                returns: TsType::String,
452            },
453        );
454
455        let ts1 = generate_ts_client(&metas);
456        let ts2 = generate_ts_client(&metas);
457        assert_eq!(ts1, ts2);
458    }
459
460    #[test]
461    fn test_full_example_output() {
462        let mut metas = HashMap::new();
463        metas.insert(
464            "get_user".to_string(),
465            HandlerMeta {
466                args: vec![TsField::new("id", TsType::Number)],
467                returns: TsType::Object(vec![
468                    TsField::new("id", TsType::Number),
469                    TsField::new("name", TsType::String),
470                    TsField::optional("email", TsType::String),
471                ]),
472            },
473        );
474
475        let ts = generate_ts_client(&metas);
476
477        // Verify complete structure
478        assert!(ts.contains("export interface GetUserArgs {\n  id: number;\n}"));
479        assert!(ts.contains("export interface GetUserResponse {\n  id: number;\n  name: string;\n  email?: string;\n}"));
480        assert!(ts.contains(
481            "export async function getUser(args: GetUserArgs): Promise<GetUserResponse>"
482        ));
483        assert!(ts.contains("callHandler<GetUserResponse>(\"get_user\", args)"));
484    }
485}