Skip to main content

zag_agent/
json_validation.rs

1//! JSON validation utilities for `--json` and `--json-schema` output modes.
2
3use log::debug;
4
5/// Strip markdown JSON fences if present (e.g., ```json ... ```).
6pub fn strip_markdown_fences(text: &str) -> &str {
7    let trimmed = text.trim();
8    if let Some(rest) = trimmed.strip_prefix("```json") {
9        rest.strip_suffix("```").unwrap_or(rest).trim()
10    } else if let Some(rest) = trimmed.strip_prefix("```") {
11        rest.strip_suffix("```").unwrap_or(rest).trim()
12    } else {
13        trimmed
14    }
15}
16
17/// Parse text as JSON, stripping markdown fences if present.
18///
19/// Returns the parsed JSON value, or an error string describing the parse failure.
20pub fn validate_json(text: &str) -> Result<serde_json::Value, String> {
21    let cleaned = strip_markdown_fences(text);
22    debug!("Validating JSON ({} bytes)", cleaned.len());
23    let result = serde_json::from_str(cleaned).map_err(|e| format!("Invalid JSON: {}", e));
24    if result.is_ok() {
25        debug!("JSON validation passed");
26    } else {
27        debug!("JSON validation failed");
28    }
29    result
30}
31
32/// Validate that a JSON value is a valid JSON Schema.
33///
34/// Returns `Ok(())` if the schema is valid, or an error string describing why it is not.
35pub fn validate_schema(schema: &serde_json::Value) -> Result<(), String> {
36    debug!("Validating JSON schema");
37    jsonschema::validator_for(schema)
38        .map(|_| {
39            debug!("JSON schema is valid");
40        })
41        .map_err(|e| format!("Invalid JSON schema: {}", e))
42}
43
44/// Parse text as JSON and validate it against a JSON schema.
45///
46/// Returns the parsed JSON value, or a list of validation error strings.
47pub fn validate_json_schema(
48    text: &str,
49    schema: &serde_json::Value,
50) -> Result<serde_json::Value, Vec<String>> {
51    let cleaned = strip_markdown_fences(text);
52    debug!("Validating JSON ({} bytes) against schema", cleaned.len());
53    let value: serde_json::Value = serde_json::from_str(cleaned).map_err(|e| {
54        debug!(
55            "JSON parse failed on input ({} bytes): {:.200}",
56            cleaned.len(),
57            cleaned
58        );
59        vec![format!("Invalid JSON: {}", e)]
60    })?;
61
62    let validator = jsonschema::validator_for(schema)
63        .map_err(|e| vec![format!("Invalid JSON schema: {}", e)])?;
64
65    let errors: Vec<String> = validator
66        .iter_errors(&value)
67        .map(|e| {
68            let path = e.instance_path.to_string();
69            if path.is_empty() {
70                e.to_string()
71            } else {
72                format!("{} at {}", e, path)
73            }
74        })
75        .collect();
76
77    if errors.is_empty() {
78        debug!("JSON schema validation passed");
79        Ok(value)
80    } else {
81        debug!("JSON schema validation failed with {} errors", errors.len());
82        Err(errors)
83    }
84}
85
86#[cfg(test)]
87mod tests {
88    use super::*;
89
90    #[test]
91    fn test_validate_json_valid() {
92        let result = validate_json(r#"{"key": "value"}"#);
93        assert!(result.is_ok());
94        assert_eq!(result.unwrap()["key"], "value");
95    }
96
97    #[test]
98    fn test_validate_json_invalid() {
99        let result = validate_json("not json at all");
100        assert!(result.is_err());
101        assert!(result.unwrap_err().contains("Invalid JSON"));
102    }
103
104    #[test]
105    fn test_validate_json_with_markdown_fences() {
106        let result = validate_json("```json\n{\"key\": \"value\"}\n```");
107        assert!(result.is_ok());
108        assert_eq!(result.unwrap()["key"], "value");
109    }
110
111    #[test]
112    fn test_validate_json_with_generic_fences() {
113        let result = validate_json("```\n{\"key\": \"value\"}\n```");
114        assert!(result.is_ok());
115    }
116
117    #[test]
118    fn test_validate_json_array() {
119        let result = validate_json("[1, 2, 3]");
120        assert!(result.is_ok());
121    }
122
123    #[test]
124    fn test_validate_json_schema_valid() {
125        let schema: serde_json::Value = serde_json::json!({
126            "type": "object",
127            "properties": {
128                "name": {"type": "string"}
129            },
130            "required": ["name"]
131        });
132        let result = validate_json_schema(r#"{"name": "test"}"#, &schema);
133        assert!(result.is_ok());
134    }
135
136    #[test]
137    fn test_validate_json_schema_invalid_missing_required() {
138        let schema: serde_json::Value = serde_json::json!({
139            "type": "object",
140            "properties": {
141                "name": {"type": "string"}
142            },
143            "required": ["name"]
144        });
145        let result = validate_json_schema(r#"{"other": "value"}"#, &schema);
146        assert!(result.is_err());
147        let errors = result.unwrap_err();
148        assert!(!errors.is_empty());
149    }
150
151    #[test]
152    fn test_validate_json_schema_invalid_wrong_type() {
153        let schema: serde_json::Value = serde_json::json!({
154            "type": "object",
155            "properties": {
156                "count": {"type": "integer"}
157            }
158        });
159        let result = validate_json_schema(r#"{"count": "not a number"}"#, &schema);
160        assert!(result.is_err());
161    }
162
163    #[test]
164    fn test_validate_json_schema_with_fences() {
165        let schema: serde_json::Value = serde_json::json!({
166            "type": "object",
167            "properties": {
168                "items": {"type": "array"}
169            }
170        });
171        let result = validate_json_schema("```json\n{\"items\": [1,2,3]}\n```", &schema);
172        assert!(result.is_ok());
173    }
174
175    #[test]
176    fn test_validate_json_schema_root_error_no_dangling_at() {
177        let schema: serde_json::Value = serde_json::json!({
178            "type": "object",
179            "required": ["languages"]
180        });
181        let result = validate_json_schema(r#"{"other": "value"}"#, &schema);
182        assert!(result.is_err());
183        let errors = result.unwrap_err();
184        assert_eq!(errors.len(), 1);
185        // Root-level error should NOT end with " at " or " at"
186        assert!(
187            !errors[0].ends_with(" at"),
188            "Error message has dangling 'at': {}",
189            errors[0]
190        );
191        assert!(
192            !errors[0].ends_with(" at "),
193            "Error message has dangling 'at ': {}",
194            errors[0]
195        );
196    }
197
198    #[test]
199    fn test_validate_json_schema_nested_error_includes_path() {
200        let schema: serde_json::Value = serde_json::json!({
201            "type": "object",
202            "properties": {
203                "user": {
204                    "type": "object",
205                    "properties": {
206                        "age": {"type": "integer"}
207                    }
208                }
209            }
210        });
211        let result = validate_json_schema(r#"{"user": {"age": "not a number"}}"#, &schema);
212        assert!(result.is_err());
213        let errors = result.unwrap_err();
214        assert!(!errors.is_empty());
215        assert!(
216            errors[0].contains(" at "),
217            "Nested error should include path: {}",
218            errors[0]
219        );
220    }
221
222    #[test]
223    fn test_validate_schema_accepts_valid_schema() {
224        let schema: serde_json::Value = serde_json::json!({
225            "type": "object",
226            "properties": {
227                "name": {"type": "string"}
228            }
229        });
230        assert!(validate_schema(&schema).is_ok());
231    }
232
233    #[test]
234    fn test_validate_schema_rejects_invalid_schema() {
235        let schema: serde_json::Value = serde_json::json!({
236            "type": "not-a-real-type"
237        });
238        let result = validate_schema(&schema);
239        assert!(result.is_err());
240        assert!(result.unwrap_err().contains("Invalid JSON schema"));
241    }
242
243    #[test]
244    fn test_strip_markdown_fences_no_fences() {
245        assert_eq!(
246            strip_markdown_fences(r#"{"key": "value"}"#),
247            r#"{"key": "value"}"#
248        );
249    }
250
251    #[test]
252    fn test_strip_markdown_fences_json_fences() {
253        assert_eq!(
254            strip_markdown_fences("```json\n{\"key\": \"value\"}\n```"),
255            "{\"key\": \"value\"}"
256        );
257    }
258
259    #[test]
260    fn test_validate_json_empty_string() {
261        let result = validate_json("");
262        assert!(result.is_err());
263    }
264
265    #[test]
266    fn test_validate_json_whitespace_only() {
267        let result = validate_json("   \n\t  ");
268        assert!(result.is_err());
269    }
270
271    #[test]
272    fn test_validate_json_schema_additional_properties() {
273        let schema: serde_json::Value = serde_json::json!({
274            "type": "object",
275            "properties": {
276                "name": {"type": "string"}
277            },
278            "additionalProperties": false
279        });
280        let result = validate_json_schema(r#"{"name": "test", "extra": true}"#, &schema);
281        assert!(result.is_err());
282    }
283
284    #[test]
285    fn test_strip_markdown_fences_with_whitespace() {
286        assert_eq!(
287            strip_markdown_fences("  ```json\n{\"key\": \"value\"}\n```  "),
288            "{\"key\": \"value\"}"
289        );
290    }
291}