Skip to content

Strict Output Validator — Rust source

Check a JSON Schema against OpenAI structured-outputs strict mode rules — open objects, missing required keys, unsupported keywords — before the API rejects it. 100% client-side.

This is the Rust implementation — the same logic the interactive tool runs, in a shareable, citable form.

//! Strict Output Validator — check a JSON Schema against OpenAI structured-
//! outputs strict-mode rules, so it fails here instead of at the API.
//! Port of src/lib/strictOutputValidator.ts
//!
//! Language: Rust (edition 2021, standard library only)
//! Source:   CosmoDev polyglot showcase port of the Strict Output Validator
//!           tool (slug: strict-output-validator), ported from
//!           src/lib/strictOutputValidator.ts (the canonical TypeScript
//!           implementation). javascript.js in this set carries the same
//!           port; this file mirrors it for Rust.
//! Tool page: https://dev.cosmolabs.org/tools/strict-output-validator
//!
//! The Rust standard library ships no JSON parser, so — unlike the
//! TypeScript/JavaScript/PHP/Python ports, which lean on a stdlib JSON
//! engine — this file includes a small hand-written recursive-descent JSON
//! reader (std only, no serde and no external crates). Object members are
//! kept in a Vec<(String, Json)> so key order — which the walker's issue
//! order depends on — is preserved exactly as written.
//!
//! Rules (2026 OpenAI strict mode):
//!   R1 root must be type "object"            (validate_strict_root)
//!   R2 every object node needs additionalProperties: false
//!   R3 every key in properties must be listed in required (no optional keys)
//!   R4 required must not name keys absent from properties
//!   R5 only the supported type values / keywords may appear

#![forbid(unsafe_code)]

// ---------------------------------------------------------------------------
// JSON value + parser (std only)
// ---------------------------------------------------------------------------

/// A parsed JSON value. Object members preserve document order.
#[derive(Debug, Clone, PartialEq)]
pub enum Json {
    Null,
    Bool(bool),
    Num(f64),
    Str(String),
    Arr(Vec<Json>),
    Obj(Vec<(String, Json)>),
}

impl Json {
    /// True for JSON objects — the analogue of the ports' isObj().
    pub fn is_obj(&self) -> bool {
        matches!(self, Json::Obj(_))
    }

    /// The object members, or `None` on any other value.
    pub fn as_obj(&self) -> Option<&[(String, Json)]> {
        match self {
            Json::Obj(members) => Some(members),
            _ => None,
        }
    }

    /// The array items, or `None` on any other value.
    pub fn as_arr(&self) -> Option<&[Json]> {
        match self {
            Json::Arr(items) => Some(items),
            _ => None,
        }
    }

    /// Look up a key in a JSON object (`None` otherwise) — the analogue of
    /// the dynamic ports' `node[key]`.
    pub fn get(&self, key: &str) -> Option<&Json> {
        self.as_obj()?.iter().find(|(k, _)| k == key).map(|(_, v)| v)
    }

    /// Whether a key is present in a JSON object.
    pub fn has(&self, key: &str) -> bool {
        self.get(key).is_some()
    }

    /// Compact JSON serialization — the analogue of JSON.stringify.
    pub fn stringify(&self) -> String {
        match self {
            Json::Null => "null".to_string(),
            Json::Bool(b) => b.to_string(),
            Json::Num(n) => {
                if n.fract() == 0.0 && n.abs() < 1e15 {
                    format!("{}", *n as i64)
                } else {
                    format!("{n}")
                }
            }
            Json::Str(s) => {
                let mut out = String::with_capacity(s.len() + 2);
                out.push('"');
                for c in s.chars() {
                    match c {
                        '"' => out.push_str("\\\""),
                        '\\' => out.push_str("\\\\"),
                        '\n' => out.push_str("\\n"),
                        '\r' => out.push_str("\\r"),
                        '\t' => out.push_str("\\t"),
                        c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
                        c => out.push(c),
                    }
                }
                out.push('"');
                out
            }
            Json::Arr(items) => {
                let inner: Vec<String> = items.iter().map(Json::stringify).collect();
                format!("[{}]", inner.join(","))
            }
            Json::Obj(members) => {
                let inner: Vec<String> = members
                    .iter()
                    .map(|(k, v)| format!("{}:{}", Json::Str(k.clone()).stringify(), v.stringify()))
                    .collect();
                format!("{{{}}}", inner.join(","))
            }
        }
    }
}

/// Parse JSON text, or return a short human-readable error describing where
/// parsing stopped. The dynamic ports get this message from their JSON
/// engine; here it comes from the parser below.
pub fn parse_json(text: &str) -> Result<Json, String> {
    let mut p = Parser { bytes: text.as_bytes(), pos: 0 };
    p.skip_ws();
    let value = p.value()?;
    p.skip_ws();
    if p.pos != p.bytes.len() {
        return Err(format!("unexpected trailing characters at offset {}", p.pos));
    }
    Ok(value)
}

struct Parser<'a> {
    bytes: &'a [u8],
    pos: usize,
}

impl<'a> Parser<'a> {
    fn skip_ws(&mut self) {
        while let Some(b) = self.bytes.get(self.pos) {
            if matches!(b, b' ' | b'\t' | b'\n' | b'\r') {
                self.pos += 1;
            } else {
                break;
            }
        }
    }

    fn peek(&self) -> Option<u8> {
        self.bytes.get(self.pos).copied()
    }

    fn err<T>(&self, what: &str) -> Result<T, String> {
        Err(format!("{what} at offset {}", self.pos))
    }

    fn value(&mut self) -> Result<Json, String> {
        match self.peek() {
            Some(b'{') => self.object(),
            Some(b'[') => self.array(),
            Some(b'"') => Ok(Json::Str(self.string()?)),
            Some(b't') => self.literal("true", Json::Bool(true)),
            Some(b'f') => self.literal("false", Json::Bool(false)),
            Some(b'n') => self.literal("null", Json::Null),
            Some(b'-' | b'0'..=b'9') => self.number(),
            _ => self.err("unexpected character"),
        }
    }

    fn literal(&mut self, word: &str, value: Json) -> Result<Json, String> {
        if self.bytes[self.pos..].starts_with(word.as_bytes()) {
            self.pos += word.len();
            Ok(value)
        } else {
            self.err("invalid literal")
        }
    }

    fn object(&mut self) -> Result<Json, String> {
        self.pos += 1; // '{'
        let mut members = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b'}') {
            self.pos += 1;
            return Ok(Json::Obj(members));
        }
        loop {
            self.skip_ws();
            if self.peek() != Some(b'"') {
                return self.err("expected object key");
            }
            let key = self.string()?;
            self.skip_ws();
            if self.peek() != Some(b':') {
                return self.err("expected ':' after object key");
            }
            self.pos += 1;
            self.skip_ws();
            let value = self.value()?;
            members.push((key, value));
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b'}') => {
                    self.pos += 1;
                    return Ok(Json::Obj(members));
                }
                _ => return self.err("expected ',' or '}' in object"),
            }
        }
    }

    fn array(&mut self) -> Result<Json, String> {
        self.pos += 1; // '['
        let mut items = Vec::new();
        self.skip_ws();
        if self.peek() == Some(b']') {
            self.pos += 1;
            return Ok(Json::Arr(items));
        }
        loop {
            self.skip_ws();
            items.push(self.value()?);
            self.skip_ws();
            match self.peek() {
                Some(b',') => self.pos += 1,
                Some(b']') => {
                    self.pos += 1;
                    return Ok(Json::Arr(items));
                }
                _ => return self.err("expected ',' or ']' in array"),
            }
        }
    }

    fn string(&mut self) -> Result<String, String> {
        self.pos += 1; // '"'
        let mut out = String::new();
        loop {
            let b = match self.peek() {
                Some(b) => b,
                None => return self.err("unterminated string"),
            };
            match b {
                b'"' => {
                    self.pos += 1;
                    return Ok(out);
                }
                b'\\' => {
                    self.pos += 1;
                    let esc = match self.peek() {
                        Some(e) => e,
                        None => return self.err("unterminated escape"),
                    };
                    self.pos += 1;
                    match esc {
                        b'"' => out.push('"'),
                        b'\\' => out.push('\\'),
                        b'/' => out.push('/'),
                        b'b' => out.push('\u{0008}'),
                        b'f' => out.push('\u{000C}'),
                        b'n' => out.push('\n'),
                        b'r' => out.push('\r'),
                        b't' => out.push('\t'),
                        b'u' => {
                            let hi = self.hex4()?;
                            // Surrogate pairs: combine with a following \uXXXX.
                            let ch = if (0xD800..0xDC00).contains(&hi) {
                                if self.bytes[self.pos..].starts_with(b"\\u") {
                                    self.pos += 2;
                                    let lo = self.hex4()?;
                                    if !(0xDC00..0xE000).contains(&lo) {
                                        return self.err("invalid low surrogate");
                                    }
                                    let cp = 0x10000
                                        + ((hi - 0xD800) << 10)
                                        + (lo - 0xDC00);
                                    char::from_u32(cp)
                                        .ok_or_else(|| "invalid surrogate pair".to_string())?
                                } else {
                                    return self.err("unpaired surrogate");
                                }
                            } else {
                                char::from_u32(hi)
                                    .ok_or_else(|| "invalid \\u escape".to_string())?
                            };
                            out.push(ch);
                        }
                        _ => return self.err("invalid escape"),
                    }
                }
                _ if b < 0x20 => return self.err("control character in string"),
                _ => {
                    // Copy one UTF-8 encoded char (multi-byte safe).
                    let start = self.pos;
                    let len = utf8_len(b);
                    let end = start + len;
                    if end > self.bytes.len() {
                        return self.err("truncated UTF-8 sequence");
                    }
                    match std::str::from_utf8(&self.bytes[start..end]) {
                        Ok(s) => out.push_str(s),
                        Err(_) => return self.err("invalid UTF-8"),
                    }
                    self.pos = end;
                }
            }
        }
    }

    fn hex4(&mut self) -> Result<u32, String> {
        if self.pos + 4 > self.bytes.len() {
            return self.err("truncated \\u escape");
        }
        let mut v = 0u32;
        for _ in 0..4 {
            let d = match self.bytes[self.pos] {
                b @ b'0'..=b'9' => (b - b'0') as u32,
                b @ b'a'..=b'f' => (b - b'a' + 10) as u32,
                b @ b'A'..=b'F' => (b - b'A' + 10) as u32,
                _ => return self.err("invalid \\u escape"),
            };
            v = v * 16 + d;
            self.pos += 1;
        }
        Ok(v)
    }

    fn number(&mut self) -> Result<Json, String> {
        let start = self.pos;
        if self.peek() == Some(b'-') {
            self.pos += 1;
        }
        while matches!(self.peek(), Some(b'0'..=b'9')) {
            self.pos += 1;
        }
        if self.peek() == Some(b'.') {
            self.pos += 1;
            while matches!(self.peek(), Some(b'0'..=b'9')) {
                self.pos += 1;
            }
        }
        if matches!(self.peek(), Some(b'e' | b'E')) {
            self.pos += 1;
            if matches!(self.peek(), Some(b'+' | b'-')) {
                self.pos += 1;
            }
            while matches!(self.peek(), Some(b'0'..=b'9')) {
                self.pos += 1;
            }
        }
        let text = std::str::from_utf8(&self.bytes[start..self.pos])
            .map_err(|_| "invalid UTF-8".to_string())?;
        text.parse::<f64>()
            .map(Json::Num)
            .map_err(|_| format!("invalid number \"{text}\""))
    }
}

fn utf8_len(first: u8) -> usize {
    match first {
        0x00..=0x7F => 1,
        0xC0..=0xDF => 2,
        0xE0..=0xEF => 3,
        _ => 4,
    }
}

// ---------------------------------------------------------------------------
// Validation
// ---------------------------------------------------------------------------

/// One rule violation — the Rust spelling of the TypeScript StrictIssue.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StrictIssue {
    pub path: String,
    pub rule: &'static str,
    pub message: String,
}

/// Schema shape tally accumulated during the walk.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct Counts {
    pub objects: usize,
    pub properties: usize,
    pub enums: usize,
}

/// The whole verdict: ok plus every issue and the shape counts.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct StrictReport {
    pub ok: bool,
    pub issues: Vec<StrictIssue>,
    pub counts: Counts,
}

/// Types strict mode supports.
pub const SUPPORTED_TYPES: [&str; 6] =
    ["object", "array", "string", "number", "integer", "boolean"];

/// Keywords strict mode understands per-node. Everything else is flagged.
/// ("allOf" is accepted only as single-element; checked in the walker.)
pub const SUPPORTED_KEYWORDS: [&str; 17] = [
    "type",
    "description",
    "title",
    "properties",
    "required",
    "additionalProperties",
    "items",
    "enum",
    "const",
    "anyOf",
    "allOf",
    "$ref",
    "$defs",
    "definitions",
    "format",
    "nullable",
    "default",
];

/// Validate a pre-parsed schema against the strict-mode structural rules.
/// R1 (root must be an object type) is NOT checked here — use
/// [`validate_strict_root`] for that. A non-object value is reported as a
/// single `invalid-schema` issue.
pub fn validate_strict_schema(schema: &Json) -> StrictReport {
    let mut v = Validator { issues: Vec::new(), counts: Counts::default() };
    if !schema.is_obj() {
        v.issues.push(StrictIssue {
            path: "$".to_string(),
            rule: "invalid-schema",
            message: "Schema must be a JSON object.".to_string(),
        });
        return StrictReport { ok: false, issues: v.issues, counts: v.counts };
    }
    v.walk(schema, "$");
    StrictReport { ok: v.issues.is_empty(), issues: v.issues, counts: v.counts }
}

/// String entry point mirroring the TypeScript `input: unknown`: parse JSON
/// text, then validate. A parse failure is reported as a single
/// `invalid-schema` issue (`Not valid JSON: …`).
pub fn validate_strict_schema_from_str(text: &str) -> StrictReport {
    match parse_json(text) {
        Ok(schema) => validate_strict_schema(&schema),
        Err(e) => StrictReport {
            ok: false,
            issues: vec![StrictIssue {
                path: "$".to_string(),
                rule: "invalid-schema",
                message: format!("Not valid JSON: {e}"),
            }],
            counts: Counts::default(),
        },
    }
}

/// Whole-report entry point on a pre-parsed schema: everything
/// [`validate_strict_schema`] checks, plus R1 — the root schema must be
/// type "object" (strict mode cannot return a bare scalar or array). The
/// root issue is unshifted to the front.
pub fn validate_strict_root(schema: &Json) -> StrictReport {
    let mut report = validate_strict_schema(schema);
    if schema.is_obj() && !matches!(schema.get("type"), Some(Json::Str(t)) if t == "object") {
        report.issues.insert(
            0,
            StrictIssue {
                path: "$".to_string(),
                rule: "root-not-object",
                message: "The root schema must be type \"object\" — strict mode cannot return a bare scalar or array.".to_string(),
            },
        );
        report.ok = false;
    }
    report
}

/// String entry point mirroring the TypeScript `input: unknown`: parse JSON
/// text, then run [`validate_strict_root`]. A parse failure short-circuits
/// with the `invalid-schema` report from [`validate_strict_schema_from_str`].
pub fn validate_strict_root_from_str(text: &str) -> StrictReport {
    match parse_json(text) {
        Ok(schema) => validate_strict_root(&schema),
        Err(_) => validate_strict_schema_from_str(text),
    }
}

struct Validator {
    issues: Vec<StrictIssue>,
    counts: Counts,
}

impl Validator {
    fn push(&mut self, path: String, rule: &'static str, message: String) {
        self.issues.push(StrictIssue { path, rule, message });
    }

    /// Flag every keyword strict mode does not understand.
    fn unsupported_keywords(&mut self, node: &Json, path: &str) {
        for (key, _) in node.as_obj().unwrap_or(&[]) {
            if !SUPPORTED_KEYWORDS.contains(&key.as_str()) {
                self.push(
                    path.to_string(),
                    "unsupported-keyword",
                    format!(
                        "\"{key}\" is not supported in strict mode — remove it or express the constraint another way."
                    ),
                );
            }
        }
    }

    /// Depth-first walk emitting issues and accumulating counts.
    fn walk(&mut self, node: &Json, path: &str) {
        self.unsupported_keywords(node, path);

        let type_ = node.get("type");
        // 'null' is only expressible inside a type array (the nullable form).
        let is_nullable_form = matches!(type_, Some(Json::Arr(_)));
        // In a dynamic language the type value fans out to a list of tags;
        // here the same check runs over either the array or the lone string.
        let check_type = |t: &Json, v: &mut Validator| {
            let supported = match t {
                Json::Str(s) => {
                    SUPPORTED_TYPES.contains(&s.as_str())
                        || (is_nullable_form && s == "null")
                }
                _ => false,
            };
            if !supported {
                v.push(
                    path.to_string(),
                    "unsupported-type",
                    format!(
                        "type {} is not supported — strict mode allows object, array, string, number, integer, boolean (null only inside a type array).",
                        t.stringify()
                    ),
                );
            }
        };
        match type_ {
            Some(Json::Arr(items)) => {
                for t in items {
                    check_type(t, self);
                }
            }
            Some(t @ Json::Str(_)) => check_type(t, self),
            _ => {}
        }

        // allOf is accepted only as a single-element wrapper.
        if let Some(Json::Arr(items)) = node.get("allOf") {
            if items.len() != 1 {
                self.push(
                    path.to_string(),
                    "unsupported-keyword",
                    "allOf is supported only with exactly one subschema (use anyOf for unions).".to_string(),
                );
            }
        }

        let is_object_node = matches!(node.get("type"), Some(Json::Str(t)) if t == "object")
            || node.has("properties")
            || node.has("required");
        if is_object_node {
            self.counts.objects += 1;
            if !matches!(node.get("additionalProperties"), Some(Json::Bool(false))) {
                self.push(
                    path.to_string(),
                    "missing-additional-properties",
                    "Object needs \"additionalProperties\": false — strict mode rejects open objects.".to_string(),
                );
            }
            let props = node.get("properties").and_then(Json::as_obj).unwrap_or(&[]);
            let required = node.get("required").and_then(Json::as_arr).unwrap_or(&[]);
            self.counts.properties += props.len();
            for (key, _) in props {
                let listed = required
                    .iter()
                    .any(|r| matches!(r, Json::Str(s) if s == key));
                if !listed {
                    self.push(
                        format!("{path}.required"),
                        "property-not-required",
                        format!(
                            "\"{key}\" is defined in properties but missing from required — strict mode requires every property."
                        ),
                    );
                }
            }
            for r in required {
                if let Json::Str(key) = r {
                    if !props.iter().any(|(k, _)| k == key) {
                        self.push(
                            format!("{path}.required"),
                            "required-not-property",
                            format!("\"{key}\" is required but has no definition in properties."),
                        );
                    }
                }
            }
            for (key, sub) in props {
                if sub.is_obj() {
                    self.walk(sub, &format!("{path}.properties.{key}"));
                }
            }
        }

        if let Some(items) = node.get("items") {
            if items.is_obj() {
                self.walk(items, &format!("{path}.items"));
            }
        }
        if matches!(node.get("enum"), Some(Json::Arr(_))) {
            self.counts.enums += 1;
        }
        for list_key in ["anyOf", "oneOf", "allOf"] {
            if let Some(Json::Arr(list)) = node.get(list_key) {
                if list_key == "oneOf" {
                    self.push(
                        format!("{path}.{list_key}"),
                        "unsupported-keyword",
                        "oneOf is not supported — strict mode unions are expressed with anyOf.".to_string(),
                    );
                }
                for (i, sub) in list.iter().enumerate() {
                    if sub.is_obj() {
                        self.walk(sub, &format!("{path}.{list_key}[{i}]"));
                    }
                }
            }
        }
        for defs_key in ["$defs", "definitions"] {
            if let Some(defs) = node.get(defs_key) {
                if let Some(members) = defs.as_obj() {
                    for (name, sub) in members {
                        if sub.is_obj() {
                            self.walk(sub, &format!("{path}.{defs_key}.{name}"));
                        }
                    }
                }
            }
        }
    }
}

Also available in 13 other languages

Every CosmoDev tool ships its pure logic in TypeScript (web) and Go (CLI), with authored implementations in a dozen-plus languages — the same contract, ported. Compare all languages side by side →