Skip to main content

mcp_execution_codegen/common/
typescript.rs

1//! TypeScript code generation utilities.
2//!
3//! Provides functions to convert JSON Schema to TypeScript types
4//! and generate type-safe TypeScript code.
5//!
6//! # Examples
7//!
8//! ```
9//! use mcp_execution_codegen::common::typescript;
10//! use serde_json::json;
11//!
12//! let schema = json!({
13//!     "type": "object",
14//!     "properties": {
15//!         "name": {"type": "string"},
16//!         "age": {"type": "number"}
17//!     }
18//! });
19//!
20//! let ts_type = typescript::json_schema_to_typescript(&schema);
21//! ```
22
23use serde_json::Value;
24use std::collections::HashSet;
25
26/// Converts a snake_case name to camelCase for TypeScript.
27///
28/// # Examples
29///
30/// ```
31/// use mcp_execution_codegen::common::typescript::to_camel_case;
32///
33/// assert_eq!(to_camel_case("send_message"), "sendMessage");
34/// assert_eq!(to_camel_case("get_user_data"), "getUserData");
35/// assert_eq!(to_camel_case("hello"), "hello");
36/// ```
37#[must_use]
38pub fn to_camel_case(snake_case: &str) -> String {
39    let mut result = String::new();
40    let mut capitalize_next = false;
41
42    for ch in snake_case.chars() {
43        if ch == '_' {
44            capitalize_next = true;
45        } else if capitalize_next {
46            result.push(ch.to_ascii_uppercase());
47            capitalize_next = false;
48        } else {
49            result.push(ch);
50        }
51    }
52
53    result
54}
55
56/// Converts a snake_case name to PascalCase for TypeScript types.
57///
58/// # Examples
59///
60/// ```
61/// use mcp_execution_codegen::common::typescript::to_pascal_case;
62///
63/// assert_eq!(to_pascal_case("send_message"), "SendMessage");
64/// assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
65/// assert_eq!(to_pascal_case("hello"), "Hello");
66/// ```
67#[must_use]
68pub fn to_pascal_case(snake_case: &str) -> String {
69    let camel = to_camel_case(snake_case);
70    let mut chars = camel.chars();
71    match chars.next() {
72        None => String::new(),
73        Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
74    }
75}
76
77/// Sanitizes a string for safe use as a TypeScript identifier (e.g. a function, export,
78/// or object property name).
79///
80/// Replaces any character outside `[A-Za-z0-9_$]` with `_`, and prefixes the result with
81/// `_` if it would otherwise start with a digit or be empty. This prevents identifier-position
82/// injection of arbitrary TypeScript syntax from untrusted schema data (property keys, tool
83/// names, etc. sourced from an MCP server are not guaranteed to be valid identifiers).
84///
85/// # Examples
86///
87/// ```
88/// use mcp_execution_codegen::common::typescript::sanitize_ts_identifier;
89///
90/// assert_eq!(sanitize_ts_identifier("valid_name"), "valid_name");
91/// assert_eq!(sanitize_ts_identifier("123abc"), "_123abc");
92/// assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
93/// ```
94#[must_use]
95pub fn sanitize_ts_identifier(s: &str) -> String {
96    let mut result: String = s
97        .chars()
98        .map(|c| {
99            if c.is_ascii_alphanumeric() || c == '_' || c == '$' {
100                c
101            } else {
102                '_'
103            }
104        })
105        .collect();
106
107    if result.is_empty() || result.starts_with(|c: char| c.is_ascii_digit()) {
108        result.insert(0, '_');
109    }
110
111    result
112}
113
114/// Disambiguates `base` against the `used` set by appending a numeric suffix
115/// (`_2`, `_3`, ...) until the candidate is not already present, then reserves the
116/// winning candidate by inserting it into `used`.
117///
118/// Shared by every call site that maps untrusted, sanitized names into a namespace where
119/// distinct source names can collide after sanitization (e.g. sibling schema keys or tool
120/// names) — collisions must be disambiguated deterministically rather than silently
121/// overwriting one another.
122pub(crate) fn disambiguate_identifier(base: &str, used: &mut HashSet<String>) -> String {
123    let mut candidate = base.to_string();
124    let mut suffix = 2;
125    while !used.insert(candidate.clone()) {
126        candidate = format!("{base}_{suffix}");
127        suffix += 1;
128    }
129    candidate
130}
131
132/// Converts JSON Schema type to TypeScript type.
133///
134/// Maps JSON Schema primitive types to their TypeScript equivalents.
135///
136/// # Examples
137///
138/// ```
139/// use mcp_execution_codegen::common::typescript::json_type_to_typescript;
140///
141/// assert_eq!(json_type_to_typescript("string"), "string");
142/// assert_eq!(json_type_to_typescript("number"), "number");
143/// assert_eq!(json_type_to_typescript("integer"), "number");
144/// assert_eq!(json_type_to_typescript("boolean"), "boolean");
145/// assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
146/// ```
147#[must_use]
148pub fn json_type_to_typescript(json_type: &str) -> &'static str {
149    match json_type {
150        "string" => "string",
151        "number" | "integer" => "number",
152        "boolean" => "boolean",
153        "array" => "unknown[]",
154        "object" => "Record<string, unknown>",
155        "null" => "null",
156        _ => "unknown",
157    }
158}
159
160/// Converts a JSON Schema to TypeScript type definition.
161///
162/// Handles complex schemas including objects, arrays, and nested types.
163///
164/// # Examples
165///
166/// ```
167/// use mcp_execution_codegen::common::typescript::json_schema_to_typescript;
168/// use serde_json::json;
169///
170/// let schema = json!({
171///     "type": "object",
172///     "properties": {
173///         "name": {"type": "string"},
174///         "age": {"type": "number"}
175///     },
176///     "required": ["name"]
177/// });
178///
179/// let ts = json_schema_to_typescript(&schema);
180/// assert!(ts.contains("name: string"));
181/// ```
182#[must_use]
183pub fn json_schema_to_typescript(schema: &Value) -> String {
184    match schema {
185        Value::Object(obj) => {
186            // Get type field
187            let schema_type = obj
188                .get("type")
189                .and_then(|v| v.as_str())
190                .unwrap_or("unknown");
191
192            match schema_type {
193                "object" => {
194                    // Extract properties
195                    let properties = obj.get("properties").and_then(|v| v.as_object());
196                    let required = obj
197                        .get("required")
198                        .and_then(|v| v.as_array())
199                        .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
200                        .unwrap_or_default();
201
202                    if let Some(props) = properties {
203                        let mut fields = Vec::new();
204                        let mut used_keys = HashSet::new();
205                        for (key, value) in props {
206                            let is_required = required.contains(&key.as_str());
207                            let optional_marker = if is_required { "" } else { "?" };
208                            let ts_type = json_schema_to_typescript(value);
209                            let base_key = sanitize_ts_identifier(key);
210                            let safe_key = disambiguate_identifier(&base_key, &mut used_keys);
211                            fields.push(format!("  {safe_key}{optional_marker}: {ts_type};"));
212                        }
213
214                        if fields.is_empty() {
215                            "Record<string, unknown>".to_string()
216                        } else {
217                            format!("{{\n{}\n}}", fields.join("\n"))
218                        }
219                    } else {
220                        "Record<string, unknown>".to_string()
221                    }
222                }
223                "array" => {
224                    let items = obj.get("items");
225                    if let Some(item_schema) = items {
226                        format!("{}[]", json_schema_to_typescript(item_schema))
227                    } else {
228                        "unknown[]".to_string()
229                    }
230                }
231                other => json_type_to_typescript(other).to_string(),
232            }
233        }
234        Value::String(s) => json_type_to_typescript(s).to_string(),
235        _ => "unknown".to_string(),
236    }
237}
238
239/// Extracts property definitions from JSON Schema for template rendering.
240///
241/// Returns a vector of property information suitable for Handlebars templates.
242///
243/// # Examples
244///
245/// ```
246/// use mcp_execution_codegen::common::typescript::extract_properties;
247/// use serde_json::json;
248///
249/// let schema = json!({
250///     "type": "object",
251///     "properties": {
252///         "name": {"type": "string"},
253///         "age": {"type": "number"}
254///     },
255///     "required": ["name"]
256/// });
257///
258/// let props = extract_properties(&schema);
259/// assert_eq!(props.len(), 2);
260/// ```
261#[must_use]
262pub fn extract_properties(schema: &Value) -> Vec<serde_json::Value> {
263    let mut properties = Vec::new();
264
265    if let Some(obj) = schema.as_object()
266        && let Some(props) = obj.get("properties").and_then(|v| v.as_object())
267    {
268        let required = obj
269            .get("required")
270            .and_then(|v| v.as_array())
271            .map(|arr| {
272                arr.iter()
273                    .filter_map(|v| v.as_str())
274                    .map(String::from)
275                    .collect::<Vec<_>>()
276            })
277            .unwrap_or_default();
278
279        for (name, prop_schema) in props {
280            let ts_type = json_schema_to_typescript(prop_schema);
281            let is_required = required.contains(name);
282
283            properties.push(serde_json::json!({
284                "name": name,
285                "type": ts_type,
286                "required": is_required,
287            }));
288        }
289    }
290
291    properties
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use serde_json::json;
298
299    #[test]
300    fn test_to_camel_case() {
301        assert_eq!(to_camel_case("send_message"), "sendMessage");
302        assert_eq!(to_camel_case("get_user_data"), "getUserData");
303        assert_eq!(to_camel_case("hello"), "hello");
304        assert_eq!(to_camel_case("a_b_c"), "aBC");
305    }
306
307    #[test]
308    fn test_to_pascal_case() {
309        assert_eq!(to_pascal_case("send_message"), "SendMessage");
310        assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
311        assert_eq!(to_pascal_case("hello"), "Hello");
312    }
313
314    #[test]
315    fn test_json_type_to_typescript() {
316        assert_eq!(json_type_to_typescript("string"), "string");
317        assert_eq!(json_type_to_typescript("number"), "number");
318        assert_eq!(json_type_to_typescript("integer"), "number");
319        assert_eq!(json_type_to_typescript("boolean"), "boolean");
320        assert_eq!(json_type_to_typescript("array"), "unknown[]");
321        assert_eq!(json_type_to_typescript("object"), "Record<string, unknown>");
322        assert_eq!(json_type_to_typescript("null"), "null");
323        assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
324    }
325
326    #[test]
327    fn test_json_schema_to_typescript_primitive() {
328        assert_eq!(
329            json_schema_to_typescript(&json!({"type": "string"})),
330            "string"
331        );
332        assert_eq!(
333            json_schema_to_typescript(&json!({"type": "number"})),
334            "number"
335        );
336    }
337
338    #[test]
339    fn test_json_schema_to_typescript_object() {
340        let schema = json!({
341            "type": "object",
342            "properties": {
343                "name": {"type": "string"},
344                "age": {"type": "number"}
345            },
346            "required": ["name"]
347        });
348
349        let result = json_schema_to_typescript(&schema);
350        assert!(result.contains("name: string"));
351        assert!(result.contains("age?: number"));
352    }
353
354    #[test]
355    fn test_json_schema_to_typescript_object_sanitizes_nested_keys() {
356        let malicious_key = "x }; export const pwned = evil(); interface J {";
357        let schema = json!({
358            "type": "object",
359            "properties": {
360                malicious_key: {"type": "string"}
361            },
362            "required": []
363        });
364
365        let result = json_schema_to_typescript(&schema);
366        assert!(!result.contains("export const pwned"));
367        assert!(!result.contains(malicious_key));
368        assert!(result.contains(&sanitize_ts_identifier(malicious_key)));
369    }
370
371    #[test]
372    fn test_json_schema_to_typescript_dedups_colliding_sibling_keys() {
373        // "a-b" and "a.b" both sanitize to "a_b"; the second must be disambiguated
374        // rather than producing a duplicate field name in the generated interface.
375        let schema = json!({
376            "type": "object",
377            "properties": {
378                "a-b": {"type": "string"},
379                "a.b": {"type": "number"}
380            },
381            "required": []
382        });
383
384        let result = json_schema_to_typescript(&schema);
385        // Exact field-line matches: a substring count on "a_b" would also match "a_b_2",
386        // so assert on the full `key?: type` field lines instead.
387        assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
388        assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
389    }
390
391    #[test]
392    fn test_json_schema_to_typescript_dedups_three_way_colliding_sibling_keys() {
393        let schema = json!({
394            "type": "object",
395            "properties": {
396                "a-b": {"type": "string"},
397                "a.b": {"type": "number"},
398                "a b": {"type": "boolean"}
399            },
400            "required": []
401        });
402
403        let result = json_schema_to_typescript(&schema);
404        // The `preserve_order` feature is enabled transitively (via schemars/rmcp), so
405        // `serde_json::Map` iterates in insertion order: "a-b" claims the base "a_b" first.
406        assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
407        assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
408        assert_eq!(result.matches("a_b_3?: boolean").count(), 1, "{result}");
409    }
410
411    #[test]
412    fn test_json_schema_to_typescript_dedups_colliding_keys_in_nested_object() {
413        let schema = json!({
414            "type": "object",
415            "properties": {
416                "outer": {
417                    "type": "object",
418                    "properties": {
419                        "a-b": {"type": "string"},
420                        "a.b": {"type": "number"}
421                    },
422                    "required": []
423                }
424            },
425            "required": []
426        });
427
428        let result = json_schema_to_typescript(&schema);
429        assert!(result.contains("a_b?: string"), "{result}");
430        assert!(result.contains("a_b_2?: number"), "{result}");
431    }
432
433    #[test]
434    fn test_disambiguate_identifier_reuses_base_across_independent_scopes() {
435        // Disambiguation state must not leak between unrelated objects: two sibling nested
436        // objects each independently reusing "a_b" as a field name is not a collision.
437        let schema = json!({
438            "type": "object",
439            "properties": {
440                "first": {
441                    "type": "object",
442                    "properties": {"a_b": {"type": "string"}},
443                    "required": []
444                },
445                "second": {
446                    "type": "object",
447                    "properties": {"a_b": {"type": "number"}},
448                    "required": []
449                }
450            },
451            "required": []
452        });
453
454        let result = json_schema_to_typescript(&schema);
455        assert!(!result.contains("a_b_2"), "{result}");
456    }
457
458    #[test]
459    fn test_sanitize_ts_identifier_replaces_invalid_characters() {
460        assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
461    }
462
463    #[test]
464    fn test_sanitize_ts_identifier_prefixes_leading_digit() {
465        assert_eq!(sanitize_ts_identifier("123name"), "_123name");
466    }
467
468    #[test]
469    fn test_sanitize_ts_identifier_prefixes_empty_string() {
470        assert_eq!(sanitize_ts_identifier(""), "_");
471    }
472
473    #[test]
474    fn test_json_schema_to_typescript_array() {
475        let schema = json!({
476            "type": "array",
477            "items": {"type": "string"}
478        });
479
480        assert_eq!(json_schema_to_typescript(&schema), "string[]");
481    }
482
483    #[test]
484    fn test_extract_properties() {
485        let schema = json!({
486            "type": "object",
487            "properties": {
488                "name": {"type": "string"},
489                "age": {"type": "number"}
490            },
491            "required": ["name"]
492        });
493
494        let props = extract_properties(&schema);
495        assert_eq!(props.len(), 2);
496
497        // Find the "name" property (HashMap order is not guaranteed)
498        let name_prop = props
499            .iter()
500            .find(|p| p["name"] == "name")
501            .expect("name property not found");
502
503        assert_eq!(name_prop["type"], "string");
504        assert_eq!(name_prop["required"], true);
505
506        // Check age property
507        let age_prop = props
508            .iter()
509            .find(|p| p["name"] == "age")
510            .expect("age property not found");
511
512        assert_eq!(age_prop["type"], "number");
513        assert_eq!(age_prop["required"], false);
514    }
515
516    #[test]
517    fn test_extract_properties_empty() {
518        let schema = json!({"type": "string"});
519        let props = extract_properties(&schema);
520        assert_eq!(props.len(), 0);
521    }
522}