use serde_json::Value;
use std::fmt::Write;
use tracing::{debug, warn};
pub const MAX_VALIDATION_RETRIES: usize = 2;
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ValidationResult {
Valid,
Invalid(String),
}
#[must_use]
pub fn validate_output(output: &str, schema: &Value) -> ValidationResult {
let parsed: Value = match serde_json::from_str(output) {
Ok(v) => v,
Err(e) => {
warn!("output validation: response is not valid JSON: {e}");
return ValidationResult::Invalid(format!("Response is not valid JSON: {e}"));
}
};
if let Some(expected_type) = schema.get("type").and_then(|v| v.as_str()) {
let actual_type = json_type_name(&parsed);
if actual_type != expected_type {
warn!(
expected_type,
actual_type, "output validation: type mismatch"
);
return ValidationResult::Invalid(format!(
"Expected top-level type \"{expected_type}\", got \"{actual_type}\""
));
}
}
if let Some(required) = schema.get("required").and_then(|v| v.as_array()) {
if let Some(obj) = parsed.as_object() {
let missing: Vec<&str> = required
.iter()
.filter_map(|v| v.as_str())
.filter(|key| !obj.contains_key(*key))
.collect();
if !missing.is_empty() {
warn!(fields = %missing.join(", "), "output validation: missing required fields");
return ValidationResult::Invalid(format!(
"Missing required fields: {}",
missing.join(", ")
));
}
} else if !required.is_empty() {
return ValidationResult::Invalid(
"Schema requires fields but response is not a JSON object".into(),
);
}
}
ValidationResult::Valid
}
#[must_use]
pub fn build_retry_prompt(
original_prompt: &str,
failed_output: &str,
validation_error: &str,
schema: &Value,
) -> String {
let mut prompt = String::with_capacity(
original_prompt.len() + failed_output.len() + validation_error.len() + 512,
);
let _ = write!(
prompt,
"{original_prompt}\n\n\
Your previous response failed validation.\n\
Error: {validation_error}\n\
Your previous response was:\n```\n{failed_output}\n```\n\n\
Please respond with ONLY valid JSON matching this schema:\n```json\n{schema}\n```"
);
prompt
}
#[must_use]
pub fn extract_and_validate(output: &str, schema: &Value) -> (String, ValidationResult) {
let result = validate_output(output, schema);
if result == ValidationResult::Valid {
debug!("output validated successfully (raw)");
return (output.to_string(), result);
}
if let Some(json_str) = extract_json_block(output) {
let fenced_result = validate_output(json_str, schema);
if fenced_result == ValidationResult::Valid {
debug!("output validated successfully (extracted from fence)");
return (json_str.to_string(), fenced_result);
}
return (json_str.to_string(), fenced_result);
}
(output.to_string(), result)
}
fn extract_json_block(text: &str) -> Option<&str> {
let start_markers = ["```json\n", "```json\r\n", "```\n", "```\r\n"];
for marker in start_markers {
if let Some(start) = text.find(marker) {
let content_start = start + marker.len();
let remainder = &text[content_start..];
let end = remainder
.find("\n```")
.or_else(|| remainder.find("\r\n```"))?;
let block = remainder[..end].trim();
if !block.is_empty() {
return Some(block);
}
}
}
None
}
fn json_type_name(v: &Value) -> &'static str {
match v {
Value::Null => "null",
Value::Bool(_) => "boolean",
Value::Number(_) => "number",
Value::String(_) => "string",
Value::Array(_) => "array",
Value::Object(_) => "object",
}
}
pub fn log_retry(task_id: &str, attempt: usize, error: &str) {
warn!(
task_id,
attempt,
max = MAX_VALIDATION_RETRIES,
error,
"output validation failed, retrying"
);
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn valid_json_object_with_required_fields() {
let schema = json!({
"type": "object",
"required": ["name", "score"],
});
let output = r#"{"name": "test", "score": 42}"#;
assert_eq!(validate_output(output, &schema), ValidationResult::Valid);
}
#[test]
fn missing_required_field() {
let schema = json!({
"type": "object",
"required": ["name", "score"],
});
let output = r#"{"name": "test"}"#;
assert!(matches!(
validate_output(output, &schema),
ValidationResult::Invalid(ref s) if s.contains("score")
));
}
#[test]
fn not_valid_json() {
let schema = json!({"type": "object"});
assert!(matches!(
validate_output("this is not json", &schema),
ValidationResult::Invalid(ref s) if s.contains("not valid JSON")
));
}
#[test]
fn wrong_type() {
let schema = json!({"type": "object"});
let output = r#"[1, 2, 3]"#;
assert!(matches!(
validate_output(output, &schema),
ValidationResult::Invalid(ref s) if s.contains("array")
));
}
#[test]
fn array_type_valid() {
let schema = json!({"type": "array"});
let output = r#"[1, 2, 3]"#;
assert_eq!(validate_output(output, &schema), ValidationResult::Valid);
}
#[test]
fn no_schema_constraints_passes_any_json() {
let schema = json!({});
assert_eq!(
validate_output(r#"{"anything": true}"#, &schema),
ValidationResult::Valid
);
assert_eq!(validate_output("42", &schema), ValidationResult::Valid);
assert_eq!(
validate_output(r#""hello""#, &schema),
ValidationResult::Valid
);
}
#[test]
fn extract_json_from_markdown_fence() {
let text = "Here is the result:\n```json\n{\"key\": \"value\"}\n```\nDone.";
let schema = json!({"type": "object", "required": ["key"]});
let (extracted, result) = extract_and_validate(text, &schema);
assert_eq!(result, ValidationResult::Valid);
assert_eq!(extracted, r#"{"key": "value"}"#);
}
#[test]
fn extract_json_from_plain_fence() {
let text = "Result:\n```\n{\"a\": 1}\n```";
let schema = json!({"type": "object"});
let (_, result) = extract_and_validate(text, &schema);
assert_eq!(result, ValidationResult::Valid);
}
#[test]
fn no_fence_falls_back_to_raw() {
let text = r#"{"valid": true}"#;
let schema = json!({"type": "object"});
let (_, result) = extract_and_validate(text, &schema);
assert_eq!(result, ValidationResult::Valid);
}
#[test]
fn build_retry_prompt_includes_error() {
let prompt = build_retry_prompt(
"Summarize this",
"not json",
"not valid JSON",
&json!({"type": "object"}),
);
assert!(prompt.contains("Summarize this"));
assert!(prompt.contains("not valid JSON"));
assert!(prompt.contains("not json"));
assert!(prompt.contains("schema"));
}
#[test]
fn fence_with_backticks_in_json_value() {
let text = "Result:\n```json\n{\"code\": \"use ```markdown``` here\"}\n```\nDone.";
let schema = json!({"type": "object", "required": ["code"]});
let (extracted, result) = extract_and_validate(text, &schema);
assert_eq!(result, ValidationResult::Valid);
assert!(extracted.contains("markdown"));
}
#[test]
fn required_on_non_object_fails() {
let schema = json!({"type": "string", "required": ["field"]});
assert!(matches!(
validate_output(r#""just a string""#, &schema),
ValidationResult::Invalid(ref s) if s.contains("not a JSON object")
));
}
}