Skip to main content

agentd/
jsonschema.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! A dependency-free **JSON Schema subset validator** (draft 2020-12 vocabulary,
3//! the parts tool contracts and workflow schemas actually use): `type` (single
4//! or list, `integer` distinct from `number`), `properties`, `required`,
5//! `additionalProperties` (bool or schema), `patternProperties` (literal-prefix
6//! and `^…$` anchored-literal patterns only — no regex engine), `enum`, `const`,
7//! `items` (schema), `prefixItems`, `minItems`/`maxItems`, `uniqueItems`,
8//! `minimum`/`maximum`/`exclusiveMinimum`/`exclusiveMaximum`, `multipleOf`,
9//! `minLength`/`maxLength`, `minProperties`/`maxProperties`, `allOf`/`anyOf`/
10//! `oneOf`/`not`, `if`/`then`/`else`, `$ref` to `#/$defs/<name>` /
11//! `#/definitions/<name>` / `#` (root), boolean schemas, `nullable` (OpenAPI
12//! sugar), `default` (ignored), `format`/`pattern`/`description`/`title`/
13//! `examples`/`$schema`/`$id`/`$comment` (accepted, not enforced — `pattern`
14//! is checked only for the same literal shapes as `patternProperties`).
15//!
16//! Errors are collected (not fail-fast) and name the JSON pointer of the
17//! offending value, so a model or an author sees every miss at once.
18
19use serde_json::{Map, Value};
20
21/// Validate `value` against `schema`. `Ok(())` or every violation, each as
22/// `"<pointer>: <message>"` (`<pointer>` is `/a/0/b`, or `/` for the root).
23pub fn validate(schema: &Value, value: &Value) -> Result<(), Vec<String>> {
24    let mut errs = Vec::new();
25    let mut depth = 0u32;
26    walk(schema, schema, value, "", &mut errs, &mut depth);
27    if errs.is_empty() { Ok(()) } else { Err(errs) }
28}
29
30/// One-line rendering of a validation failure (for tool errors / logs).
31pub fn explain(errs: &[String]) -> String {
32    errs.join("; ")
33}
34
35/// Whether `schema` is a well-formed schema for this validator: an object or
36/// a boolean, with known-typed keywords. Unknown keywords are allowed (JSON
37/// Schema is open); wrong-typed known keywords are reported.
38pub fn check_schema(schema: &Value) -> Result<(), Vec<String>> {
39    let mut errs = Vec::new();
40    check_schema_at(schema, "", &mut errs, 0);
41    if errs.is_empty() { Ok(()) } else { Err(errs) }
42}
43
44const MAX_DEPTH: u32 = 64;
45
46fn ptr(path: &str) -> &str {
47    if path.is_empty() { "/" } else { path }
48}
49
50fn walk(
51    root: &Value,
52    schema: &Value,
53    value: &Value,
54    path: &str,
55    errs: &mut Vec<String>,
56    depth: &mut u32,
57) {
58    if *depth > MAX_DEPTH {
59        errs.push(format!("{}: schema nesting exceeds {MAX_DEPTH}", ptr(path)));
60        return;
61    }
62    *depth += 1;
63    walk_inner(root, schema, value, path, errs, depth);
64    *depth -= 1;
65}
66
67fn walk_inner(
68    root: &Value,
69    schema: &Value,
70    value: &Value,
71    path: &str,
72    errs: &mut Vec<String>,
73    depth: &mut u32,
74) {
75    let s = match schema {
76        Value::Bool(true) => return,
77        Value::Bool(false) => {
78            errs.push(format!("{}: no value is allowed here", ptr(path)));
79            return;
80        }
81        Value::Object(s) => s,
82        _ => {
83            errs.push(format!(
84                "{}: invalid schema (not an object or boolean)",
85                ptr(path)
86            ));
87            return;
88        }
89    };
90    // $ref (draft 2020-12: siblings apply too).
91    if let Some(Value::String(r)) = s.get("$ref") {
92        match resolve_ref(root, r) {
93            Some(target) => walk(root, target, value, path, errs, depth),
94            None => errs.push(format!("{}: unresolvable $ref {r:?}", ptr(path))),
95        }
96    }
97    // nullable sugar.
98    if value.is_null() && s.get("nullable") == Some(&Value::Bool(true)) {
99        return;
100    }
101    // type
102    if let Some(t) = s.get("type") {
103        let ok = match t {
104            Value::String(t) => type_matches(t, value),
105            Value::Array(ts) => ts
106                .iter()
107                .any(|t| t.as_str().is_some_and(|t| type_matches(t, value))),
108            _ => true,
109        };
110        if !ok {
111            errs.push(format!(
112                "{}: expected type {}, got {}",
113                ptr(path),
114                render_type(t),
115                type_name(value)
116            ));
117            // A type miss makes most other keywords moot; keep going only for
118            // combinators so `anyOf` failures still explain themselves.
119        }
120    }
121    // enum / const
122    if let Some(Value::Array(e)) = s.get("enum")
123        && !e.iter().any(|x| x == value)
124    {
125        errs.push(format!(
126            "{}: value is not one of the allowed values ({})",
127            ptr(path),
128            short(&Value::Array(e.clone()))
129        ));
130    }
131    if let Some(c) = s.get("const")
132        && c != value
133    {
134        errs.push(format!("{}: value must equal {}", ptr(path), short(c)));
135    }
136    // Per-type keywords.
137    match value {
138        Value::Object(obj) => object_keywords(root, s, obj, path, errs, depth),
139        Value::Array(arr) => array_keywords(root, s, arr, path, errs, depth),
140        Value::String(st) => string_keywords(s, st, path, errs),
141        Value::Number(n) => number_keywords(s, n, path, errs),
142        _ => {}
143    }
144    // Combinators.
145    if let Some(Value::Array(all)) = s.get("allOf") {
146        for sub in all {
147            walk(root, sub, value, path, errs, depth);
148        }
149    }
150    if let Some(Value::Array(any)) = s.get("anyOf") {
151        let mut best: Option<Vec<String>> = None;
152        let mut matched = false;
153        for sub in any {
154            let mut e = Vec::new();
155            walk(root, sub, value, path, &mut e, depth);
156            if e.is_empty() {
157                matched = true;
158                break;
159            }
160            if best.as_ref().is_none_or(|b| e.len() < b.len()) {
161                best = Some(e);
162            }
163        }
164        if !matched {
165            errs.push(format!(
166                "{}: matches none of anyOf ({})",
167                ptr(path),
168                best.map(|b| b.join("; ")).unwrap_or_default()
169            ));
170        }
171    }
172    if let Some(Value::Array(one)) = s.get("oneOf") {
173        let mut n = 0;
174        let mut best: Option<Vec<String>> = None;
175        for sub in one {
176            let mut e = Vec::new();
177            walk(root, sub, value, path, &mut e, depth);
178            if e.is_empty() {
179                n += 1;
180            } else if best.as_ref().is_none_or(|b| e.len() < b.len()) {
181                best = Some(e);
182            }
183        }
184        if n != 1 {
185            errs.push(format!(
186                "{}: must match exactly one of oneOf (matched {n}{})",
187                ptr(path),
188                if n == 0 {
189                    format!("; {}", best.map(|b| b.join("; ")).unwrap_or_default())
190                } else {
191                    String::new()
192                }
193            ));
194        }
195    }
196    if let Some(not) = s.get("not") {
197        let mut e = Vec::new();
198        walk(root, not, value, path, &mut e, depth);
199        if e.is_empty() {
200            errs.push(format!("{}: must not match the `not` schema", ptr(path)));
201        }
202    }
203    if let Some(cond) = s.get("if") {
204        let mut e = Vec::new();
205        walk(root, cond, value, path, &mut e, depth);
206        let branch = if e.is_empty() {
207            s.get("then")
208        } else {
209            s.get("else")
210        };
211        if let Some(b) = branch {
212            walk(root, b, value, path, errs, depth);
213        }
214    }
215}
216
217fn object_keywords(
218    root: &Value,
219    s: &Map<String, Value>,
220    obj: &Map<String, Value>,
221    path: &str,
222    errs: &mut Vec<String>,
223    depth: &mut u32,
224) {
225    if let Some(Value::Array(req)) = s.get("required") {
226        for r in req {
227            if let Some(r) = r.as_str()
228                && !obj.contains_key(r)
229            {
230                errs.push(format!("{}: missing required property {r:?}", ptr(path)));
231            }
232        }
233    }
234    let props = s.get("properties").and_then(Value::as_object);
235    let pattern_props = s.get("patternProperties").and_then(Value::as_object);
236    let additional = s.get("additionalProperties");
237    for (k, v) in obj {
238        let child = format!("{path}/{}", escape(k));
239        let mut covered = false;
240        if let Some(sub) = props.and_then(|p| p.get(k)) {
241            covered = true;
242            walk(root, sub, v, &child, errs, depth);
243        }
244        if let Some(pp) = pattern_props {
245            for (pat, sub) in pp {
246                if literal_pattern_matches(pat, k) {
247                    covered = true;
248                    walk(root, sub, v, &child, errs, depth);
249                }
250            }
251        }
252        if !covered {
253            match additional {
254                Some(Value::Bool(false)) => {
255                    errs.push(format!("{}: unknown property {k:?}", ptr(path)))
256                }
257                Some(sub @ Value::Object(_)) => walk(root, sub, v, &child, errs, depth),
258                _ => {}
259            }
260        }
261    }
262    if let Some(n) = s.get("minProperties").and_then(Value::as_u64)
263        && (obj.len() as u64) < n
264    {
265        errs.push(format!("{}: at least {n} properties required", ptr(path)));
266    }
267    if let Some(n) = s.get("maxProperties").and_then(Value::as_u64)
268        && (obj.len() as u64) > n
269    {
270        errs.push(format!("{}: at most {n} properties allowed", ptr(path)));
271    }
272    if let Some(Value::Object(deps)) = s.get("dependentRequired") {
273        for (k, needs) in deps {
274            if obj.contains_key(k)
275                && let Some(needs) = needs.as_array()
276            {
277                for n in needs.iter().filter_map(Value::as_str) {
278                    if !obj.contains_key(n) {
279                        errs.push(format!("{}: property {k:?} requires {n:?}", ptr(path)));
280                    }
281                }
282            }
283        }
284    }
285}
286
287fn array_keywords(
288    root: &Value,
289    s: &Map<String, Value>,
290    arr: &[Value],
291    path: &str,
292    errs: &mut Vec<String>,
293    depth: &mut u32,
294) {
295    let prefix = s.get("prefixItems").and_then(Value::as_array);
296    let items = s.get("items");
297    for (i, v) in arr.iter().enumerate() {
298        let child = format!("{path}/{i}");
299        if let Some(p) = prefix.and_then(|p| p.get(i)) {
300            walk(root, p, v, &child, errs, depth);
301            continue;
302        }
303        match items {
304            Some(Value::Bool(false)) if prefix.is_some() => {
305                errs.push(format!("{}: no additional items allowed", ptr(&child)));
306            }
307            Some(sub @ (Value::Object(_) | Value::Bool(_))) => {
308                walk(root, sub, v, &child, errs, depth)
309            }
310            _ => {}
311        }
312    }
313    if let Some(n) = s.get("minItems").and_then(Value::as_u64)
314        && (arr.len() as u64) < n
315    {
316        errs.push(format!("{}: at least {n} items required", ptr(path)));
317    }
318    if let Some(n) = s.get("maxItems").and_then(Value::as_u64)
319        && (arr.len() as u64) > n
320    {
321        errs.push(format!("{}: at most {n} items allowed", ptr(path)));
322    }
323    if s.get("uniqueItems") == Some(&Value::Bool(true)) {
324        for i in 0..arr.len() {
325            if arr[i + 1..].contains(&arr[i]) {
326                errs.push(format!(
327                    "{}: items must be unique (duplicate at {i})",
328                    ptr(path)
329                ));
330                break;
331            }
332        }
333    }
334    if let Some(c) = s.get("contains") {
335        let any = arr.iter().any(|v| {
336            let mut e = Vec::new();
337            walk(root, c, v, path, &mut e, depth);
338            e.is_empty()
339        });
340        if !any {
341            errs.push(format!("{}: no item matches `contains`", ptr(path)));
342        }
343    }
344}
345
346fn string_keywords(s: &Map<String, Value>, st: &str, path: &str, errs: &mut Vec<String>) {
347    let len = st.chars().count() as u64;
348    if let Some(n) = s.get("minLength").and_then(Value::as_u64)
349        && len < n
350    {
351        errs.push(format!("{}: at least {n} characters required", ptr(path)));
352    }
353    if let Some(n) = s.get("maxLength").and_then(Value::as_u64)
354        && len > n
355    {
356        errs.push(format!("{}: at most {n} characters allowed", ptr(path)));
357    }
358    if let Some(Value::String(p)) = s.get("pattern")
359        && is_literal_pattern(p)
360        && !literal_pattern_matches(p, st)
361    {
362        errs.push(format!("{}: does not match pattern {p:?}", ptr(path)));
363    }
364}
365
366fn number_keywords(
367    s: &Map<String, Value>,
368    n: &serde_json::Number,
369    path: &str,
370    errs: &mut Vec<String>,
371) {
372    let Some(x) = n.as_f64() else { return };
373    let num = |k: &str| s.get(k).and_then(Value::as_f64);
374    if let Some(m) = num("minimum")
375        && x < m
376    {
377        errs.push(format!("{}: must be >= {m}", ptr(path)));
378    }
379    if let Some(m) = num("maximum")
380        && x > m
381    {
382        errs.push(format!("{}: must be <= {m}", ptr(path)));
383    }
384    if let Some(m) = num("exclusiveMinimum")
385        && x <= m
386    {
387        errs.push(format!("{}: must be > {m}", ptr(path)));
388    }
389    if let Some(m) = num("exclusiveMaximum")
390        && x >= m
391    {
392        errs.push(format!("{}: must be < {m}", ptr(path)));
393    }
394    if let Some(m) = num("multipleOf")
395        && m > 0.0
396    {
397        let q = x / m;
398        if (q - q.round()).abs() > 1e-9 {
399            errs.push(format!("{}: must be a multiple of {m}", ptr(path)));
400        }
401    }
402}
403
404fn type_matches(t: &str, v: &Value) -> bool {
405    match t {
406        "null" => v.is_null(),
407        "boolean" => v.is_boolean(),
408        "object" => v.is_object(),
409        "array" => v.is_array(),
410        "string" => v.is_string(),
411        "number" => v.is_number(),
412        "integer" => {
413            v.as_i64().is_some()
414                || v.as_u64().is_some()
415                || v.as_f64().is_some_and(|f| f.fract() == 0.0)
416        }
417        _ => true, // unknown type names are not enforced
418    }
419}
420
421fn type_name(v: &Value) -> &'static str {
422    match v {
423        Value::Null => "null",
424        Value::Bool(_) => "boolean",
425        Value::Number(_) => "number",
426        Value::String(_) => "string",
427        Value::Array(_) => "array",
428        Value::Object(_) => "object",
429    }
430}
431
432fn render_type(t: &Value) -> String {
433    match t {
434        Value::String(s) => s.clone(),
435        Value::Array(a) => a
436            .iter()
437            .filter_map(Value::as_str)
438            .collect::<Vec<_>>()
439            .join("|"),
440        _ => "?".into(),
441    }
442}
443
444fn short(v: &Value) -> String {
445    let s = v.to_string();
446    if s.len() > 80 {
447        let mut cut = 77;
448        while !s.is_char_boundary(cut) {
449            cut -= 1;
450        }
451        format!("{}…", &s[..cut])
452    } else {
453        s
454    }
455}
456
457fn escape(k: &str) -> String {
458    k.replace('~', "~0").replace('/', "~1")
459}
460
461/// Resolve `#`, `#/$defs/x`, `#/definitions/x`, or any `#/a/b` pointer.
462fn resolve_ref<'a>(root: &'a Value, r: &str) -> Option<&'a Value> {
463    let rest = r.strip_prefix('#')?;
464    if rest.is_empty() {
465        return Some(root);
466    }
467    let mut cur = root;
468    for seg in rest.trim_start_matches('/').split('/') {
469        let seg = seg.replace("~1", "/").replace("~0", "~");
470        cur = match cur {
471            Value::Object(m) => m.get(&seg)?,
472            Value::Array(a) => a.get(seg.parse::<usize>().ok()?)?,
473            _ => return None,
474        };
475    }
476    Some(cur)
477}
478
479/// The pattern shapes we can honour without a regex engine: an anchored
480/// literal (`^foo$`), an anchored literal prefix (`^foo`), an anchored literal
481/// suffix (`foo$`), a bare literal (substring), and `^.*$`/`.*` (anything). A
482/// pattern with any other metacharacter is not enforced (`is_literal_pattern`
483/// says so; `check_schema` warns).
484fn is_literal_pattern(p: &str) -> bool {
485    let core = p.trim_start_matches('^').trim_end_matches('$');
486    core == ".*" || !core.chars().any(|c| ".*+?()[]{}|\\".contains(c))
487}
488
489fn literal_pattern_matches(p: &str, s: &str) -> bool {
490    if !is_literal_pattern(p) {
491        return true; // not enforceable ⇒ permissive
492    }
493    let anchored_start = p.starts_with('^');
494    let anchored_end = p.ends_with('$');
495    let core = p.trim_start_matches('^').trim_end_matches('$');
496    if core == ".*" {
497        return true;
498    }
499    match (anchored_start, anchored_end) {
500        (true, true) => s == core,
501        (true, false) => s.starts_with(core),
502        (false, true) => s.ends_with(core),
503        (false, false) => s.contains(core),
504    }
505}
506
507fn check_schema_at(schema: &Value, path: &str, errs: &mut Vec<String>, depth: u32) {
508    if depth > MAX_DEPTH {
509        errs.push(format!("{}: schema nesting exceeds {MAX_DEPTH}", ptr(path)));
510        return;
511    }
512    let s = match schema {
513        Value::Bool(_) => return,
514        Value::Object(s) => s,
515        _ => {
516            errs.push(format!(
517                "{}: a schema must be an object or a boolean",
518                ptr(path)
519            ));
520            return;
521        }
522    };
523    let sub = |k: &str, v: &Value, errs: &mut Vec<String>| {
524        check_schema_at(v, &format!("{path}/{k}"), errs, depth + 1)
525    };
526    for (k, v) in s {
527        match k.as_str() {
528            "type" => {
529                let ok = match v {
530                    Value::String(t) => KNOWN_TYPES.contains(&t.as_str()),
531                    Value::Array(a) => a
532                        .iter()
533                        .all(|t| t.as_str().is_some_and(|t| KNOWN_TYPES.contains(&t))),
534                    _ => false,
535                };
536                if !ok {
537                    errs.push(format!(
538                        "{}/type: must be a known type name or a list of them",
539                        ptr(path)
540                    ));
541                }
542            }
543            "properties" | "patternProperties" | "$defs" | "definitions" => match v {
544                Value::Object(m) => {
545                    for (name, sv) in m {
546                        check_schema_at(
547                            sv,
548                            &format!("{path}/{k}/{}", escape(name)),
549                            errs,
550                            depth + 1,
551                        );
552                    }
553                }
554                _ => errs.push(format!("{}/{k}: must be an object of schemas", ptr(path))),
555            },
556            "items" | "additionalProperties" | "not" | "if" | "then" | "else" | "contains" => {
557                sub(k, v, errs)
558            }
559            "prefixItems" | "allOf" | "anyOf" | "oneOf" => match v {
560                Value::Array(a) => {
561                    for (i, sv) in a.iter().enumerate() {
562                        check_schema_at(sv, &format!("{path}/{k}/{i}"), errs, depth + 1);
563                    }
564                }
565                _ => errs.push(format!("{}/{k}: must be an array of schemas", ptr(path))),
566            },
567            "required" => {
568                if !v.as_array().is_some_and(|a| a.iter().all(Value::is_string)) {
569                    errs.push(format!(
570                        "{}/required: must be an array of property names",
571                        ptr(path)
572                    ));
573                }
574            }
575            "enum" => {
576                if !v.is_array() {
577                    errs.push(format!("{}/enum: must be an array", ptr(path)));
578                }
579            }
580            "minimum" | "maximum" | "exclusiveMinimum" | "exclusiveMaximum" | "multipleOf" => {
581                if !v.is_number() {
582                    errs.push(format!("{}/{k}: must be a number", ptr(path)));
583                }
584            }
585            "minLength" | "maxLength" | "minItems" | "maxItems" | "minProperties"
586            | "maxProperties" => {
587                if v.as_u64().is_none() {
588                    errs.push(format!("{}/{k}: must be a non-negative integer", ptr(path)));
589                }
590            }
591            "pattern" => {
592                if let Some(p) = v.as_str() {
593                    if !is_literal_pattern(p) {
594                        errs.push(format!(
595                            "{}/pattern: {p:?} uses regex features this validator does not enforce (literal, ^prefix, suffix$, ^exact$ only)",
596                            ptr(path)
597                        ));
598                    }
599                } else {
600                    errs.push(format!("{}/pattern: must be a string", ptr(path)));
601                }
602            }
603            "$ref" if !v.as_str().is_some_and(|r| r.starts_with('#')) => {
604                errs.push(format!(
605                    "{}/$ref: only local references (`#/...`) are supported",
606                    ptr(path)
607                ));
608            }
609            _ => {}
610        }
611    }
612}
613
614const KNOWN_TYPES: &[&str] = &[
615    "null", "boolean", "object", "array", "string", "number", "integer",
616];
617
618#[cfg(test)]
619mod tests {
620    use super::*;
621    use serde_json::json;
622
623    fn ok(s: &Value, v: &Value) {
624        if let Err(e) = validate(s, v) {
625            panic!("expected valid, got {e:?}");
626        }
627    }
628    fn bad(s: &Value, v: &Value) -> Vec<String> {
629        validate(s, v).expect_err("expected invalid")
630    }
631
632    #[test]
633    fn types_required_and_additional_properties() {
634        let s = json!({
635            "type": "object",
636            "properties": {
637                "name": {"type": "string", "minLength": 1},
638                "n": {"type": "integer", "minimum": 0},
639                "tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": true},
640                "mode": {"enum": ["a", "b"]},
641                "x": {"type": ["number", "null"]}
642            },
643            "required": ["name"],
644            "additionalProperties": false
645        });
646        ok(
647            &s,
648            &json!({"name": "k", "n": 3, "tags": ["a", "b"], "mode": "a", "x": null}),
649        );
650        ok(&s, &json!({"name": "k", "n": 3.0}));
651        let e = bad(
652            &s,
653            &json!({"n": -1.5, "tags": ["a", "a"], "mode": "z", "extra": 1, "x": "s"}),
654        );
655        let joined = e.join("\n");
656        assert!(
657            joined.contains("/: missing required property \"name\""),
658            "{joined}"
659        );
660        assert!(joined.contains("/n: expected type integer"), "{joined}");
661        assert!(joined.contains("/n: must be >= 0"), "{joined}");
662        assert!(joined.contains("/tags: items must be unique"), "{joined}");
663        assert!(
664            joined.contains("/mode: value is not one of the allowed values"),
665            "{joined}"
666        );
667        assert!(joined.contains("/: unknown property \"extra\""), "{joined}");
668        assert!(
669            joined.contains("/x: expected type number|null, got string"),
670            "{joined}"
671        );
672        // Boolean schemas.
673        ok(&json!(true), &json!(42));
674        assert!(!bad(&json!(false), &json!(42)).is_empty());
675        // Non-object with properties keyword is fine (keyword applies to objects only).
676        ok(
677            &json!({"properties": {"a": {"type": "string"}}}),
678            &json!("str"),
679        );
680    }
681
682    #[test]
683    fn combinators_refs_and_conditionals() {
684        let s = json!({
685            "$defs": {"pos": {"type": "integer", "exclusiveMinimum": 0}},
686            "type": "object",
687            "properties": {
688                "id": {"$ref": "#/$defs/pos"},
689                "kind": {"type": "string"},
690                "v": {"oneOf": [{"type": "string"}, {"type": "integer"}]},
691                "w": {"anyOf": [{"type": "string", "maxLength": 2}, {"type": "boolean"}]},
692                "z": {"not": {"const": 0}},
693                "self": {"$ref": "#"}
694            },
695            "if": {"properties": {"kind": {"const": "a"}}, "required": ["kind"]},
696            "then": {"required": ["v"]},
697            "else": {"required": ["w"]}
698        });
699        ok(&s, &json!({"id": 1, "kind": "a", "v": "x", "z": 1}));
700        ok(
701            &s,
702            &json!({"id": 2, "kind": "b", "w": true, "self": {"id": 3, "w": "ab"}}),
703        );
704        let e = bad(
705            &s,
706            &json!({"id": 0, "kind": "a", "w": "abc", "z": 0, "self": {"id": -1, "kind": "a"}}),
707        );
708        let joined = e.join("\n");
709        assert!(joined.contains("/id: must be > 0"), "{joined}");
710        assert!(
711            joined.contains("/: missing required property \"v\""),
712            "{joined}"
713        );
714        assert!(joined.contains("/z: must not match"), "{joined}");
715        assert!(joined.contains("/self/id: must be > 0"), "{joined}");
716        assert!(
717            joined.contains("/self: missing required property \"v\""),
718            "{joined}"
719        );
720        let e = bad(&s, &json!({"id": 1, "kind": "b", "w": "abc"}));
721        assert!(e.join("\n").contains("/w: matches none of anyOf"), "{e:?}");
722        let e = bad(&s, &json!({"id": 1, "kind": "b", "w": "a", "v": 1.5}));
723        assert!(
724            e.join("\n")
725                .contains("/v: must match exactly one of oneOf (matched 0"),
726            "{e:?}"
727        );
728        assert!(bad(&json!({"$ref": "#/$defs/nope"}), &json!(1))[0].contains("unresolvable $ref"));
729    }
730
731    #[test]
732    fn arrays_strings_numbers_and_patterns() {
733        let s = json!({
734            "type": "array",
735            "prefixItems": [{"type": "string"}, {"type": "number"}],
736            "items": {"type": "boolean"},
737            "minItems": 2, "maxItems": 4,
738            "contains": {"const": true}
739        });
740        ok(&s, &json!(["a", 1, true]));
741        let e = bad(&s, &json!(["a", "b", 1, false, true]));
742        let joined = e.join("\n");
743        assert!(joined.contains("/1: expected type number"), "{joined}");
744        assert!(joined.contains("/2: expected type boolean"), "{joined}");
745        assert!(joined.contains("/: at most 4 items"), "{joined}");
746        assert!(
747            bad(&s, &json!(["a", 1, false]))
748                .join("")
749                .contains("contains")
750        );
751        let s = json!({"type": "string", "pattern": "^agentd/", "maxLength": 12});
752        ok(&s, &json!("agentd/x"));
753        assert!(bad(&s, &json!("other/x"))[0].contains("pattern"));
754        assert!(bad(&s, &json!("agentd/toolongvalue"))[0].contains("at most 12"));
755        // Complex regex patterns are not enforced (permissive) but flagged by check_schema.
756        ok(&json!({"pattern": "^[a-z]+$"}), &json!("123"));
757        assert!(
758            check_schema(&json!({"pattern": "^[a-z]+$"})).unwrap_err()[0]
759                .contains("regex features")
760        );
761        let s = json!({"type": "number", "multipleOf": 0.5, "maximum": 10, "exclusiveMaximum": 10});
762        ok(&s, &json!(9.5));
763        let e = bad(&s, &json!(10));
764        assert!(e.iter().any(|m| m.contains("must be < 10")), "{e:?}");
765        assert!(bad(&s, &json!(9.3))[0].contains("multiple of 0.5"));
766        // patternProperties with a literal prefix.
767        let s = json!({"type": "object", "patternProperties": {"^x-": {"type": "string"}}, "additionalProperties": false});
768        ok(&s, &json!({"x-team": "ops"}));
769        assert!(bad(&s, &json!({"x-team": 1}))[0].contains("/x-team: expected type string"));
770        assert!(bad(&s, &json!({"team": "ops"}))[0].contains("unknown property"));
771        // dependentRequired
772        let s = json!({"type": "object", "dependentRequired": {"a": ["b"]}});
773        ok(&s, &json!({"a": 1, "b": 2}));
774        assert!(bad(&s, &json!({"a": 1}))[0].contains("requires \"b\""));
775        // integer accepts 3.0
776        ok(&json!({"type": "integer"}), &json!(3.0));
777        assert!(!bad(&json!({"type": "integer"}), &json!(3.5)).is_empty());
778    }
779
780    #[test]
781    fn schema_well_formedness() {
782        assert!(check_schema(&json!({"type": "object", "properties": {"a": {"type": "string"}}, "required": ["a"]})).is_ok());
783        let e = check_schema(&json!({"type": "strng", "properties": [], "required": "a", "items": 3, "minLength": -1, "$ref": "http://x"}))
784            .unwrap_err();
785        let joined = e.join("\n");
786        assert!(joined.contains("/type: must be a known type"), "{joined}");
787        assert!(
788            joined.contains("/properties: must be an object"),
789            "{joined}"
790        );
791        assert!(joined.contains("/required: must be an array"), "{joined}");
792        assert!(
793            joined.contains("/items: a schema must be an object or a boolean"),
794            "{joined}"
795        );
796        assert!(
797            joined.contains("/minLength: must be a non-negative integer"),
798            "{joined}"
799        );
800        assert!(joined.contains("/$ref: only local references"), "{joined}");
801        assert!(check_schema(&json!(true)).is_ok());
802    }
803}