jsonforge 0.1.0

A Rust procedural macro for generating JSON schema validators from Rust types
Documentation
use serde_json::Value;

use crate::error::{ForgeError, ForgeResult};

use super::types::{FieldSchema, SchemaType, StructSchema, TopLevel};
use crate::codegen::util::sanitize_ident;
use crate::rename::RenameRule;

/// Entry point: infer the [`TopLevel`] shape from the root JSON value.
pub fn infer_top_level(root: &Value) -> ForgeResult<TopLevel> {
    match root {
        Value::Object(map) => {
            if map.is_empty() {
                return Err(ForgeError::call_site(
                    "JSON object is empty; cannot infer schema",
                ));
            }

            // Detect if this is a string-keyed record map (uniform values).
            let first_val = map.values().next().unwrap();
            let all_objects = map.values().all(|v| v.is_object());

            if all_objects && map.len() > 1 {
                // Merge struct schemas across all entries.
                let schema = merge_object_schemas(map.values())?;
                Ok(TopLevel::Map { entry: schema })
            } else if first_val.is_object() {
                let obj = first_val.as_object().unwrap();
                Ok(TopLevel::Struct(infer_struct_schema(obj)?))
            } else {
                Err(ForgeError::call_site(
                    "top-level JSON object values must all be JSON objects to infer a struct schema",
                ))
            }
        },
        Value::Array(arr) => {
            if arr.is_empty() {
                return Err(ForgeError::call_site(
                    "JSON array is empty; cannot infer schema",
                ));
            }
            let mut ty = infer_value(&arr[0])?;
            for val in arr.iter().skip(1) {
                ty = merge_types(ty, infer_value(val)?);
            }
            Ok(TopLevel::Array { entry: ty })
        },
        _ => Err(ForgeError::call_site(
            "top-level JSON must be an object or array",
        )),
    }
}

/// Infer a [`SchemaType`] from a single JSON value.
pub fn infer_value(value: &Value) -> ForgeResult<SchemaType> {
    match value {
        Value::Null => Ok(SchemaType::Optional(Box::new(SchemaType::Str))),
        Value::Bool(_) => Ok(SchemaType::Bool),
        Value::Number(n) => {
            if n.is_f64() && !n.is_i64() && !n.is_u64() {
                Ok(SchemaType::Float)
            } else {
                Ok(SchemaType::Integer)
            }
        },
        Value::String(_) => Ok(SchemaType::Str),
        Value::Array(arr) => {
            if arr.is_empty() {
                return Ok(SchemaType::Array(Box::new(SchemaType::Str)));
            }
            let mut inner = infer_value(&arr[0])?;
            for v in arr.iter().skip(1) {
                inner = merge_types(inner, infer_value(v)?);
            }
            Ok(SchemaType::Array(Box::new(inner)))
        },
        Value::Object(obj) => Ok(SchemaType::Struct(infer_struct_schema(obj)?)),
    }
}

/// Infer a [`StructSchema`] from a JSON object.
fn infer_struct_schema(obj: &serde_json::Map<String, Value>) -> ForgeResult<StructSchema> {
    let mut fields = Vec::with_capacity(obj.len());
    for (key, val) in obj {
        let ty = infer_value(val)?;
        fields.push(FieldSchema {
            rust_name: sanitize_ident(key),
            json_key: key.clone(),
            ty,
        });
    }
    Ok(StructSchema { fields })
}

/// Merge struct schemas from multiple JSON objects (same "template"), reconciling nullable fields.
fn merge_object_schemas<'a>(values: impl Iterator<Item = &'a Value>) -> ForgeResult<StructSchema> {
    let mut merged: Option<StructSchema> = None;

    for val in values {
        let obj = val
            .as_object()
            .ok_or_else(|| ForgeError::call_site("expected a JSON object in map values"))?;
        let schema = infer_struct_schema(obj)?;

        merged = Some(match merged.take() {
            None => schema,
            Some(existing) => reconcile_structs(existing, schema),
        });
    }

    merged.ok_or_else(|| ForgeError::call_site("no values to merge"))
}

/// Reconcile two struct schemas, promoting fields to `Option` when missing or typed differently.
fn reconcile_structs(mut base: StructSchema, other: StructSchema) -> StructSchema {
    // Collect keys present in `other` before consuming it.
    let other_keys: std::collections::HashSet<String> =
        other.fields.iter().map(|f| f.json_key.clone()).collect();

    // Add or merge fields from `other` into `base`.
    for other_field in other.fields {
        if let Some(base_field) = base
            .fields
            .iter_mut()
            .find(|f| f.json_key == other_field.json_key)
        {
            base_field.ty = merge_types(base_field.ty.clone(), other_field.ty);
        } else {
            // Field exists in `other` but not in `base` → optional.
            let ty = make_optional(other_field.ty);
            base.fields.push(FieldSchema {
                json_key: other_field.json_key,
                rust_name: other_field.rust_name,
                ty,
            });
        }
    }

    // Fields present in `base` but absent from `other` become optional.
    for base_field in &mut base.fields {
        if !other_keys.contains(&base_field.json_key) {
            base_field.ty = make_optional(base_field.ty.clone());
        }
    }

    base
}

/// Merge two [`SchemaType`]s into a compatible type.
pub fn merge_types(a: SchemaType, b: SchemaType) -> SchemaType {
    if a == b {
        return a;
    }
    match (a, b) {
        // null on either side → Option
        (SchemaType::Optional(inner), other) | (other, SchemaType::Optional(inner)) => {
            let merged = merge_types(*inner, other);
            SchemaType::Optional(Box::new(merged))
        },
        // int / float unification
        (SchemaType::Integer, SchemaType::Float) | (SchemaType::Float, SchemaType::Integer) => {
            SchemaType::Float
        },
        // array inner type reconciliation
        (SchemaType::Array(a), SchemaType::Array(b)) => {
            SchemaType::Array(Box::new(merge_types(*a, *b)))
        },
        // struct reconciliation
        (SchemaType::Struct(a), SchemaType::Struct(b)) => {
            SchemaType::Struct(reconcile_structs(a, b))
        },
        // Incompatible → fall back to String
        _ => SchemaType::Str,
    }
}

/// Apply a [`RenameRule`] to every `rust_name` in the schema tree.
///
/// `json_key` is left unchanged so lookups still work against the original JSON.
/// After conversion the name is also passed through [`sanitize_ident`] to
/// ensure it remains a valid Rust identifier.
pub fn apply_rename(top: TopLevel, rule: RenameRule) -> TopLevel {
    match top {
        TopLevel::Map { entry } => TopLevel::Map {
            entry: rename_struct(entry, rule),
        },
        TopLevel::Array { entry } => TopLevel::Array {
            entry: rename_type(entry, rule),
        },
        TopLevel::Struct(s) => TopLevel::Struct(rename_struct(s, rule)),
    }
}

fn rename_struct(
    mut schema: super::types::StructSchema,
    rule: RenameRule,
) -> super::types::StructSchema {
    for field in &mut schema.fields {
        let raw = rule.json_key_to_rust_name(&field.json_key);
        field.rust_name = sanitize_ident(&raw);
        field.ty = rename_type(std::mem::replace(&mut field.ty, SchemaType::Str), rule);
    }
    schema
}

fn rename_type(ty: SchemaType, rule: RenameRule) -> SchemaType {
    match ty {
        SchemaType::Struct(s) => SchemaType::Struct(rename_struct(s, rule)),
        SchemaType::Array(inner) => SchemaType::Array(Box::new(rename_type(*inner, rule))),
        SchemaType::Optional(inner) => SchemaType::Optional(Box::new(rename_type(*inner, rule))),
        other => other,
    }
}

/// Wrap a type in `Option<T>` if not already optional.
fn make_optional(ty: SchemaType) -> SchemaType {
    match ty {
        SchemaType::Optional(_) => ty,
        other => SchemaType::Optional(Box::new(other)),
    }
}