vld-ts 0.2.0

Generate TypeScript Zod schemas from vld JSON Schemas
Documentation
//! # vld-ts — Generate TypeScript Zod schemas from JSON Schema
//!
//! Converts JSON Schemas (as produced by `vld`'s `json_schema()` methods)
//! into TypeScript [Zod](https://zod.dev/) schema code.
//!
//! # Example
//!
//! ```
//! use vld_ts::json_schema_to_zod;
//!
//! let schema = serde_json::json!({
//!     "type": "object",
//!     "required": ["name", "email"],
//!     "properties": {
//!         "name": {"type": "string", "minLength": 2, "maxLength": 50},
//!         "email": {"type": "string", "format": "email"},
//!         "age": {"type": "integer", "minimum": 0}
//!     }
//! });
//!
//! let ts = json_schema_to_zod(&schema);
//! assert!(ts.contains("z.object("));
//! assert!(ts.contains("z.string()"));
//! ```

use serde_json::Value;

/// Convert a JSON Schema to TypeScript Zod schema code.
///
/// Produces a string like `z.object({ name: z.string().min(2), ... })`.
///
/// Supports types: `string`, `number`, `integer`, `boolean`, `array`,
/// `object`, `null`, and combinators (`oneOf`, `allOf`, `anyOf`).
pub fn json_schema_to_zod(schema: &Value) -> String {
    convert_schema(schema)
}

/// Generate named Zod schemas for multiple types.
///
/// # Example
///
/// ```
/// use vld_ts::generate_zod_file;
///
/// let schemas = vec![
///     ("User", serde_json::json!({"type": "object", "required": ["name"], "properties": {"name": {"type": "string"}}})),
///     ("Email", serde_json::json!({"type": "string", "format": "email"})),
/// ];
///
/// let ts = generate_zod_file(&schemas);
/// assert!(ts.contains("export const UserSchema"));
/// assert!(ts.contains("export const EmailSchema"));
/// ```
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 {
    // Handle combinators first
    if let Some(one_of) = schema.get("oneOf").and_then(|v| v.as_array()) {
        // Check if it's a nullable pattern: [inner, {"type": "null"}]
        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(", "));
    }

    // Handle enum
    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(", "));
    }

    // Get the type
    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()"),
            _ => { /* skip unknown formats */ }
        }
    }
    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"));

        // additionalProperties
        if schema.get("additionalProperties") == Some(&Value::Bool(false)) {
            s.push_str(".strict()");
        }

        add_description(&mut s, schema);
        s
    } else {
        // Record-like schema
        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)
    }
}