use serde_json::Value;
use std::collections::HashSet;
pub const MAX_SCHEMA_RECURSION_DEPTH: usize = 128;
#[must_use]
pub fn to_camel_case(snake_case: &str) -> String {
let mut result = String::new();
let mut capitalize_next = false;
for ch in snake_case.chars() {
if ch == '_' {
capitalize_next = true;
} else if capitalize_next {
result.push(ch.to_ascii_uppercase());
capitalize_next = false;
} else {
result.push(ch);
}
}
result
}
#[must_use]
pub fn to_pascal_case(snake_case: &str) -> String {
let camel = to_camel_case(snake_case);
let mut chars = camel.chars();
chars.next().map_or_else(String::new, |first| {
first.to_uppercase().collect::<String>() + chars.as_str()
})
}
#[must_use]
pub fn sanitize_ts_identifier(s: &str) -> String {
let mut result = String::new();
let mut prev_was_underscore = false;
for c in s.chars() {
if c.is_ascii_alphanumeric() || c == '$' {
result.push(c);
prev_was_underscore = false;
} else if !prev_was_underscore {
result.push('_');
prev_was_underscore = true;
}
}
if result.is_empty() || result.starts_with(|c: char| c.is_ascii_digit()) {
result.insert(0, '_');
}
result
}
pub(crate) fn disambiguate_identifier(base: &str, used: &mut HashSet<String>) -> String {
let mut candidate = base.to_string();
let mut suffix = 2;
while !used.insert(candidate.clone()) {
candidate = format!("{base}_{suffix}");
suffix += 1;
}
candidate
}
#[must_use]
pub fn json_type_to_typescript(json_type: &str) -> &'static str {
match json_type {
"string" => "string",
"number" | "integer" => "number",
"boolean" => "boolean",
"array" => "unknown[]",
"object" => "Record<string, unknown>",
"null" => "null",
_ => "unknown",
}
}
#[must_use]
pub fn json_schema_to_typescript(schema: &Value) -> String {
let mut cap_hit = false;
let ts_type = json_schema_to_typescript_at_depth(schema, 0, &mut cap_hit);
if cap_hit {
tracing::warn!(
max_depth = MAX_SCHEMA_RECURSION_DEPTH,
"schema nesting exceeded MAX_SCHEMA_RECURSION_DEPTH; branches beyond that depth were \
rendered as an opaque `unknown` type"
);
}
ts_type
}
fn json_schema_to_typescript_at_depth(schema: &Value, depth: usize, cap_hit: &mut bool) -> String {
if depth >= MAX_SCHEMA_RECURSION_DEPTH {
*cap_hit = true;
return "unknown".to_string();
}
match schema {
Value::Object(obj) => {
let schema_type = obj
.get("type")
.and_then(|v| v.as_str())
.unwrap_or("unknown");
match schema_type {
"object" => {
let properties = obj.get("properties").and_then(|v| v.as_object());
let required = obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect::<Vec<_>>())
.unwrap_or_default();
properties.map_or_else(
|| "Record<string, unknown>".to_string(),
|props| {
let mut fields = Vec::new();
let mut used_keys = HashSet::new();
for (key, value) in props {
let is_required = required.contains(&key.as_str());
let optional_marker = if is_required { "" } else { "?" };
let ts_type =
json_schema_to_typescript_at_depth(value, depth + 1, cap_hit);
let base_key = sanitize_ts_identifier(key);
let safe_key = disambiguate_identifier(&base_key, &mut used_keys);
fields.push(format!(" {safe_key}{optional_marker}: {ts_type};"));
}
if fields.is_empty() {
"Record<string, unknown>".to_string()
} else {
format!("{{\n{}\n}}", fields.join("\n"))
}
},
)
}
"array" => obj.get("items").map_or_else(
|| "unknown[]".to_string(),
|item_schema| {
format!(
"{}[]",
json_schema_to_typescript_at_depth(item_schema, depth + 1, cap_hit)
)
},
),
other => json_type_to_typescript(other).to_string(),
}
}
Value::String(s) => json_type_to_typescript(s).to_string(),
_ => "unknown".to_string(),
}
}
#[must_use]
pub fn extract_properties(schema: &Value) -> Vec<serde_json::Value> {
let mut properties = Vec::new();
if let Some(obj) = schema.as_object()
&& let Some(props) = obj.get("properties").and_then(|v| v.as_object())
{
let required = obj
.get("required")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.map(String::from)
.collect::<Vec<_>>()
})
.unwrap_or_default();
for (name, prop_schema) in props {
let ts_type = json_schema_to_typescript(prop_schema);
let is_required = required.contains(name);
properties.push(serde_json::json!({
"name": name,
"type": ts_type,
"required": is_required,
}));
}
}
properties
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn test_to_camel_case() {
assert_eq!(to_camel_case("send_message"), "sendMessage");
assert_eq!(to_camel_case("get_user_data"), "getUserData");
assert_eq!(to_camel_case("hello"), "hello");
assert_eq!(to_camel_case("a_b_c"), "aBC");
}
#[test]
fn test_to_pascal_case() {
assert_eq!(to_pascal_case("send_message"), "SendMessage");
assert_eq!(to_pascal_case("get_user_data"), "GetUserData");
assert_eq!(to_pascal_case("hello"), "Hello");
}
#[test]
fn test_json_type_to_typescript() {
assert_eq!(json_type_to_typescript("string"), "string");
assert_eq!(json_type_to_typescript("number"), "number");
assert_eq!(json_type_to_typescript("integer"), "number");
assert_eq!(json_type_to_typescript("boolean"), "boolean");
assert_eq!(json_type_to_typescript("array"), "unknown[]");
assert_eq!(json_type_to_typescript("object"), "Record<string, unknown>");
assert_eq!(json_type_to_typescript("null"), "null");
assert_eq!(json_type_to_typescript("unknown_type"), "unknown");
}
#[test]
fn test_json_schema_to_typescript_primitive() {
assert_eq!(
json_schema_to_typescript(&json!({"type": "string"})),
"string"
);
assert_eq!(
json_schema_to_typescript(&json!({"type": "number"})),
"number"
);
}
#[test]
fn test_json_schema_to_typescript_object() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "number"}
},
"required": ["name"]
});
let result = json_schema_to_typescript(&schema);
assert!(result.contains("name: string"));
assert!(result.contains("age?: number"));
}
#[test]
fn test_json_schema_to_typescript_object_sanitizes_nested_keys() {
let malicious_key = "x }; export const pwned = evil(); interface J {";
let schema = json!({
"type": "object",
"properties": {
malicious_key: {"type": "string"}
},
"required": []
});
let result = json_schema_to_typescript(&schema);
assert!(!result.contains("export const pwned"));
assert!(!result.contains(malicious_key));
assert!(result.contains(&sanitize_ts_identifier(malicious_key)));
}
#[test]
fn test_json_schema_to_typescript_dedups_colliding_sibling_keys() {
let schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"}
},
"required": []
});
let result = json_schema_to_typescript(&schema);
assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
}
#[test]
fn test_json_schema_to_typescript_dedups_three_way_colliding_sibling_keys() {
let schema = json!({
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"},
"a b": {"type": "boolean"}
},
"required": []
});
let result = json_schema_to_typescript(&schema);
assert_eq!(result.matches("a_b?: string").count(), 1, "{result}");
assert_eq!(result.matches("a_b_2?: number").count(), 1, "{result}");
assert_eq!(result.matches("a_b_3?: boolean").count(), 1, "{result}");
}
#[test]
fn test_json_schema_to_typescript_dedups_colliding_keys_in_nested_object() {
let schema = json!({
"type": "object",
"properties": {
"outer": {
"type": "object",
"properties": {
"a-b": {"type": "string"},
"a.b": {"type": "number"}
},
"required": []
}
},
"required": []
});
let result = json_schema_to_typescript(&schema);
assert!(result.contains("a_b?: string"), "{result}");
assert!(result.contains("a_b_2?: number"), "{result}");
}
#[test]
fn test_disambiguate_identifier_reuses_base_across_independent_scopes() {
let schema = json!({
"type": "object",
"properties": {
"first": {
"type": "object",
"properties": {"a_b": {"type": "string"}},
"required": []
},
"second": {
"type": "object",
"properties": {"a_b": {"type": "number"}},
"required": []
}
},
"required": []
});
let result = json_schema_to_typescript(&schema);
assert!(!result.contains("a_b_2"), "{result}");
}
#[test]
fn test_sanitize_ts_identifier_replaces_invalid_characters() {
assert_eq!(sanitize_ts_identifier("a-b c"), "a_b_c");
}
#[test]
fn test_sanitize_ts_identifier_prefixes_leading_digit() {
assert_eq!(sanitize_ts_identifier("123name"), "_123name");
}
#[test]
fn test_sanitize_ts_identifier_prefixes_empty_string() {
assert_eq!(sanitize_ts_identifier(""), "_");
}
#[test]
fn test_sanitize_ts_identifier_collapses_consecutive_non_ascii_run() {
assert_eq!(sanitize_ts_identifier("café_menu_日本語"), "caf_menu_");
}
#[test]
fn test_sanitize_ts_identifier_collapses_mixed_invalid_run() {
assert_eq!(sanitize_ts_identifier("a---b"), "a_b");
assert_eq!(sanitize_ts_identifier("a- .b"), "a_b");
}
#[test]
fn test_sanitize_ts_identifier_collapses_literal_underscore_runs() {
assert_eq!(sanitize_ts_identifier("a__b"), "a_b");
assert_eq!(sanitize_ts_identifier("日_本"), "_");
assert_eq!(sanitize_ts_identifier("_日_"), "_");
}
#[test]
fn test_sanitize_ts_identifier_preserves_isolated_invalid_chars() {
assert_eq!(sanitize_ts_identifier("a-b-c"), "a_b_c");
}
#[test]
fn test_sanitize_ts_identifier_all_invalid_chars_collapses_to_bare_underscore() {
assert_eq!(sanitize_ts_identifier("日本語"), "_");
assert_eq!(sanitize_ts_identifier("---"), "_");
}
#[test]
fn test_json_schema_to_typescript_array() {
let schema = json!({
"type": "array",
"items": {"type": "string"}
});
assert_eq!(json_schema_to_typescript(&schema), "string[]");
}
fn nested_array_schema(depth: usize) -> Value {
let mut schema = json!({"type": "string"});
for _ in 0..depth {
let mut map = serde_json::Map::new();
map.insert("type".to_string(), Value::String("array".to_string()));
map.insert("items".to_string(), schema);
schema = Value::Object(map);
}
schema
}
fn nested_object_schema(depth: usize) -> Value {
let mut schema = json!({"type": "string"});
for _ in 0..depth {
let mut properties = serde_json::Map::new();
properties.insert("a".to_string(), schema);
let mut map = serde_json::Map::new();
map.insert("type".to_string(), Value::String("object".to_string()));
map.insert("properties".to_string(), Value::Object(properties));
map.insert("required".to_string(), Value::Array(Vec::new()));
schema = Value::Object(map);
}
schema
}
fn run_on_large_stack<F: FnOnce() + Send + 'static>(f: F) {
std::thread::Builder::new()
.stack_size(64 * 1024 * 1024)
.spawn(f)
.expect("spawn test thread")
.join()
.expect("test thread panicked");
}
#[test]
fn test_json_schema_to_typescript_bounds_deeply_nested_array() {
run_on_large_stack(|| {
let schema = nested_array_schema(5_000);
let result = json_schema_to_typescript(&schema);
assert!(result.starts_with("unknown"), "{result}");
assert!(result.ends_with("[]"), "{result}");
});
}
#[test]
fn test_json_schema_to_typescript_bounds_deeply_nested_object() {
run_on_large_stack(|| {
let schema = nested_object_schema(5_000);
let result = json_schema_to_typescript(&schema);
assert!(result.contains("unknown"), "{result}");
});
}
#[test]
fn test_extract_properties() {
let schema = json!({
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "number"}
},
"required": ["name"]
});
let props = extract_properties(&schema);
assert_eq!(props.len(), 2);
let name_prop = props
.iter()
.find(|p| p["name"] == "name")
.expect("name property not found");
assert_eq!(name_prop["type"], "string");
assert_eq!(name_prop["required"], true);
let age_prop = props
.iter()
.find(|p| p["name"] == "age")
.expect("age property not found");
assert_eq!(age_prop["type"], "number");
assert_eq!(age_prop["required"], false);
}
#[test]
fn test_extract_properties_empty() {
let schema = json!({"type": "string"});
let props = extract_properties(&schema);
assert_eq!(props.len(), 0);
}
}