mcp_execution_codegen/common/
typescript.rs1use serde_json::Value;
24use std::collections::HashSet;
25
26#[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#[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#[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
114pub(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#[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#[must_use]
183pub fn json_schema_to_typescript(schema: &Value) -> String {
184 match schema {
185 Value::Object(obj) => {
186 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 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#[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 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 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 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 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 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 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}