vtcode_core/tools/validation/
mod.rs1pub mod commands;
2pub mod paths;
3
4use jsonschema::ValidationError;
5use jsonschema::error::{TypeKind, ValidationErrorKind};
6use serde_json::Value;
7
8pub fn condensed_schema_hint(schema: &Value) -> Option<Value> {
18 let properties = schema.get("properties").and_then(Value::as_object)?;
19 let required: Vec<Value> = schema.get("required").and_then(Value::as_array).cloned().unwrap_or_default();
20
21 let mut prop_types = serde_json::Map::new();
22 for (name, def) in properties {
23 let type_str = def.get("type").and_then(Value::as_str).unwrap_or("any").to_string();
24 let mut rendered = match def.get("enum").and_then(Value::as_array) {
28 Some(options) if !options.is_empty() => {
29 let joined = options
30 .iter()
31 .map(|option| match option {
32 Value::String(s) => s.clone(),
33 other => other.to_string(),
34 })
35 .collect::<Vec<_>>()
36 .join("|");
37 format!("{type_str}({joined})")
38 }
39 _ => type_str,
40 };
41
42 let bounds = [("min", def.get("minimum")), ("max", def.get("maximum"))]
43 .into_iter()
44 .filter_map(|(label, value)| value.map(|value| format!("{label}={value}")))
45 .collect::<Vec<_>>();
46 if !bounds.is_empty() {
47 if rendered.ends_with(')') {
48 rendered.pop();
49 rendered.push(',');
50 } else {
51 rendered.push('(');
52 }
53 rendered.push_str(&bounds.join(","));
54 rendered.push(')');
55 }
56 prop_types.insert(name.clone(), Value::String(rendered));
57 }
58
59 Some(serde_json::json!({
60 "required": required,
61 "properties": prop_types,
62 }))
63}
64
65pub fn describe_jsonschema_error(err: &ValidationError<'_>) -> String {
74 let path = err.instance_path().to_string();
75 let path_label = if path.is_empty() { "(root)".to_string() } else { path };
76 let schema_path = err.schema_path().to_string();
77 let value = err.instance();
78 let raw_value_str = match &**value {
79 Value::String(s) => format!("\"{s}\""),
80 other => other.to_string(),
81 };
82 let value_str = truncate_for_error(&raw_value_str, 500);
83 match err.kind() {
84 ValidationErrorKind::Enum { options } => {
85 let opts = options
86 .as_array()
87 .map(|items| {
88 items
89 .iter()
90 .map(|v| match v {
91 Value::String(s) => s.clone(),
92 other => other.to_string(),
93 })
94 .collect::<Vec<_>>()
95 .join(", ")
96 })
97 .unwrap_or_default();
98 format!("field '{path_label}' value {value_str} is not one of the allowed enum: [{opts}]")
99 }
100 ValidationErrorKind::Constant { expected_value } => {
101 format!("field '{path_label}' value {value_str} must equal the required const {expected_value}")
102 }
103 ValidationErrorKind::Type { kind } => {
104 let expected = match kind {
105 TypeKind::Single(t) => t.to_string(),
106 TypeKind::Multiple(set) => format!("{set:?}"),
107 };
108 format!("field '{path_label}' has wrong type: expected {expected}, got {value_str}")
109 }
110 ValidationErrorKind::Required { property } => {
111 let name = match property {
112 Value::String(s) => s.clone(),
113 other => other.to_string(),
114 };
115 format!("field '{path_label}' missing required property '{name}' (schema {schema_path})")
116 }
117 ValidationErrorKind::AdditionalProperties { unexpected } => {
118 format!("unexpected field(s) {unexpected:?} not allowed by the schema (did you use the right field name?)")
119 }
120 ValidationErrorKind::Not { schema } => {
121 let forbidden = forbidden_properties_from_not_schema(schema);
122 if forbidden.is_empty() {
123 let schema_str = truncate_for_error(&schema.to_string(), 300);
124 format!(
125 "field '{path_label}' value {value_str} is forbidden by schema {schema_path} (not {schema_str}); remove the forbidden field(s) and retry"
126 )
127 } else {
128 let present = present_forbidden_fields(value, &forbidden);
129 let offending = if present.is_empty() { forbidden.clone() } else { present };
130 format!(
131 "field '{path_label}' must not include {offending:?} for this action (schema {schema_path} forbids them); remove {offending:?} and retry"
132 )
133 }
134 }
135 ValidationErrorKind::AnyOf { context } => {
136 describe_combinator_error(&path_label, &schema_path, "anyOf", context)
137 }
138 ValidationErrorKind::OneOfNotValid { context } => {
139 describe_combinator_error(&path_label, &schema_path, "oneOf", context)
140 }
141 ValidationErrorKind::OneOfMultipleValid { context } => {
142 let _ = context;
143 format!(
144 "field '{path_label}' value {value_str} matches more than one allowed shape (schema {schema_path} oneOf); make the call match exactly one variant"
145 )
146 }
147 ValidationErrorKind::FalseSchema => {
148 format!("field '{path_label}' value {value_str} is not allowed here (schema {schema_path} disallows it)")
149 }
150 _ => {
151 let detail = truncate_for_error(&err.to_string(), 500);
155 if schema_path.is_empty() {
156 format!("field '{path_label}' failed validation: {detail}")
157 } else {
158 format!("field '{path_label}' failed validation (schema {schema_path}): {detail}")
159 }
160 }
161 }
162}
163
164fn truncate_for_error(raw: &str, limit: usize) -> String {
165 vtcode_commons::formatting::truncate_byte_budget(raw, limit, "…")
166}
167
168fn forbidden_properties_from_not_schema(schema: &Value) -> Vec<String> {
169 let mut out = Vec::new();
173 if let Some(required) = schema.get("required").and_then(Value::as_array) {
174 for entry in required {
175 if let Some(name) = entry.as_str() {
176 out.push(name.to_string());
177 }
178 }
179 }
180 for key in ["anyOf", "oneOf", "allOf"] {
181 if let Some(branches) = schema.get(key).and_then(Value::as_array) {
182 for branch in branches {
183 for name in forbidden_properties_from_not_schema(branch) {
184 if !out.contains(&name) {
185 out.push(name);
186 }
187 }
188 }
189 }
190 }
191 out
192}
193
194fn present_forbidden_fields(instance: &Value, forbidden: &[String]) -> Vec<String> {
195 let Some(map) = instance.as_object() else {
196 return Vec::new();
197 };
198 forbidden.iter().filter(|name| map.contains_key(*name)).cloned().collect()
199}
200
201fn describe_combinator_error(
202 path_label: &str,
203 schema_path: &str,
204 keyword: &str,
205 context: &[Vec<ValidationError<'static>>],
206) -> String {
207 let mut variants: Vec<String> = Vec::new();
211 for (idx, branch) in context.iter().take(3).enumerate() {
212 let mut parts: Vec<String> = Vec::new();
213 for error in branch.iter().take(2) {
214 let rendered = match error.kind() {
215 ValidationErrorKind::AnyOf { .. }
216 | ValidationErrorKind::OneOfNotValid { .. }
217 | ValidationErrorKind::OneOfMultipleValid { .. } => truncate_for_error(&error.to_string(), 300),
218 _ => truncate_for_error(&describe_jsonschema_error(error), 300),
219 };
220 parts.push(rendered);
221 }
222 if parts.is_empty() {
223 continue;
224 }
225 variants.push(format!("variant {}: {}", idx + 1, parts.join(" + ")));
226 }
227 if variants.is_empty() {
228 format!(
229 "field '{path_label}' does not match any allowed shape (schema {schema_path} {keyword}); adjust the arguments to match one variant and retry"
230 )
231 } else {
232 format!(
233 "field '{path_label}' does not match any allowed shape (schema {schema_path} {keyword}): {}",
234 variants.join(" | ")
235 )
236 }
237}
238
239#[cfg(test)]
240mod tests {
241 use super::*;
242 use serde_json::json;
243
244 #[test]
245 fn enum_error_names_field_and_valid_options() {
246 let schema = json!({
247 "type": "object",
248 "properties": {
249 "format": {"type": "string", "enum": ["github", "sarif", "files_with_matches", "count"]}
250 }
251 });
252 let instance = json!({ "format": "content" });
253 let error = jsonschema::validate(&schema, &instance).unwrap_err();
254 let msg = describe_jsonschema_error(&error);
255 assert!(msg.contains("field '/format'"), "msg was: {msg}");
256 assert!(msg.contains("\"content\""), "msg was: {msg}");
257 assert!(msg.contains("github, sarif, files_with_matches, count"), "msg was: {msg}");
258 }
259
260 #[test]
261 fn multiple_errors_are_described_independently() {
262 let schema = json!({
266 "type": "object",
267 "required": ["action", "format"],
268 "properties": {
269 "action": {"type": "string"},
270 "format": {"type": "string", "enum": ["github", "sarif"]}
271 }
272 });
273 let instance = json!({ "format": "content" });
274 let validator = jsonschema::validator_for(&schema).expect("schema is valid");
275 let errors: Vec<_> = validator.iter_errors(&instance).collect();
276 assert!(errors.len() >= 2, "expected both missing-action and bad-format errors, got {}", errors.len());
277 let messages: Vec<String> = errors.iter().map(describe_jsonschema_error).collect();
278 assert!(messages.iter().any(|m| m.contains("missing required property 'action'")));
279 assert!(
280 messages
281 .iter()
282 .any(|m| m.contains("field '/format'") && m.contains("\"content\""))
283 );
284 }
285
286 #[test]
287 fn missing_required_names_property() {
288 let schema = json!({
289 "type": "object",
290 "required": ["action"],
291 "properties": { "action": {"type": "string"} }
292 });
293 let instance = json!({});
294 let error = jsonschema::validate(&schema, &instance).unwrap_err();
295 let msg = describe_jsonschema_error(&error);
296 assert!(msg.contains("missing required property 'action'"), "msg was: {msg}");
297 }
298
299 #[test]
300 fn numeric_bounds_are_included_in_schema_hint() {
301 let schema = json!({
302 "type": "object",
303 "properties": {
304 "max_results": {"type": "integer", "minimum": 1, "maximum": 100}
305 }
306 });
307
308 let hint = condensed_schema_hint(&schema).expect("object schema should produce a hint");
309 assert_eq!(hint["properties"]["max_results"], "integer(min=1,max=100)");
310 }
311
312 #[test]
313 fn not_error_names_forbidden_fields_and_schema_path() {
314 let schema = json!({
315 "type": "object",
316 "properties": {
317 "action": {"type": "string"},
318 "index": {"type": "integer"},
319 "index_path": {"type": "string"}
320 },
321 "required": ["action"],
322 "allOf": [
323 {
324 "if": {"properties": {"action": {"const": "create"}}, "required": ["action"]},
325 "then": {"not": {"anyOf": [{"required": ["index"]}, {"required": ["index_path"]}]}}
326 }
327 ]
328 });
329 let instance = json!({"action": "create", "title": "README plan", "index": 1, "index_path": "1"});
330 let validator = jsonschema::validator_for(&schema).expect("schema is valid");
331 let errors: Vec<_> = validator.iter_errors(&instance).collect();
332 assert!(!errors.is_empty(), "expected a not-violation");
333 let messages: Vec<String> = errors.iter().map(describe_jsonschema_error).collect();
334 let combined = messages.join("; ");
335 assert!(combined.contains("must not include"), "msg was: {combined}");
336 assert!(combined.contains("index"), "msg was: {combined}");
337 assert!(
338 !combined.contains("failed validation: {\"action\""),
339 "should not dump the whole object, got: {combined}"
340 );
341 }
342
343 #[test]
344 fn fallback_includes_schema_path_instead_of_bare_dump() {
345 let schema = json!({
346 "type": "object",
347 "properties": {"name": {"type": "string", "minLength": 5}}
348 });
349 let instance = json!({"name": "abc"});
350 let validator = jsonschema::validator_for(&schema).expect("schema is valid");
351 let errors: Vec<_> = validator.iter_errors(&instance).collect();
352 assert!(!errors.is_empty());
353 let msg = describe_jsonschema_error(&errors[0]);
354 assert!(msg.contains("field '"), "msg was: {msg}");
355 assert!(msg.contains("/properties/name"), "msg was: {msg}");
356 }
357
358 #[test]
359 fn required_error_includes_instance_and_schema_location() {
360 let schema = json!({
361 "type": "object",
362 "properties": {
363 "items": {
364 "type": "array",
365 "items": {
366 "type": "object",
367 "properties": {"description": {"type": "string"}},
368 "required": ["description"]
369 }
370 }
371 },
372 "required": ["items"]
373 });
374 let instance = json!({"items": [{"description": "ok"}, {"status": "completed"}]});
376 let validator = jsonschema::validator_for(&schema).expect("schema is valid");
377 let errors: Vec<_> = validator.iter_errors(&instance).collect();
378 assert!(!errors.is_empty(), "expected a nested required violation");
379 let combined = errors.iter().map(describe_jsonschema_error).collect::<Vec<_>>().join("; ");
380 assert!(combined.contains("missing required property 'description'"), "msg was: {combined}");
381 assert!(combined.contains("/items/1"), "msg was: {combined}");
382 }
383
384 #[test]
385 fn combinator_error_labels_variants() {
386 let schema = json!({
387 "type": "object",
388 "properties": {"action": {"type": "string"}},
389 "required": ["action"],
390 "allOf": [
391 {
392 "if": {"properties": {"action": {"const": "update"}}, "required": ["action"]},
393 "then": {
394 "anyOf": [
395 {"required": ["index"]},
396 {"required": ["index_path"]}
397 ]
398 }
399 }
400 ]
401 });
402 let instance = json!({"action": "update"});
403 let validator = jsonschema::validator_for(&schema).expect("schema is valid");
404 let errors: Vec<_> = validator.iter_errors(&instance).collect();
405 assert!(!errors.is_empty(), "expected an anyOf violation");
406 let combined = errors.iter().map(describe_jsonschema_error).collect::<Vec<_>>().join("; ");
407 assert!(combined.contains("does not match any allowed shape"), "msg was: {combined}");
408 assert!(combined.contains("variant 1"), "msg was: {combined}");
409 }
410}