use serde_json::Value;
pub fn json_schema_to_zod(schema: &Value) -> String {
convert_schema(schema)
}
pub fn generate_zod_file(schemas: &[(&str, Value)]) -> String {
let mut lines = vec![
"// Auto-generated by vld-ts — do not edit manually".to_string(),
"import { z } from \"zod\";\n".to_string(),
];
for (name, schema) in schemas {
let zod = convert_schema(schema);
lines.push(format!("export const {}Schema = {};", name, zod));
lines.push(format!(
"export type {} = z.infer<typeof {}Schema>;\n",
name, name
));
}
lines.join("\n")
}
fn convert_schema(schema: &Value) -> String {
if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) {
if one_of.len() == 2 {
let is_null_0 = one_of[0].get("type").and_then(|t| t.as_str()) == Some("null");
let is_null_1 = one_of[1].get("type").and_then(|t| t.as_str()) == Some("null");
if is_null_1 && !is_null_0 {
return format!("{}.nullable()", convert_schema(&one_of[0]));
}
if is_null_0 && !is_null_1 {
return format!("{}.nullable()", convert_schema(&one_of[1]));
}
}
let variants: Vec<String> = one_of.iter().map(convert_schema).collect();
return format!("z.union([{}])", variants.join(", "));
}
if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
let parts: Vec<String> = all_of.iter().map(convert_schema).collect();
if parts.len() == 1 {
return parts[0].clone();
}
let mut result = parts[0].clone();
for p in &parts[1..] {
result = format!("z.intersection({}, {})", result, p);
}
return result;
}
if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) {
let variants: Vec<String> = any_of.iter().map(convert_schema).collect();
return format!("z.union([{}])", variants.join(", "));
}
if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
let literals: Vec<String> = enum_vals
.iter()
.map(|v| match v {
Value::String(s) => format!("z.literal(\"{}\")", s),
Value::Number(n) => format!("z.literal({})", n),
Value::Bool(b) => format!("z.literal({})", b),
Value::Null => "z.null()".to_string(),
_ => "z.unknown()".to_string(),
})
.collect();
if literals.len() == 1 {
return literals[0].clone();
}
return format!("z.union([{}])", literals.join(", "));
}
let type_str = schema.get("type").and_then(|t| t.as_str()).unwrap_or("");
match type_str {
"string" => convert_string(schema),
"number" => convert_number(schema),
"integer" => convert_integer(schema),
"boolean" => "z.boolean()".to_string(),
"null" => "z.null()".to_string(),
"array" => convert_array(schema),
"object" => convert_object(schema),
_ => "z.unknown()".to_string(),
}
}
fn convert_string(schema: &Value) -> String {
let mut s = "z.string()".to_string();
if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
s.push_str(&format!(".min({})", min));
}
if let Some(max) = schema.get("maxLength").and_then(|v| v.as_u64()) {
s.push_str(&format!(".max({})", max));
}
if let Some(format) = schema.get("format").and_then(|v| v.as_str()) {
match format {
"email" => s.push_str(".email()"),
"uri" | "url" => s.push_str(".url()"),
"uuid" => s.push_str(".uuid()"),
"ipv4" => s.push_str(".ip({ version: \"v4\" })"),
"ipv6" => s.push_str(".ip({ version: \"v6\" })"),
"date" => s.push_str(".date()"),
"date-time" => s.push_str(".datetime()"),
"time" => s.push_str(".time()"),
_ => { }
}
}
if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
s.push_str(&format!(".regex(/{}/)", pattern));
}
add_description(&mut s, schema);
s
}
fn convert_number(schema: &Value) -> String {
let mut s = "z.number()".to_string();
add_numeric_constraints(&mut s, schema);
add_description(&mut s, schema);
s
}
fn convert_integer(schema: &Value) -> String {
let mut s = "z.number().int()".to_string();
add_numeric_constraints(&mut s, schema);
add_description(&mut s, schema);
s
}
fn add_numeric_constraints(s: &mut String, schema: &Value) {
if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
s.push_str(&format!(".min({})", format_number(min)));
}
if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
s.push_str(&format!(".max({})", format_number(max)));
}
if let Some(gt) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
s.push_str(&format!(".gt({})", format_number(gt)));
}
if let Some(lt) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
s.push_str(&format!(".lt({})", format_number(lt)));
}
if let Some(mul) = schema.get("multipleOf").and_then(|v| v.as_f64()) {
s.push_str(&format!(".multipleOf({})", format_number(mul)));
}
}
fn convert_array(schema: &Value) -> String {
let items = schema
.get("items")
.map(convert_schema)
.unwrap_or_else(|| "z.unknown()".to_string());
let mut s = format!("z.array({})", items);
if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
s.push_str(&format!(".min({})", min));
}
if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
s.push_str(&format!(".max({})", max));
}
if schema.get("uniqueItems").and_then(|v| v.as_bool()) == Some(true) {
s.push_str(" /* uniqueItems */");
}
add_description(&mut s, schema);
s
}
fn convert_object(schema: &Value) -> String {
let required: Vec<&str> = schema
.get("required")
.and_then(|v| v.as_array())
.map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
.unwrap_or_default();
let props = schema.get("properties").and_then(|v| v.as_object());
if let Some(props) = props {
let mut fields: Vec<String> = Vec::new();
for (key, prop_schema) in props {
let mut zod = convert_schema(prop_schema);
if !required.contains(&key.as_str()) {
zod.push_str(".optional()");
}
fields.push(format!(" {}: {}", key, zod));
}
let mut s = format!("z.object({{\n{}\n}})", fields.join(",\n"));
if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
s.push_str(".strict()");
}
add_description(&mut s, schema);
s
} else {
if let Some(additional) = schema.get("additionalProperties") {
if additional.is_object() {
let value_schema = convert_schema(additional);
return format!("z.record(z.string(), {})", value_schema);
}
}
let mut s = "z.object({})".to_string();
if schema.get("additionalProperties") != Some(&Value::Bool(false)) {
s.push_str(".passthrough()");
}
s
}
}
fn add_description(s: &mut String, schema: &Value) {
if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
s.push_str(&format!(".describe(\"{}\")", desc.replace('"', "\\\"")));
}
}
fn format_number(n: f64) -> String {
if n == n.floor() && n.abs() < 1e15 {
format!("{}", n as i64)
} else {
format!("{}", n)
}
}