1use serde_json::Value;
27
28pub fn json_schema_to_zod(schema: &Value) -> String {
35 convert_schema(schema)
36}
37
38pub fn generate_zod_file(schemas: &[(&str, Value)]) -> String {
55 let mut lines = vec![
56 "// Auto-generated by vld-ts — do not edit manually".to_string(),
57 "import { z } from \"zod\";\n".to_string(),
58 ];
59
60 for (name, schema) in schemas {
61 let zod = convert_schema(schema);
62 lines.push(format!("export const {}Schema = {};", name, zod));
63 lines.push(format!(
64 "export type {} = z.infer<typeof {}Schema>;\n",
65 name, name
66 ));
67 }
68
69 lines.join("\n")
70}
71
72fn convert_schema(schema: &Value) -> String {
73 if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) {
75 if one_of.len() == 2 {
77 let is_null_0 = one_of[0].get("type").and_then(|t| t.as_str()) == Some("null");
78 let is_null_1 = one_of[1].get("type").and_then(|t| t.as_str()) == Some("null");
79 if is_null_1 && !is_null_0 {
80 return format!("{}.nullable()", convert_schema(&one_of[0]));
81 }
82 if is_null_0 && !is_null_1 {
83 return format!("{}.nullable()", convert_schema(&one_of[1]));
84 }
85 }
86 let variants: Vec<String> = one_of.iter().map(convert_schema).collect();
87 return format!("z.union([{}])", variants.join(", "));
88 }
89
90 if let Some(all_of) = schema.get("allOf").and_then(|v| v.as_array()) {
91 let parts: Vec<String> = all_of.iter().map(convert_schema).collect();
92 if parts.len() == 1 {
93 return parts[0].clone();
94 }
95 let mut result = parts[0].clone();
96 for p in &parts[1..] {
97 result = format!("z.intersection({}, {})", result, p);
98 }
99 return result;
100 }
101
102 if let Some(any_of) = schema.get("anyOf").and_then(|v| v.as_array()) {
103 let variants: Vec<String> = any_of.iter().map(convert_schema).collect();
104 return format!("z.union([{}])", variants.join(", "));
105 }
106
107 if let Some(enum_vals) = schema.get("enum").and_then(|v| v.as_array()) {
109 let literals: Vec<String> = enum_vals
110 .iter()
111 .map(|v| match v {
112 Value::String(s) => format!("z.literal(\"{}\")", s),
113 Value::Number(n) => format!("z.literal({})", n),
114 Value::Bool(b) => format!("z.literal({})", b),
115 Value::Null => "z.null()".to_string(),
116 _ => "z.unknown()".to_string(),
117 })
118 .collect();
119 if literals.len() == 1 {
120 return literals[0].clone();
121 }
122 return format!("z.union([{}])", literals.join(", "));
123 }
124
125 let type_str = schema.get("type").and_then(|t| t.as_str()).unwrap_or("");
127
128 match type_str {
129 "string" => convert_string(schema),
130 "number" => convert_number(schema),
131 "integer" => convert_integer(schema),
132 "boolean" => "z.boolean()".to_string(),
133 "null" => "z.null()".to_string(),
134 "array" => convert_array(schema),
135 "object" => convert_object(schema),
136 _ => "z.unknown()".to_string(),
137 }
138}
139
140fn convert_string(schema: &Value) -> String {
141 let mut s = "z.string()".to_string();
142
143 if let Some(min) = schema.get("minLength").and_then(|v| v.as_u64()) {
144 s.push_str(&format!(".min({})", min));
145 }
146 if let Some(max) = schema.get("maxLength").and_then(|v| v.as_u64()) {
147 s.push_str(&format!(".max({})", max));
148 }
149 if let Some(format) = schema.get("format").and_then(|v| v.as_str()) {
150 match format {
151 "email" => s.push_str(".email()"),
152 "uri" | "url" => s.push_str(".url()"),
153 "uuid" => s.push_str(".uuid()"),
154 "ipv4" => s.push_str(".ip({ version: \"v4\" })"),
155 "ipv6" => s.push_str(".ip({ version: \"v6\" })"),
156 "date" => s.push_str(".date()"),
157 "date-time" => s.push_str(".datetime()"),
158 "time" => s.push_str(".time()"),
159 _ => { }
160 }
161 }
162 if let Some(pattern) = schema.get("pattern").and_then(|v| v.as_str()) {
163 s.push_str(&format!(".regex(/{}/)", pattern));
164 }
165
166 add_description(&mut s, schema);
167 s
168}
169
170fn convert_number(schema: &Value) -> String {
171 let mut s = "z.number()".to_string();
172 add_numeric_constraints(&mut s, schema);
173 add_description(&mut s, schema);
174 s
175}
176
177fn convert_integer(schema: &Value) -> String {
178 let mut s = "z.number().int()".to_string();
179 add_numeric_constraints(&mut s, schema);
180 add_description(&mut s, schema);
181 s
182}
183
184fn add_numeric_constraints(s: &mut String, schema: &Value) {
185 if let Some(min) = schema.get("minimum").and_then(|v| v.as_f64()) {
186 s.push_str(&format!(".min({})", format_number(min)));
187 }
188 if let Some(max) = schema.get("maximum").and_then(|v| v.as_f64()) {
189 s.push_str(&format!(".max({})", format_number(max)));
190 }
191 if let Some(gt) = schema.get("exclusiveMinimum").and_then(|v| v.as_f64()) {
192 s.push_str(&format!(".gt({})", format_number(gt)));
193 }
194 if let Some(lt) = schema.get("exclusiveMaximum").and_then(|v| v.as_f64()) {
195 s.push_str(&format!(".lt({})", format_number(lt)));
196 }
197 if let Some(mul) = schema.get("multipleOf").and_then(|v| v.as_f64()) {
198 s.push_str(&format!(".multipleOf({})", format_number(mul)));
199 }
200}
201
202fn convert_array(schema: &Value) -> String {
203 let items = schema
204 .get("items")
205 .map(convert_schema)
206 .unwrap_or_else(|| "z.unknown()".to_string());
207
208 let mut s = format!("z.array({})", items);
209
210 if let Some(min) = schema.get("minItems").and_then(|v| v.as_u64()) {
211 s.push_str(&format!(".min({})", min));
212 }
213 if let Some(max) = schema.get("maxItems").and_then(|v| v.as_u64()) {
214 s.push_str(&format!(".max({})", max));
215 }
216 if schema.get("uniqueItems").and_then(|v| v.as_bool()) == Some(true) {
217 s.push_str(" /* uniqueItems */");
218 }
219
220 add_description(&mut s, schema);
221 s
222}
223
224fn convert_object(schema: &Value) -> String {
225 let required: Vec<&str> = schema
226 .get("required")
227 .and_then(|v| v.as_array())
228 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
229 .unwrap_or_default();
230
231 let props = schema.get("properties").and_then(|v| v.as_object());
232
233 if let Some(props) = props {
234 let mut fields: Vec<String> = Vec::new();
235 for (key, prop_schema) in props {
236 let mut zod = convert_schema(prop_schema);
237 if !required.contains(&key.as_str()) {
238 zod.push_str(".optional()");
239 }
240 fields.push(format!(" {}: {}", key, zod));
241 }
242
243 let mut s = format!("z.object({{\n{}\n}})", fields.join(",\n"));
244
245 if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
247 s.push_str(".strict()");
248 }
249
250 add_description(&mut s, schema);
251 s
252 } else {
253 if let Some(additional) = schema.get("additionalProperties") {
255 if additional.is_object() {
256 let value_schema = convert_schema(additional);
257 return format!("z.record(z.string(), {})", value_schema);
258 }
259 }
260
261 let mut s = "z.object({})".to_string();
262 if schema.get("additionalProperties") != Some(&Value::Bool(false)) {
263 s.push_str(".passthrough()");
264 }
265 s
266 }
267}
268
269fn add_description(s: &mut String, schema: &Value) {
270 if let Some(desc) = schema.get("description").and_then(|v| v.as_str()) {
271 s.push_str(&format!(".describe(\"{}\")", desc.replace('"', "\\\"")));
272 }
273}
274
275fn format_number(n: f64) -> String {
276 if n == n.floor() && n.abs() < 1e15 {
277 format!("{}", n as i64)
278 } else {
279 format!("{}", n)
280 }
281}