tf-types 0.1.8

Core semantic types, traits, and schemas powering the TrustForge protocol.
Documentation
#![allow(clippy::for_kv_map)]
//! Rust side of the cross-language parity suite.
//!
//! Reads `conformance/parity.yaml` (generated by `tf-schema parity`) and
//! validates each fixture against its JSON Schema using the `jsonschema`
//! crate. Both the TypeScript AJV validator and the Rust `jsonschema`
//! validator must agree on every vector. Any disagreement is a real parity
//! failure — the kind we want to catch before Phase 2 signs anything.

use std::{collections::HashMap, fs, path::PathBuf};

use jsonschema::JSONSchema;
use serde::Deserialize;
use serde_json::Value;

#[derive(Deserialize)]
struct ParityFile {
    vectors: Vec<ParityEntry>,
}

#[derive(Deserialize, Debug)]
struct ParityEntry {
    schema: String,
    fixture: String,
    expect: String,
}

fn repo_root() -> PathBuf {
    PathBuf::from(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
}

fn yaml_to_json(yaml: &str) -> Value {
    let v: serde_json::Value = tf_types::yaml::from_str(yaml).expect("parse yaml");
    serde_json::to_value(v).expect("yaml->json")
}

fn compile_schemas() -> HashMap<String, JSONSchema> {
    // jsonschema 0.18 resolves $refs relative to each schema's $id by default,
    // but we have cross-file $refs (e.g. "_common.schema.json#/$defs/...").
    // The cleanest approach is to pre-bundle each schema via a simple walker
    // that inlines $defs from _common.schema.json. To keep this test simple
    // we use a custom resolver via the options API.
    let schemas_dir = repo_root().join("schemas");
    let mut registry: HashMap<String, Value> = HashMap::new();
    for entry in fs::read_dir(&schemas_dir).expect("read schemas/") {
        let e = entry.expect("dirent");
        let path = e.path();
        if path.extension().and_then(|s| s.to_str()) != Some("json") {
            continue;
        }
        let name = path.file_name().unwrap().to_string_lossy().to_string();
        if !name.ends_with(".schema.json") {
            continue;
        }
        let raw = fs::read_to_string(&path).expect("read schema");
        let v: Value = serde_json::from_str(&raw).expect("parse schema");
        registry.insert(name, v);
    }

    let mut out = HashMap::new();
    for (name, _schema) in &registry {
        if !name.ends_with(".schema.json") || name == "_common.schema.json" {
            continue;
        }
        let schema_name = name.trim_end_matches(".schema.json").to_string();
        let bundled = bundle(&schema_name, &registry);
        let compiled = JSONSchema::options()
            .compile(&bundled)
            .unwrap_or_else(|e| panic!("compile {}: {}", schema_name, e));
        out.insert(schema_name, compiled);
    }
    out
}

/// Minimal bundler: resolve every $ref that points outside the current
/// document into an inline entry under $defs. Mirrors tools/tf-schema bundle.
fn bundle(root_name: &str, registry: &HashMap<String, Value>) -> Value {
    let mut bundled_defs = serde_json::Map::new();
    let root = &registry[&format!("{}.schema.json", root_name)];
    let resolved = resolve(root, root_name, root_name, registry, &mut bundled_defs);
    let mut obj = resolved.as_object().cloned().unwrap_or_default();
    let mut defs = obj
        .remove("$defs")
        .and_then(|v| v.as_object().cloned())
        .unwrap_or_default();
    for (k, v) in bundled_defs {
        defs.entry(k).or_insert(v);
    }
    if !defs.is_empty() {
        obj.insert("$defs".to_string(), Value::Object(defs));
    }
    Value::Object(obj)
}

/// Resolve every $ref in `node`.
/// `current_file` = the schema the node currently belongs to (same-file refs
/// inside imported schemas must be rewritten to cross-file def names).
/// `root_name` = the top-level schema being bundled.
fn resolve(
    node: &Value,
    current_file: &str,
    root_name: &str,
    registry: &HashMap<String, Value>,
    bundled: &mut serde_json::Map<String, Value>,
) -> Value {
    match node {
        Value::Array(xs) => Value::Array(
            xs.iter()
                .map(|v| resolve(v, current_file, root_name, registry, bundled))
                .collect(),
        ),
        Value::Object(map) => {
            if let Some(r) = map.get("$ref").and_then(|v| v.as_str()) {
                let (file_part, fragment) = match r.split_once('#') {
                    Some((f, frag)) => (f, frag),
                    None => (r, ""),
                };
                if file_part.is_empty() {
                    // Same-file ref.
                    if current_file == root_name {
                        return Value::Object(map.clone());
                    }
                    // Inside an imported schema → rewrite to cross-file def name.
                    let def_name = fragment.strip_prefix("/$defs/").unwrap_or(fragment);
                    let key = format!("{}_{}", to_pascal(current_file), def_name);
                    ensure_def(current_file, def_name, &key, registry, bundled);
                    let mut out = serde_json::Map::new();
                    out.insert(
                        "$ref".to_string(),
                        Value::String(format!("#/$defs/{}", key)),
                    );
                    return Value::Object(out);
                }
                let schema_name_json = file_part;
                let schema_name = schema_name_json.trim_end_matches(".schema.json");
                if let Some(def_name) = fragment.strip_prefix("/$defs/") {
                    let key = format!("{}_{}", to_pascal(schema_name), def_name);
                    ensure_def(schema_name, def_name, &key, registry, bundled);
                    let mut out = serde_json::Map::new();
                    out.insert(
                        "$ref".to_string(),
                        Value::String(format!("#/$defs/{}", key)),
                    );
                    return Value::Object(out);
                }
                // Whole-schema ref
                let root_key = format!("{}_root", to_pascal(schema_name));
                ensure_root(schema_name, &root_key, registry, bundled);
                let mut out = serde_json::Map::new();
                out.insert(
                    "$ref".to_string(),
                    Value::String(format!("#/$defs/{}", root_key)),
                );
                return Value::Object(out);
            }
            let mut out = serde_json::Map::new();
            for (k, v) in map {
                if k == "$id" || k == "$schema" {
                    out.insert(k.clone(), v.clone());
                    continue;
                }
                out.insert(
                    k.clone(),
                    resolve(v, current_file, root_name, registry, bundled),
                );
            }
            Value::Object(out)
        }
        _ => node.clone(),
    }
}

fn ensure_def(
    schema_name: &str,
    def_name: &str,
    key: &str,
    registry: &HashMap<String, Value>,
    bundled: &mut serde_json::Map<String, Value>,
) {
    if bundled.contains_key(key) {
        return;
    }
    let target_doc = registry
        .get(&format!("{}.schema.json", schema_name))
        .unwrap_or_else(|| panic!("unknown schema ref target: {}", schema_name));
    let defs = target_doc.get("$defs").and_then(|v| v.as_object());
    let def = defs
        .and_then(|m| m.get(def_name))
        .unwrap_or_else(|| panic!("unknown $def: {}#/$defs/{}", schema_name, def_name));
    bundled.insert(key.to_string(), Value::Null);
    let resolved = resolve(def, schema_name, "__root_placeholder__", registry, bundled);
    bundled.insert(key.to_string(), resolved);
}

fn ensure_root(
    schema_name: &str,
    root_key: &str,
    registry: &HashMap<String, Value>,
    bundled: &mut serde_json::Map<String, Value>,
) {
    if bundled.contains_key(root_key) {
        return;
    }
    let target_doc = registry
        .get(&format!("{}.schema.json", schema_name))
        .unwrap_or_else(|| panic!("unknown schema ref target: {}", schema_name));
    bundled.insert(root_key.to_string(), Value::Null);
    let mut copy = target_doc.as_object().cloned().unwrap_or_default();
    copy.remove("$id");
    copy.remove("$schema");
    let defs_map = copy
        .remove("$defs")
        .and_then(|v| v.as_object().cloned())
        .unwrap_or_default();
    let resolved = resolve(
        &Value::Object(copy),
        schema_name,
        "__root_placeholder__",
        registry,
        bundled,
    );
    bundled.insert(root_key.to_string(), resolved);
    for (def_name, def) in defs_map {
        let key = format!("{}_{}", to_pascal(schema_name), def_name);
        if !bundled.contains_key(&key) {
            bundled.insert(key.clone(), Value::Null);
            let resolved = resolve(&def, schema_name, "__root_placeholder__", registry, bundled);
            bundled.insert(key, resolved);
        }
    }
}

fn to_pascal(s: &str) -> String {
    s.split(|c: char| !c.is_alphanumeric())
        .filter(|p| !p.is_empty())
        .map(|p| {
            let mut chars = p.chars();
            match chars.next() {
                Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
                None => String::new(),
            }
        })
        .collect()
}

#[test]
fn parity_against_ts() {
    let parity_path = repo_root().join("conformance").join("parity.yaml");
    let raw = fs::read_to_string(&parity_path)
        .unwrap_or_else(|e| panic!("read {}: {}", parity_path.display(), e));
    let file: ParityFile = tf_types::yaml::from_str(&raw).expect("parse parity.yaml");

    let validators = compile_schemas();
    let mut mismatches: Vec<String> = Vec::new();

    for v in &file.vectors {
        let validator = validators
            .get(&v.schema)
            .unwrap_or_else(|| panic!("no validator for schema {}", v.schema));
        let fixture_path = repo_root().join(&v.fixture);
        let yaml = fs::read_to_string(&fixture_path)
            .unwrap_or_else(|e| panic!("read {}: {}", fixture_path.display(), e));
        let doc = yaml_to_json(&yaml);
        let verdict = if validator.is_valid(&doc) {
            "valid"
        } else {
            "invalid"
        };
        if verdict != v.expect {
            let detail = if verdict == "invalid" {
                let errors: Vec<String> = validator
                    .validate(&doc)
                    .err()
                    .into_iter()
                    .flat_map(|errs| {
                        errs.map(|e| format!("{}@{}", e, e.instance_path))
                            .collect::<Vec<_>>()
                    })
                    .collect();
                format!(" [{}]", errors.join("; "))
            } else {
                String::new()
            };
            mismatches.push(format!(
                "{} ({}): expected {}, got {}{}",
                v.fixture, v.schema, v.expect, verdict, detail
            ));
        }
    }

    if !mismatches.is_empty() {
        panic!(
            "{} parity mismatches:\n  {}",
            mismatches.len(),
            mismatches.join("\n  ")
        );
    }
}