use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NativeSchema {
ClaudeJsonSchema,
}
pub struct Schema {
raw: Value,
text: String,
validator: jsonschema::Validator,
}
impl Schema {
pub fn compile(text: &str) -> Result<Schema, String> {
let raw: Value =
serde_json::from_str(text).map_err(|e| format!("schema is not valid JSON: {e}"))?;
let validator =
jsonschema::validator_for(&raw).map_err(|e| format!("not a valid JSON Schema: {e}"))?;
let text = serde_json::to_string(&raw).unwrap_or_else(|_| text.to_string());
Ok(Schema {
raw,
text,
validator,
})
}
pub fn as_value(&self) -> &Value {
&self.raw
}
pub fn as_text(&self) -> &str {
&self.text
}
pub fn validate(&self, instance: &Value) -> Result<(), Vec<String>> {
let errors: Vec<String> = self
.validator
.iter_errors(instance)
.map(|e| e.to_string())
.collect();
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Check {
pub value: Option<Value>,
pub errors: Vec<String>,
}
impl Check {
pub fn is_valid(&self) -> bool {
self.value.is_some() && self.errors.is_empty()
}
}
pub fn check(schema: &Schema, native: Option<NativeSchema>, text: &str, stdout: &str) -> Check {
match extract_value(native, text, stdout) {
Some(value) => match schema.validate(&value) {
Ok(()) => Check {
value: Some(value),
errors: Vec::new(),
},
Err(errors) => Check {
value: Some(value),
errors,
},
},
None => Check {
value: None,
errors: vec!["no JSON value could be extracted from the response".to_string()],
},
}
}
pub fn extract_value(native: Option<NativeSchema>, text: &str, stdout: &str) -> Option<Value> {
if native == Some(NativeSchema::ClaudeJsonSchema) {
if let Ok(doc) = serde_json::from_str::<Value>(stdout.trim()) {
if let Some(value) = doc.get("structured_output") {
if !value.is_null() {
return Some(value.clone());
}
}
}
}
extract_json(text)
}
pub fn extract_json(text: &str) -> Option<Value> {
let trimmed = text.trim();
if trimmed.is_empty() {
return None;
}
if let Ok(value) = serde_json::from_str::<Value>(trimmed) {
return Some(value);
}
if let Some(inner) = fenced_block(trimmed) {
let body = inner.trim();
if let Ok(value) = serde_json::from_str::<Value>(body) {
return Some(value);
}
if let Some(value) = first_json_value(body) {
return Some(value);
}
}
first_json_value(trimmed)
}
fn fenced_block(s: &str) -> Option<&str> {
let start = s.find("```")?;
let after = &s[start + 3..];
let body_start = after.find('\n').map(|i| i + 1)?;
let body = &after[body_start..];
let end = body.find("```")?;
Some(&body[..end])
}
fn first_json_value(s: &str) -> Option<Value> {
let start = s.find(['{', '['])?;
serde_json::Deserializer::from_str(&s[start..])
.into_iter::<Value>()
.next()
.and_then(Result::ok)
}
pub fn prompt_instruction(schema_text: &str) -> String {
format!(
"You must respond with a single JSON value that strictly conforms to the \
following JSON Schema. Output ONLY that JSON value — no prose, no \
explanation, and no Markdown code fences. JSON Schema: {schema_text}"
)
}
pub fn retry_instruction(schema_text: &str, previous: &str, errors: &[String]) -> String {
let errors = if errors.is_empty() {
"(no JSON value could be extracted from the response)".to_string()
} else {
errors.join("; ")
};
let previous = flatten_whitespace(previous);
format!(
"Your previous response did not conform to the required JSON Schema. \
Previous response: {previous} -- Validation errors: {errors}. \
Respond again with ONLY a single JSON value that strictly conforms to \
this JSON Schema (no prose, no code fences): {schema_text}"
)
}
fn flatten_whitespace(s: &str) -> String {
s.split_whitespace().collect::<Vec<_>>().join(" ")
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
const PERSON: &str = r#"{"type":"object","properties":{"name":{"type":"string"},
"age":{"type":"integer"}},"required":["name","age"],"additionalProperties":false}"#;
fn person() -> Schema {
Schema::compile(PERSON).expect("schema compiles")
}
#[test]
fn compile_rejects_non_json_and_invalid_schema() {
let err = Schema::compile("not json")
.err()
.expect("non-json rejected");
assert!(err.contains("not valid JSON"), "{err}");
let err = Schema::compile(r#"{"type": 5}"#)
.err()
.expect("invalid schema rejected");
assert!(err.contains("not a valid JSON Schema"), "{err}");
}
#[test]
fn validate_accepts_conforming_and_lists_errors_otherwise() {
let s = person();
assert!(s.validate(&json!({"name": "Ada", "age": 36})).is_ok());
let errs = s
.validate(&json!({"name": "Ada"}))
.expect_err("missing required age");
assert!(!errs.is_empty());
assert!(s
.validate(&json!({"name": "Ada", "age": 1, "x": true}))
.is_err());
}
#[test]
fn as_text_is_canonical_compact_json() {
let s = Schema::compile(r#"{ "type" : "string" }"#).unwrap();
assert_eq!(s.as_text(), r#"{"type":"string"}"#);
assert_eq!(s.as_value(), &json!({"type": "string"}));
}
#[test]
fn extract_json_parses_plain_document() {
let v = extract_json(r#" {"a":1} "#).unwrap();
assert_eq!(v, json!({"a": 1}));
}
#[test]
fn extract_json_unwraps_fenced_block() {
let text = "Here you go:\n```json\n{\"name\":\"Ada\",\"age\":36}\n```\nDone.";
assert_eq!(extract_json(text).unwrap(), json!({"name":"Ada","age":36}));
let text = "```\n[1, 2, 3]\n```";
assert_eq!(extract_json(text).unwrap(), json!([1, 2, 3]));
}
#[test]
fn extract_json_recovers_value_inside_a_noisy_fence() {
let text = "```json\n{\"name\":\"Ada\",\"age\":36} (that's her)\n```";
assert_eq!(extract_json(text).unwrap(), json!({"name":"Ada","age":36}));
}
#[test]
fn extract_json_finds_object_embedded_in_prose() {
let text = "Sure! {\"name\": \"Ada\", \"age\": 36} — hope that helps.";
assert_eq!(extract_json(text).unwrap(), json!({"name":"Ada","age":36}));
}
#[test]
fn extract_json_returns_none_when_absent() {
assert!(extract_json("no json here at all").is_none());
assert!(extract_json(" ").is_none());
assert_eq!(extract_json("```json\n{\"a\":1}").unwrap(), json!({"a": 1}));
}
#[test]
fn check_validates_prompt_based_text() {
let s = person();
let ok = check(&s, None, r#"{"name":"Ada","age":36}"#, "");
assert!(ok.is_valid());
assert_eq!(ok.value.unwrap(), json!({"name":"Ada","age":36}));
let bad = check(&s, None, r#"{"name":"Ada"}"#, "");
assert!(!bad.is_valid());
assert!(bad.value.is_some());
assert!(!bad.errors.is_empty());
let none = check(&s, None, "I can't do that", "");
assert!(!none.is_valid());
assert!(none.value.is_none());
assert_eq!(none.errors.len(), 1);
}
#[test]
fn check_native_reads_structured_output_field() {
let s = person();
let stdout = r#"{"type":"result","result":"Here is Ada.",
"structured_output":{"name":"Ada","age":36}}"#;
let c = check(
&s,
Some(NativeSchema::ClaudeJsonSchema),
"Here is Ada.",
stdout,
);
assert!(c.is_valid());
assert_eq!(c.value.unwrap(), json!({"name":"Ada","age":36}));
}
#[test]
fn native_falls_back_to_text_when_field_absent() {
let s = person();
let c = check(
&s,
Some(NativeSchema::ClaudeJsonSchema),
r#"{"name":"Ada","age":36}"#,
r#"{"type":"result","result":"{\"name\":\"Ada\",\"age\":36}"}"#,
);
assert!(c.is_valid());
}
#[test]
fn instructions_carry_the_schema_and_errors() {
let instr = prompt_instruction(r#"{"type":"string"}"#);
assert!(instr.contains(r#"{"type":"string"}"#));
assert!(instr.contains("ONLY"));
let retry = retry_instruction(
r#"{"type":"object"}"#,
"oops not json",
&["missing required property 'name'".to_string()],
);
assert!(retry.contains("oops not json"));
assert!(retry.contains("missing required property 'name'"));
assert!(retry.contains(r#"{"type":"object"}"#));
let retry = retry_instruction("{}", "prose", &[]);
assert!(retry.contains("no JSON value could be extracted"));
}
#[test]
fn instructions_are_single_line_for_cmd_shim_safety() {
let instr = prompt_instruction("{\"type\":\"object\"}");
assert!(!instr.contains('\n'), "prompt_instruction must be one line");
let retry = retry_instruction(
"{\"type\":\"object\"}",
"line one\nline two\r\nline three",
&["err a".to_string(), "err b".to_string()],
);
assert!(!retry.contains('\n'), "retry_instruction must be one line");
assert!(!retry.contains('\r'), "retry_instruction must be one line");
assert!(retry.contains("line one line two line three"), "{retry}");
assert!(retry.contains("err a; err b"));
}
#[test]
fn validator_is_shareable_across_threads() {
fn assert_sync<T: Sync>() {}
assert_sync::<Schema>();
}
}