Skip to main content

oino_agent/
validate.rs

1//! Tool-argument validation (`validateToolArguments`).
2//!
3//! Validation runs against a JSON schema after two normalisation passes: optional properties set to `null`
4//! are dropped, then primitives are coerced towards the schema type (`"5"` → `5`, `1` → `"1"`,
5//! `"true"` → `true`, …). Error messages and paths follow `TypeBox` so the text the model sees is
6//! golden-tested against reference fixtures.
7//!
8//! Supported keywords: `type`, `const`, `enum`, `minLength`, `maxLength`, `pattern`, `minimum`,
9//! `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`, `items`, `prefixItems`,
10//! `minItems`, `maxItems`, `uniqueItems`, `contains`, `properties`, `required`,
11//! `additionalProperties`, `minProperties`, `maxProperties`, `allOf`, `anyOf`, `oneOf`, `not`,
12//! local `$ref` (`#/$defs/…`, `#/definitions/…`) and boolean schemas. `format` is not checked.
13
14use oino_ai::{JsonObject, JsonValue, ToolCall};
15use regex::Regex;
16use serde_json::Number;
17
18/// Returns the coerced arguments or the error text.
19///
20/// # Errors
21///
22/// `Invalid arguments for tool "{name}": {argumentsError}` when the provider could not parse
23/// the arguments, or `Validation failed for tool "{name}":\n  - {path}: {message}…` followed by
24/// the received arguments.
25pub fn validate_tool_arguments(schema: &JsonValue, call: &ToolCall) -> Result<JsonObject, String> {
26    if let Some(error) = &call.arguments_error {
27        return Err(format!(
28            "Invalid arguments for tool \"{}\": {error}",
29            call.name
30        ));
31    }
32    let mut args = JsonValue::Object(call.arguments.clone());
33    normalize_optional_nulls(&mut args, schema, schema);
34    let args = coerce(args, schema, schema);
35    let errors = Validator::new(schema).errors(&args);
36    if errors.is_empty() {
37        return Ok(match args {
38            JsonValue::Object(map) => map,
39            _ => call.arguments.clone(),
40        });
41    }
42    let list = errors
43        .iter()
44        .map(|error| format!("  - {}: {}", error.path, error.message))
45        .collect::<Vec<_>>()
46        .join("\n");
47    let received = serde_json::to_string_pretty(&JsonValue::Object(call.arguments.clone()))
48        .unwrap_or_else(|_| "{}".to_owned());
49    Err(format!(
50        "Validation failed for tool \"{}\":\n{list}\n\nReceived arguments:\n{received}",
51        call.name
52    ))
53}
54
55/// One validation failure with a TypeBox-style dotted path.
56#[derive(Clone, Debug, PartialEq, Eq)]
57pub struct ValidationError {
58    /// Dotted path (`root` for the top level).
59    pub path: String,
60    /// `TypeBox` message.
61    pub message: String,
62}
63
64/// `true` when `value` satisfies `schema`.
65#[must_use]
66pub fn check(schema: &JsonValue, value: &JsonValue) -> bool {
67    Validator::new(schema).errors(value).is_empty()
68}
69
70fn resolve<'a>(root: &'a JsonValue, schema: &'a JsonValue) -> &'a JsonValue {
71    let mut current = schema;
72    for _ in 0..32 {
73        let Some(reference) = current.get("$ref").and_then(JsonValue::as_str) else {
74            return current;
75        };
76        let Some(pointer) = reference.strip_prefix('#') else {
77            return current;
78        };
79        match root.pointer(pointer) {
80            Some(target) => current = target,
81            None => return current,
82        }
83    }
84    current
85}
86
87fn schema_types(schema: &JsonValue) -> Vec<&str> {
88    match schema.get("type") {
89        Some(JsonValue::String(kind)) => vec![kind.as_str()],
90        Some(JsonValue::Array(kinds)) => kinds.iter().filter_map(JsonValue::as_str).collect(),
91        _ => Vec::new(),
92    }
93}
94
95fn matches_type(value: &JsonValue, kind: &str) -> bool {
96    match kind {
97        "number" => value.is_number(),
98        "integer" => value.as_f64().is_some_and(|n| n.fract() == 0.0),
99        "boolean" => value.is_boolean(),
100        "string" => value.is_string(),
101        "null" => value.is_null(),
102        "array" => value.is_array(),
103        "object" => value.is_object(),
104        _ => false,
105    }
106}
107
108fn normalize_optional_nulls(value: &mut JsonValue, schema: &JsonValue, root: &JsonValue) {
109    match value {
110        JsonValue::Array(items) => match schema.get("items") {
111            Some(JsonValue::Array(item_schemas)) => {
112                for (item, item_schema) in items.iter_mut().zip(item_schemas) {
113                    normalize_optional_nulls(item, item_schema, root);
114                }
115            }
116            Some(item_schema) => {
117                for item in items {
118                    normalize_optional_nulls(item, item_schema, root);
119                }
120            }
121            None => {}
122        },
123        JsonValue::Object(object) => {
124            let Some(JsonValue::Object(properties)) = schema.get("properties") else {
125                return;
126            };
127            let required: Vec<&str> = schema
128                .get("required")
129                .and_then(JsonValue::as_array)
130                .map(|r| r.iter().filter_map(JsonValue::as_str).collect())
131                .unwrap_or_default();
132            for (key, property_schema) in properties {
133                let Some(current) = object.get_mut(key) else {
134                    continue;
135                };
136                let removable = current.is_null()
137                    && !required.contains(&key.as_str())
138                    && property_schema.get("$ref").is_none()
139                    && !check_in(root, property_schema, &JsonValue::Null);
140                if removable {
141                    object.shift_remove(key);
142                } else {
143                    normalize_optional_nulls(current, property_schema, root);
144                }
145            }
146        }
147        _ => {}
148    }
149}
150
151fn check_in(root: &JsonValue, schema: &JsonValue, value: &JsonValue) -> bool {
152    let validator = Validator { root };
153    let mut errors = Vec::new();
154    validator.collect(schema, value, &mut Vec::new(), &mut errors);
155    errors.is_empty()
156}
157
158/// Coerces with the JSON schema (does not follow `$ref`).
159fn coerce(value: JsonValue, schema: &JsonValue, root: &JsonValue) -> JsonValue {
160    let mut next = value;
161    if let Some(JsonValue::Array(all)) = schema.get("allOf") {
162        for nested in all {
163            next = coerce(next, nested, root);
164        }
165    }
166    for union in ["anyOf", "oneOf"] {
167        if let Some(JsonValue::Array(members)) = schema.get(union) {
168            next = coerce_union(next, members, root);
169        }
170    }
171    let types = schema_types(schema);
172    let matches_member = types.len() > 1 && types.iter().any(|kind| matches_type(&next, kind));
173    if !types.is_empty() && !matches_member {
174        for kind in &types {
175            if let Some(candidate) = coerce_primitive(&next, kind) {
176                next = candidate;
177                break;
178            }
179        }
180    }
181    if types.contains(&"object")
182        && let JsonValue::Object(object) = &mut next
183    {
184        coerce_object(object, schema, root);
185    }
186    if types.contains(&"array")
187        && let JsonValue::Array(items) = &mut next
188    {
189        coerce_array(items, schema, root);
190    }
191    next
192}
193
194fn coerce_union(value: JsonValue, members: &[JsonValue], root: &JsonValue) -> JsonValue {
195    if members.iter().any(|member| check_in(root, member, &value)) {
196        return value;
197    }
198    for member in members {
199        let candidate = coerce(value.clone(), member, root);
200        if check_in(root, member, &candidate) {
201            return candidate;
202        }
203    }
204    value
205}
206
207fn coerce_object(object: &mut JsonObject, schema: &JsonValue, root: &JsonValue) {
208    let properties = schema.get("properties").and_then(JsonValue::as_object);
209    if let Some(properties) = properties {
210        for (key, property_schema) in properties {
211            if let Some(slot) = object.get_mut(key) {
212                *slot = coerce(slot.take(), property_schema, root);
213            }
214        }
215    }
216    if let Some(additional @ JsonValue::Object(_)) = schema.get("additionalProperties") {
217        for (key, slot) in object.iter_mut() {
218            if properties.is_some_and(|p| p.contains_key(key)) {
219                continue;
220            }
221            *slot = coerce(slot.take(), additional, root);
222        }
223    }
224}
225
226fn coerce_array(items: &mut [JsonValue], schema: &JsonValue, root: &JsonValue) {
227    match schema.get("items") {
228        Some(JsonValue::Array(item_schemas)) => {
229            for (item, item_schema) in items.iter_mut().zip(item_schemas) {
230                *item = coerce(item.take(), item_schema, root);
231            }
232        }
233        Some(item_schema @ JsonValue::Object(_)) => {
234            for item in items.iter_mut() {
235                *item = coerce(item.take(), item_schema, root);
236            }
237        }
238        _ => {}
239    }
240}
241
242/// Coerces a primitive by its schema type; `None` when the value is left unchanged.
243fn coerce_primitive(value: &JsonValue, kind: &str) -> Option<JsonValue> {
244    match (kind, value) {
245        ("number" | "integer", JsonValue::Null) => Some(JsonValue::from(0)),
246        ("number", JsonValue::String(text)) if !text.trim().is_empty() => js_to_number(text)
247            .filter(|n| n.is_finite())
248            .map(number_value),
249        ("integer", JsonValue::String(text)) if !text.trim().is_empty() => js_to_number(text)
250            .filter(|n| n.is_finite() && n.fract() == 0.0)
251            .map(number_value),
252        ("number" | "integer", JsonValue::Bool(flag)) => Some(JsonValue::from(u8::from(*flag))),
253        ("boolean", JsonValue::Null) => Some(JsonValue::Bool(false)),
254        ("boolean", JsonValue::String(text)) => match text.as_str() {
255            "true" => Some(JsonValue::Bool(true)),
256            "false" => Some(JsonValue::Bool(false)),
257            _ => None,
258        },
259        ("boolean", JsonValue::Number(n)) => match n.as_f64() {
260            Some(1.0) => Some(JsonValue::Bool(true)),
261            Some(0.0) => Some(JsonValue::Bool(false)),
262            _ => None,
263        },
264        ("string", JsonValue::Null) => Some(JsonValue::String(String::new())),
265        ("string", JsonValue::Number(n)) => Some(JsonValue::String(js_number_text(n))),
266        ("string", JsonValue::Bool(flag)) => Some(JsonValue::String(flag.to_string())),
267        ("null", JsonValue::String(text)) if text.is_empty() => Some(JsonValue::Null),
268        ("null", JsonValue::Bool(false)) => Some(JsonValue::Null),
269        ("null", JsonValue::Number(n)) if n.as_f64() == Some(0.0) => Some(JsonValue::Null),
270        _ => None,
271    }
272}
273
274/// JSON number for `value`, integral when JavaScript would print no fraction.
275#[allow(
276    clippy::cast_possible_truncation,
277    reason = "value is integral and within ±2^53, so the cast is exact"
278)]
279fn number_value(value: f64) -> JsonValue {
280    if value.fract() == 0.0 && value.abs() <= 9_007_199_254_740_991.0 {
281        JsonValue::from(value as i64)
282    } else {
283        Number::from_f64(value).map_or(JsonValue::Null, JsonValue::Number)
284    }
285}
286
287/// JavaScript `Number(text)`; `None` for `NaN`.
288fn js_to_number(text: &str) -> Option<f64> {
289    let trimmed = text.trim_matches(|c: char| c.is_whitespace() || c == '\u{feff}');
290    if trimmed.is_empty() {
291        return Some(0.0);
292    }
293    for (prefix, radix) in [
294        ("0x", 16),
295        ("0X", 16),
296        ("0o", 8),
297        ("0O", 8),
298        ("0b", 2),
299        ("0B", 2),
300    ] {
301        if let Some(digits) = trimmed.strip_prefix(prefix) {
302            return u64::from_str_radix(digits, radix).ok().map(|n| {
303                #[allow(clippy::cast_precision_loss, reason = "JavaScript numbers are f64")]
304                let n = n as f64;
305                n
306            });
307        }
308    }
309    let unsigned = trimmed.trim_start_matches(['+', '-']);
310    if unsigned == "Infinity" && trimmed.len() - unsigned.len() <= 1 {
311        return Some(if trimmed.starts_with('-') {
312            f64::NEG_INFINITY
313        } else {
314            f64::INFINITY
315        });
316    }
317    let valid = trimmed.len() - unsigned.len() <= 1
318        && !unsigned.is_empty()
319        && unsigned
320            .chars()
321            .all(|c| c.is_ascii_digit() || matches!(c, '.' | 'e' | 'E' | '+' | '-'))
322        && unsigned
323            .chars()
324            .next()
325            .is_some_and(|c| c.is_ascii_digit() || c == '.');
326    if !valid {
327        return None;
328    }
329    trimmed.parse::<f64>().ok()
330}
331
332/// JavaScript `String(number)`.
333fn js_number_text(number: &Number) -> String {
334    if number.is_i64() || number.is_u64() {
335        return number.to_string();
336    }
337    let Some(value) = number.as_f64() else {
338        return number.to_string();
339    };
340    js_f64_text(value)
341}
342
343fn js_f64_text(value: f64) -> String {
344    if value.fract() == 0.0 && value.abs() < 1e21 {
345        return format!("{value:.0}");
346    }
347    let magnitude = value.abs();
348    if (1e-7..1e21).contains(&magnitude) {
349        return format!("{value}");
350    }
351    let formatted = format!("{value:e}");
352    match formatted.split_once('e') {
353        Some((mantissa, exponent)) if !exponent.starts_with('-') => {
354            format!("{mantissa}e+{exponent}")
355        }
356        _ => formatted,
357    }
358}
359
360/// `(keyword, operator shown in the message, predicate)`.
361type NumberCheck = (&'static str, &'static str, fn(f64, f64) -> bool);
362
363/// Collection sizes as JavaScript numbers (exact below 2^53).
364#[allow(clippy::cast_precision_loss, reason = "sizes stay far below 2^53")]
365fn count_f64(count: usize) -> f64 {
366    count as f64
367}
368
369struct Validator<'a> {
370    root: &'a JsonValue,
371}
372
373impl<'a> Validator<'a> {
374    fn new(root: &'a JsonValue) -> Self {
375        Self { root }
376    }
377
378    fn errors(&self, value: &JsonValue) -> Vec<ValidationError> {
379        let mut errors = Vec::new();
380        self.collect(self.root, value, &mut Vec::new(), &mut errors);
381        errors
382    }
383
384    fn push(errors: &mut Vec<ValidationError>, path: &[String], message: String) {
385        let path = if path.is_empty() {
386            "root".to_owned()
387        } else {
388            path.join(".")
389        };
390        errors.push(ValidationError { path, message });
391    }
392
393    fn collect(
394        &self,
395        schema: &JsonValue,
396        value: &JsonValue,
397        path: &mut Vec<String>,
398        errors: &mut Vec<ValidationError>,
399    ) {
400        let schema = resolve(self.root, schema);
401        match schema {
402            JsonValue::Bool(false) => {
403                Self::push(errors, path, "schema is false".to_owned());
404                return;
405            }
406            JsonValue::Object(_) => {}
407            _ => return,
408        }
409        let types = schema_types(schema);
410        if !types.is_empty() && !types.iter().any(|kind| matches_type(value, kind)) {
411            Self::push(errors, path, format!("must be {}", types.join(" or ")));
412            return;
413        }
414        if let Some(expected) = schema.get("const")
415            && !json_equal(expected, value)
416        {
417            Self::push(errors, path, "must be equal to constant".to_owned());
418        }
419        if let Some(JsonValue::Array(allowed)) = schema.get("enum")
420            && !allowed.iter().any(|candidate| json_equal(candidate, value))
421        {
422            Self::push(
423                errors,
424                path,
425                "must be equal to one of the allowed values".to_owned(),
426            );
427        }
428        match value {
429            JsonValue::String(text) => Self::string_keywords(schema, text, path, errors),
430            JsonValue::Number(number) => {
431                Self::number_keywords(schema, number.as_f64().unwrap_or(0.0), path, errors);
432            }
433            JsonValue::Array(items) => self.array_keywords(schema, items, path, errors),
434            JsonValue::Object(object) => self.object_keywords(schema, object, path, errors),
435            _ => {}
436        }
437        self.combinators(schema, value, path, errors);
438    }
439
440    fn string_keywords(
441        schema: &JsonValue,
442        text: &str,
443        path: &[String],
444        errors: &mut Vec<ValidationError>,
445    ) {
446        let length = text.encode_utf16().count();
447        if let Some(min) = schema.get("minLength").and_then(JsonValue::as_f64)
448            && count_f64(length) < min
449        {
450            Self::push(
451                errors,
452                path,
453                format!("must not have fewer than {} characters", js_f64_text(min)),
454            );
455        }
456        if let Some(max) = schema.get("maxLength").and_then(JsonValue::as_f64)
457            && count_f64(length) > max
458        {
459            Self::push(
460                errors,
461                path,
462                format!("must not have more than {} characters", js_f64_text(max)),
463            );
464        }
465        if let Some(pattern) = schema.get("pattern").and_then(JsonValue::as_str)
466            && Regex::new(pattern).is_ok_and(|re| !re.is_match(text))
467        {
468            Self::push(errors, path, format!("must match pattern \"{pattern}\""));
469        }
470    }
471
472    fn number_keywords(
473        schema: &JsonValue,
474        number: f64,
475        path: &[String],
476        errors: &mut Vec<ValidationError>,
477    ) {
478        let bound = |key: &str| schema.get(key).and_then(JsonValue::as_f64);
479        let checks: [NumberCheck; 4] = [
480            ("minimum", ">=", |n, b| n >= b),
481            ("maximum", "<=", |n, b| n <= b),
482            ("exclusiveMinimum", ">", |n, b| n > b),
483            ("exclusiveMaximum", "<", |n, b| n < b),
484        ];
485        for (key, operator, ok) in checks {
486            if let Some(limit) = bound(key)
487                && !ok(number, limit)
488            {
489                Self::push(
490                    errors,
491                    path,
492                    format!("must be {operator} {}", js_f64_text(limit)),
493                );
494            }
495        }
496        if let Some(step) = bound("multipleOf")
497            && step != 0.0
498            && (number / step).fract() != 0.0
499        {
500            Self::push(
501                errors,
502                path,
503                format!("must be multiple of {}", js_f64_text(step)),
504            );
505        }
506    }
507
508    fn array_keywords(
509        &self,
510        schema: &JsonValue,
511        items: &[JsonValue],
512        path: &mut Vec<String>,
513        errors: &mut Vec<ValidationError>,
514    ) {
515        let count = count_f64(items.len());
516        if let Some(min) = schema.get("minItems").and_then(JsonValue::as_f64)
517            && count < min
518        {
519            Self::push(
520                errors,
521                path,
522                format!("must not have fewer than {} items", js_f64_text(min)),
523            );
524        }
525        if let Some(max) = schema.get("maxItems").and_then(JsonValue::as_f64)
526            && count > max
527        {
528            Self::push(
529                errors,
530                path,
531                format!("must not have more than {} items", js_f64_text(max)),
532            );
533        }
534        if schema.get("uniqueItems") == Some(&JsonValue::Bool(true)) {
535            let duplicate = items
536                .iter()
537                .enumerate()
538                .any(|(i, a)| items[i + 1..].iter().any(|b| json_equal(a, b)));
539            if duplicate {
540                Self::push(errors, path, "must not have duplicate items".to_owned());
541            }
542        }
543        if let Some(contains) = schema.get("contains")
544            && !items.iter().any(|item| check_in(self.root, contains, item))
545        {
546            Self::push(
547                errors,
548                path,
549                "must contain at least 1 valid item".to_owned(),
550            );
551        }
552        let prefix = match (schema.get("prefixItems"), schema.get("items")) {
553            (Some(JsonValue::Array(prefix)), _) | (None, Some(JsonValue::Array(prefix))) => {
554                Some(prefix.as_slice())
555            }
556            _ => None,
557        };
558        let rest = match schema.get("items") {
559            Some(rest @ (JsonValue::Object(_) | JsonValue::Bool(_))) => Some(rest),
560            _ => None,
561        };
562        for (index, item) in items.iter().enumerate() {
563            let item_schema =
564                prefix
565                    .and_then(|p| p.get(index))
566                    .or(if prefix.is_some() { None } else { rest });
567            if let Some(item_schema) = item_schema {
568                path.push(index.to_string());
569                self.collect(item_schema, item, path, errors);
570                path.pop();
571            }
572        }
573    }
574
575    fn object_keywords(
576        &self,
577        schema: &JsonValue,
578        object: &JsonObject,
579        path: &mut Vec<String>,
580        errors: &mut Vec<ValidationError>,
581    ) {
582        if let Some(JsonValue::Array(required)) = schema.get("required") {
583            let missing: Vec<&str> = required
584                .iter()
585                .filter_map(JsonValue::as_str)
586                .filter(|key| !object.contains_key(*key))
587                .collect();
588            if let Some(first) = missing.first() {
589                path.push((*first).to_owned());
590                Self::push(
591                    errors,
592                    path,
593                    format!("must have required properties {}", missing.join(", ")),
594                );
595                path.pop();
596            }
597        }
598        let properties = schema.get("properties").and_then(JsonValue::as_object);
599        if let Some(properties) = properties {
600            for (key, property_schema) in properties {
601                if let Some(property) = object.get(key) {
602                    path.push(key.clone());
603                    self.collect(property_schema, property, path, errors);
604                    path.pop();
605                }
606            }
607        }
608        if let Some(additional) = schema.get("additionalProperties") {
609            let extras: Vec<(&String, &JsonValue)> = object
610                .iter()
611                .filter(|(key, _)| !properties.is_some_and(|p| p.contains_key(*key)))
612                .collect();
613            let mut failed = false;
614            for (key, extra) in extras {
615                if additional == &JsonValue::Bool(true) {
616                    continue;
617                }
618                let before = errors.len();
619                path.push(key.clone());
620                self.collect(additional, extra, path, errors);
621                path.pop();
622                failed |= errors.len() > before;
623            }
624            if failed {
625                Self::push(
626                    errors,
627                    path,
628                    "must not have additional properties".to_owned(),
629                );
630            }
631        }
632        let count = count_f64(object.len());
633        if let Some(min) = schema.get("minProperties").and_then(JsonValue::as_f64)
634            && count < min
635        {
636            Self::push(
637                errors,
638                path,
639                format!("must not have fewer than {} properties", js_f64_text(min)),
640            );
641        }
642        if let Some(max) = schema.get("maxProperties").and_then(JsonValue::as_f64)
643            && count > max
644        {
645            Self::push(
646                errors,
647                path,
648                format!("must not have more than {} properties", js_f64_text(max)),
649            );
650        }
651    }
652
653    fn combinators(
654        &self,
655        schema: &JsonValue,
656        value: &JsonValue,
657        path: &mut Vec<String>,
658        errors: &mut Vec<ValidationError>,
659    ) {
660        if let Some(JsonValue::Array(all)) = schema.get("allOf") {
661            for member in all {
662                self.collect(member, value, path, errors);
663            }
664        }
665        if let Some(JsonValue::Array(any)) = schema.get("anyOf") {
666            let mut member_errors = Vec::new();
667            let mut matched = false;
668            for member in any {
669                let before = member_errors.len();
670                self.collect(member, value, path, &mut member_errors);
671                if member_errors.len() == before {
672                    matched = true;
673                    break;
674                }
675            }
676            if !matched {
677                errors.extend(member_errors);
678                Self::push(errors, path, "must match a schema in anyOf".to_owned());
679            }
680        }
681        if let Some(JsonValue::Array(one)) = schema.get("oneOf") {
682            let mut member_errors = Vec::new();
683            let mut matches = 0;
684            for member in one {
685                let before = member_errors.len();
686                self.collect(member, value, path, &mut member_errors);
687                if member_errors.len() == before {
688                    matches += 1;
689                }
690            }
691            if matches != 1 {
692                if matches == 0 {
693                    errors.extend(member_errors);
694                }
695                Self::push(
696                    errors,
697                    path,
698                    "must match exactly one schema in oneOf".to_owned(),
699                );
700            }
701        }
702        if let Some(not) = schema.get("not")
703            && check_in(self.root, not, value)
704        {
705            Self::push(errors, path, "must not be valid".to_owned());
706        }
707    }
708}
709
710/// Deep equality where numbers compare by value (`1 == 1.0`) like JavaScript.
711fn json_equal(left: &JsonValue, right: &JsonValue) -> bool {
712    match (left, right) {
713        (JsonValue::Number(a), JsonValue::Number(b)) => a.as_f64() == b.as_f64(),
714        (JsonValue::Array(a), JsonValue::Array(b)) => {
715            a.len() == b.len() && a.iter().zip(b).all(|(x, y)| json_equal(x, y))
716        }
717        (JsonValue::Object(a), JsonValue::Object(b)) => {
718            a.len() == b.len()
719                && a.iter()
720                    .all(|(key, x)| b.get(key).is_some_and(|y| json_equal(x, y)))
721        }
722        _ => left == right,
723    }
724}
725
726#[cfg(test)]
727mod tests {
728    use super::*;
729
730    #[test]
731    fn js_number_parsing() {
732        assert_eq!(js_to_number(" 3 "), Some(3.0));
733        assert_eq!(js_to_number("0x10"), Some(16.0));
734        assert_eq!(js_to_number("1e3"), Some(1000.0));
735        assert_eq!(js_to_number("abc"), None);
736        assert_eq!(js_to_number("inf"), None);
737        assert_eq!(js_to_number("Infinity"), Some(f64::INFINITY));
738        assert_eq!(js_to_number("-.5"), Some(-0.5));
739    }
740
741    #[test]
742    fn js_number_printing() {
743        assert_eq!(js_f64_text(1.5), "1.5");
744        assert_eq!(js_f64_text(1e21), "1e+21");
745        assert_eq!(js_f64_text(3.0), "3");
746    }
747}