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 →