Skip to main content

luft_core/contract/
schema.rs

1//! JSON Schema validation (M4 structured output).
2//!
3//! Validates agent output against a JSON Schema. Uses the `jsonschema` crate
4//! for full schema compliance. Designed for structured output validation
5//! where agents must return results matching a defined schema.
6
7use serde_json::Value;
8use thiserror::Error;
9
10#[derive(Error, Debug)]
11pub enum SchemaError {
12    #[error("schema is not a valid JSON Schema: {0}")]
13    InvalidSchema(String),
14    #[error("output does not match schema: {0}")]
15    ValidationFailed(String),
16    #[error("internal error: {0}")]
17    Internal(String),
18}
19
20/// Validates a JSON value against the given JSON Schema.
21///
22/// # Arguments
23/// * `output` - The agent's output to validate.
24/// * `schema` - The JSON Schema to validate against.
25///
26/// # Returns
27/// * `Ok(())` if validation passes.
28/// * `Err(SchemaError)` with a description of why validation failed.
29pub fn validate_output(output: &Value, schema: &Value) -> Result<(), SchemaError> {
30    // First, validate that the schema itself is a valid JSON Schema object
31    match schema {
32        Value::Object(_) => {}
33        Value::Bool(b) => {
34            // JSON Schema allows boolean schemas: true = anything, false = nothing
35            if !b {
36                return Err(SchemaError::ValidationFailed(
37                    "schema is 'false' (rejects all)".to_string(),
38                ));
39            }
40            return Ok(());
41        }
42        _ => {
43            return Err(SchemaError::InvalidSchema(
44                "schema must be an object or boolean".to_string(),
45            ));
46        }
47    }
48
49    // Create a JSON Schema validator using jsonschema crate
50    let validator = jsonschema::JSONSchema::options()
51        .with_draft(jsonschema::Draft::Draft7)
52        .compile(schema)
53        .map_err(|e| SchemaError::InvalidSchema(format!("failed to compile schema: {}", e)))?;
54
55    if let Err(errors) = validator.validate(output) {
56        // Collect the first few validation errors (owned strings, no borrow of validator)
57        let details: Vec<String> = errors
58            .take(5)
59            .map(|e| format!("instance {}: {}", e.instance_path, e))
60            .collect();
61        return Err(SchemaError::ValidationFailed(details.join("; ")));
62    }
63
64    Ok(())
65}
66
67#[cfg(test)]
68mod tests {
69    use super::*;
70    use serde_json::json;
71
72    #[test]
73    fn test_valid_output_matches_schema() {
74        let schema = json!({
75            "type": "object",
76            "properties": {
77                "result": { "type": "string" },
78                "confidence": { "type": "number", "minimum": 0, "maximum": 1 }
79            },
80            "required": ["result"]
81        });
82
83        let output = json!({
84            "result": "success",
85            "confidence": 0.95
86        });
87
88        assert!(validate_output(&output, &schema).is_ok());
89    }
90
91    #[test]
92    fn test_invalid_output_type() {
93        let schema = json!({
94            "type": "object",
95            "properties": {
96                "result": { "type": "string" }
97            },
98            "required": ["result"]
99        });
100
101        let output = json!({"result": 42});
102        let err = validate_output(&output, &schema).unwrap_err();
103        assert!(matches!(err, SchemaError::ValidationFailed(_)));
104    }
105
106    #[test]
107    fn test_missing_required_field() {
108        let schema = json!({
109            "type": "object",
110            "properties": {
111                "result": { "type": "string" }
112            },
113            "required": ["result"]
114        });
115
116        let output = json!({"other": "value"});
117        let err = validate_output(&output, &schema).unwrap_err();
118        assert!(matches!(err, SchemaError::ValidationFailed(_)));
119    }
120
121    #[test]
122    fn test_boolean_schema_false() {
123        let err = validate_output(&json!("anything"), &json!(false)).unwrap_err();
124        assert!(matches!(err, SchemaError::ValidationFailed(_)));
125    }
126
127    #[test]
128    fn test_boolean_schema_true() {
129        assert!(validate_output(&json!("anything"), &json!(true)).is_ok());
130        assert!(validate_output(&json!({"key": "value"}), &json!(true)).is_ok());
131    }
132
133    #[test]
134    fn test_invalid_schema_not_object() {
135        let err = validate_output(&json!("value"), &json!("not_a_schema")).unwrap_err();
136        assert!(matches!(err, SchemaError::InvalidSchema(_)));
137    }
138
139    #[test]
140    fn test_nested_object_validation() {
141        let schema = json!({
142            "type": "object",
143            "properties": {
144                "data": {
145                    "type": "object",
146                    "properties": {
147                        "id": { "type": "integer" },
148                        "tags": {
149                            "type": "array",
150                            "items": { "type": "string" }
151                        }
152                    },
153                    "required": ["id"]
154                }
155            },
156            "required": ["data"]
157        });
158
159        let valid = json!({
160            "data": {
161                "id": 1,
162                "tags": ["a", "b"]
163            }
164        });
165        assert!(validate_output(&valid, &schema).is_ok());
166
167        let invalid = json!({
168            "data": {
169                "id": "not_an_int",
170                "tags": [1, 2]
171            }
172        });
173        assert!(validate_output(&invalid, &schema).is_err());
174    }
175}