Skip to main content

adk_core/
schema_utils.rs

1//! # Schema Utilities
2//!
3//! Shared utility functions for normalizing JSON Schema documents across
4//! multiple LLM provider adapters. Each function operates on `serde_json::Value`
5//! via mutable references for in-place transformation and recurses into nested
6//! schemas (properties, items, additionalProperties, allOf, anyOf, oneOf, etc.).
7//!
8//! These utilities are independently unit-testable and composable — each adapter
9//! selects which transforms to apply and in what order.
10//!
11//! ## Example
12//!
13//! ```rust
14//! use serde_json::json;
15//! use adk_core::schema_utils;
16//!
17//! let mut schema = json!({
18//!     "$schema": "http://json-schema.org/draft-07/schema#",
19//!     "type": "object",
20//!     "properties": {
21//!         "name": { "type": "string" }
22//!     }
23//! });
24//!
25//! schema_utils::strip_schema_keyword(&mut schema);
26//! assert!(schema.get("$schema").is_none());
27//! ```
28
29use std::borrow::Cow;
30
31use serde_json::{Map, Value};
32
33/// Removes the `$schema` keyword from the schema and all nested sub-schemas.
34///
35/// Many LLM providers reject schemas containing the `$schema` meta-keyword.
36///
37/// # Example
38///
39/// ```rust
40/// use serde_json::json;
41/// use adk_core::schema_utils::strip_schema_keyword;
42///
43/// let mut schema = json!({
44///     "$schema": "http://json-schema.org/draft-07/schema#",
45///     "type": "object",
46///     "properties": {
47///         "nested": {
48///             "$schema": "http://json-schema.org/draft-07/schema#",
49///             "type": "string"
50///         }
51///     }
52/// });
53///
54/// strip_schema_keyword(&mut schema);
55/// assert!(schema.get("$schema").is_none());
56/// ```
57pub fn strip_schema_keyword(schema: &mut Value) {
58    if let Some(obj) = schema.as_object_mut() {
59        obj.remove("$schema");
60    }
61    recurse_into_subschemas(schema, strip_schema_keyword);
62}
63
64/// Removes conditional keywords (`if`, `then`, `else`) from the schema and all
65/// nested sub-schemas.
66///
67/// Providers like Gemini, OpenAI, and Anthropic do not support JSON Schema
68/// conditional composition.
69///
70/// # Example
71///
72/// ```rust
73/// use serde_json::json;
74/// use adk_core::schema_utils::strip_conditional_keywords;
75///
76/// let mut schema = json!({
77///     "type": "object",
78///     "if": { "properties": { "kind": { "const": "a" } } },
79///     "then": { "required": ["extra"] },
80///     "else": { "required": [] }
81/// });
82///
83/// strip_conditional_keywords(&mut schema);
84/// assert!(schema.get("if").is_none());
85/// assert!(schema.get("then").is_none());
86/// assert!(schema.get("else").is_none());
87/// ```
88pub fn strip_conditional_keywords(schema: &mut Value) {
89    if let Some(obj) = schema.as_object_mut() {
90        obj.remove("if");
91        obj.remove("then");
92        obj.remove("else");
93    }
94    recurse_into_subschemas(schema, strip_conditional_keywords);
95}
96
97/// Adds `"type": "object"` when `properties` exists without a `type` field.
98///
99/// Some schema authors omit the explicit `type` when `properties` is present.
100/// Most LLM providers require the `type` field to be explicit.
101///
102/// # Example
103///
104/// ```rust
105/// use serde_json::json;
106/// use adk_core::schema_utils::add_implicit_object_type;
107///
108/// let mut schema = json!({
109///     "properties": {
110///         "name": { "type": "string" }
111///     }
112/// });
113///
114/// add_implicit_object_type(&mut schema);
115/// assert_eq!(schema["type"], "object");
116/// ```
117pub fn add_implicit_object_type(schema: &mut Value) {
118    if let Some(obj) = schema.as_object_mut()
119        && obj.contains_key("properties")
120        && !obj.contains_key("type")
121    {
122        obj.insert("type".to_string(), Value::String("object".to_string()));
123    }
124    recurse_into_subschemas(schema, add_implicit_object_type);
125}
126
127/// Converts `const` values to single-element `enum` arrays.
128///
129/// Providers that do not support the `const` keyword can still enforce fixed
130/// values via a single-element `enum`.
131///
132/// # Example
133///
134/// ```rust
135/// use serde_json::json;
136/// use adk_core::schema_utils::convert_const_to_enum;
137///
138/// let mut schema = json!({
139///     "type": "string",
140///     "const": "fixed_value"
141/// });
142///
143/// convert_const_to_enum(&mut schema);
144/// assert!(schema.get("const").is_none());
145/// assert_eq!(schema["enum"], json!(["fixed_value"]));
146/// ```
147pub fn convert_const_to_enum(schema: &mut Value) {
148    if let Some(obj) = schema.as_object_mut()
149        && let Some(const_val) = obj.remove("const")
150    {
151        obj.insert("enum".to_string(), Value::Array(vec![const_val]));
152    }
153    recurse_into_subschemas(schema, convert_const_to_enum);
154}
155
156/// Removes `format` values not in the `allowed` list from the schema and all
157/// nested sub-schemas.
158///
159/// Some providers reject schemas with unsupported format annotations. This
160/// function strips any `format` value not present in the provided allowlist.
161///
162/// # Arguments
163///
164/// * `schema` - The schema to transform in place.
165/// * `allowed` - Slice of allowed format strings (e.g., `&["date-time", "email"]`).
166///
167/// # Example
168///
169/// ```rust
170/// use serde_json::json;
171/// use adk_core::schema_utils::strip_unsupported_formats;
172///
173/// let mut schema = json!({
174///     "type": "string",
175///     "format": "hostname"
176/// });
177///
178/// strip_unsupported_formats(&mut schema, &["date-time", "email", "uri"]);
179/// assert!(schema.get("format").is_none());
180/// ```
181pub fn strip_unsupported_formats(schema: &mut Value, allowed: &[&str]) {
182    if let Some(obj) = schema.as_object_mut() {
183        let should_remove =
184            obj.get("format").and_then(|f| f.as_str()).is_some_and(|f| !allowed.contains(&f));
185        if should_remove {
186            obj.remove("format");
187        }
188    }
189    // Recurse manually since we need to pass `allowed` through
190    recurse_into_subschemas_with_context(schema, allowed, strip_unsupported_formats);
191}
192
193/// Truncates a tool name to at most `max_bytes` bytes, preserving valid UTF-8.
194///
195/// If the name is already within the limit, returns a borrowed reference.
196/// Otherwise, truncates at the nearest character boundary at or before
197/// `max_bytes` and returns an owned string.
198///
199/// # Arguments
200///
201/// * `name` - The tool name to potentially truncate.
202/// * `max_bytes` - Maximum byte length for the result.
203///
204/// # Example
205///
206/// ```rust
207/// use adk_core::schema_utils::truncate_tool_name;
208///
209/// let short = truncate_tool_name("hello", 64);
210/// assert_eq!(short, "hello");
211///
212/// let long = "a".repeat(100);
213/// let truncated = truncate_tool_name(&long, 64);
214/// assert_eq!(truncated.len(), 64);
215/// ```
216pub fn truncate_tool_name(name: &str, max_bytes: usize) -> Cow<'_, str> {
217    if name.len() <= max_bytes {
218        Cow::Borrowed(name)
219    } else {
220        // Find the nearest char boundary at or before max_bytes
221        let mut end = max_bytes;
222        while end > 0 && !name.is_char_boundary(end) {
223            end -= 1;
224        }
225        Cow::Owned(name[..end].to_string())
226    }
227}
228
229/// Removes JSON `null` values from `enum` arrays in the schema and all nested
230/// sub-schemas.
231///
232/// If removing null results in an empty `enum` array, the `enum` keyword is
233/// removed entirely.
234///
235/// # Example
236///
237/// ```rust
238/// use serde_json::json;
239/// use adk_core::schema_utils::strip_null_from_enum;
240///
241/// let mut schema = json!({
242///     "type": "string",
243///     "enum": ["a", null, "b"]
244/// });
245///
246/// strip_null_from_enum(&mut schema);
247/// assert_eq!(schema["enum"], json!(["a", "b"]));
248/// ```
249pub fn strip_null_from_enum(schema: &mut Value) {
250    if let Some(obj) = schema.as_object_mut()
251        && let Some(enum_val) = obj.get_mut("enum")
252        && let Some(arr) = enum_val.as_array_mut()
253    {
254        arr.retain(|v| !v.is_null());
255        if arr.is_empty() {
256            obj.remove("enum");
257        }
258    }
259    recurse_into_subschemas(schema, strip_null_from_enum);
260}
261
262/// Resolves `$ref` references by inlining the referenced sub-schema from a
263/// definitions map.
264///
265/// Handles both `#/definitions/<name>` (Draft 4–7) and `#/$defs/<name>`
266/// (Draft 2019-09+) reference formats. Unresolvable references are replaced
267/// with `{"type": "object"}`. Circular reference chains are broken by
268/// replacing the schema with `{"type": "object"}` when `depth` exceeds 10.
269///
270/// After resolving a `$ref`, the function recurses into the inlined schema
271/// (incrementing depth) to resolve any nested references. When no `$ref` is
272/// present, the function recurses into all sub-schemas (properties, items,
273/// allOf, anyOf, oneOf, etc.).
274///
275/// # Arguments
276///
277/// * `schema` - The schema to transform in place.
278/// * `definitions` - A map of definition names to their sub-schemas (combined
279///   from both `definitions` and `$defs` at the top level).
280/// * `depth` - Current recursion depth. Pass `0` for the initial call.
281///
282/// # Example
283///
284/// ```rust
285/// use serde_json::{json, Map, Value};
286/// use adk_core::schema_utils::resolve_refs;
287///
288/// let mut defs = Map::new();
289/// defs.insert("Address".to_string(), json!({"type": "object", "properties": {"street": {"type": "string"}}}));
290///
291/// let mut schema = json!({
292///     "type": "object",
293///     "properties": {
294///         "home": { "$ref": "#/definitions/Address" }
295///     }
296/// });
297///
298/// resolve_refs(&mut schema, &defs, 0);
299/// assert_eq!(schema["properties"]["home"]["type"], "object");
300/// assert!(schema["properties"]["home"].get("$ref").is_none());
301/// ```
302pub fn resolve_refs(schema: &mut Value, definitions: &Map<String, Value>, depth: usize) {
303    // Break circular chains at max depth 10
304    if depth > 10 {
305        // Only replace if this schema itself has a $ref (circular chain detected)
306        if schema.as_object().is_some_and(|obj| obj.contains_key("$ref")) {
307            *schema = serde_json::json!({"type": "object"});
308        }
309        return;
310    }
311
312    let Some(obj) = schema.as_object() else {
313        return;
314    };
315
316    if let Some(ref_val) = obj.get("$ref").and_then(|v| v.as_str()) {
317        // Parse the ref path: #/definitions/<name> or #/$defs/<name>
318        let name =
319            ref_val.strip_prefix("#/definitions/").or_else(|| ref_val.strip_prefix("#/$defs/"));
320
321        if let Some(def_name) = name {
322            if let Some(def_schema) = definitions.get(def_name) {
323                // Replace the entire schema node with the referenced sub-schema
324                *schema = def_schema.clone();
325            } else {
326                // Unresolvable ref — replace with fallback
327                *schema = serde_json::json!({"type": "object"});
328            }
329        } else {
330            // Unsupported ref format — replace with fallback
331            *schema = serde_json::json!({"type": "object"});
332        }
333
334        // Recursively resolve refs in the inlined schema
335        resolve_refs(schema, definitions, depth + 1);
336    } else {
337        // No $ref — recurse into all sub-schemas
338        resolve_refs_recurse(schema, definitions, depth);
339    }
340}
341
342/// Recurses into all sub-schema locations to resolve nested `$ref` references.
343fn resolve_refs_recurse(schema: &mut Value, definitions: &Map<String, Value>, depth: usize) {
344    let Some(obj) = schema.as_object_mut() else {
345        return;
346    };
347
348    // properties
349    if let Some(props) = obj.get_mut("properties")
350        && let Some(props_obj) = props.as_object_mut()
351    {
352        for value in props_obj.values_mut() {
353            resolve_refs(value, definitions, depth);
354        }
355    }
356
357    // items (single schema or array)
358    if let Some(items) = obj.get_mut("items") {
359        if items.is_object() {
360            resolve_refs(items, definitions, depth);
361        } else if let Some(arr) = items.as_array_mut() {
362            for item in arr.iter_mut() {
363                resolve_refs(item, definitions, depth);
364            }
365        }
366    }
367
368    // additionalProperties (when it's a schema object, not a boolean)
369    if let Some(additional) = obj.get_mut("additionalProperties")
370        && additional.is_object()
371    {
372        resolve_refs(additional, definitions, depth);
373    }
374
375    // allOf, anyOf, oneOf
376    for keyword in &["allOf", "anyOf", "oneOf"] {
377        if let Some(arr_val) = obj.get_mut(*keyword)
378            && let Some(arr) = arr_val.as_array_mut()
379        {
380            for sub in arr.iter_mut() {
381                resolve_refs(sub, definitions, depth);
382            }
383        }
384    }
385
386    // not
387    if let Some(not_schema) = obj.get_mut("not")
388        && not_schema.is_object()
389    {
390        resolve_refs(not_schema, definitions, depth);
391    }
392
393    // patternProperties
394    if let Some(pattern_props) = obj.get_mut("patternProperties")
395        && let Some(pp_obj) = pattern_props.as_object_mut()
396    {
397        for value in pp_obj.values_mut() {
398            resolve_refs(value, definitions, depth);
399        }
400    }
401
402    // prefixItems
403    if let Some(prefix_items) = obj.get_mut("prefixItems")
404        && let Some(arr) = prefix_items.as_array_mut()
405    {
406        for item in arr.iter_mut() {
407            resolve_refs(item, definitions, depth);
408        }
409    }
410
411    // if, then, else
412    for keyword in &["if", "then", "else"] {
413        if let Some(sub) = obj.get_mut(*keyword)
414            && sub.is_object()
415        {
416            resolve_refs(sub, definitions, depth);
417        }
418    }
419}
420
421/// Recursively applies a transform function to all nested sub-schemas.
422///
423/// This traverses into:
424/// - `properties` (each property value)
425/// - `items` (single schema or array of schemas)
426/// - `additionalProperties` (when it's a schema object)
427/// - `allOf`, `anyOf`, `oneOf` (each sub-schema in the array)
428/// - `not` (single sub-schema)
429/// - `patternProperties` (each property value)
430/// - `prefixItems` (each sub-schema in the array)
431/// - `if`, `then`, `else` (single sub-schemas)
432fn recurse_into_subschemas(schema: &mut Value, transform: fn(&mut Value)) {
433    let Some(obj) = schema.as_object_mut() else {
434        return;
435    };
436
437    // properties
438    if let Some(props) = obj.get_mut("properties")
439        && let Some(props_obj) = props.as_object_mut()
440    {
441        for value in props_obj.values_mut() {
442            transform(value);
443        }
444    }
445
446    // items (single schema or array)
447    if let Some(items) = obj.get_mut("items") {
448        if items.is_object() {
449            transform(items);
450        } else if let Some(arr) = items.as_array_mut() {
451            for item in arr.iter_mut() {
452                transform(item);
453            }
454        }
455    }
456
457    // additionalProperties (when it's a schema object, not a boolean)
458    if let Some(additional) = obj.get_mut("additionalProperties")
459        && additional.is_object()
460    {
461        transform(additional);
462    }
463
464    // allOf, anyOf, oneOf
465    for keyword in &["allOf", "anyOf", "oneOf"] {
466        if let Some(arr_val) = obj.get_mut(*keyword)
467            && let Some(arr) = arr_val.as_array_mut()
468        {
469            for sub in arr.iter_mut() {
470                transform(sub);
471            }
472        }
473    }
474
475    // not
476    if let Some(not_schema) = obj.get_mut("not")
477        && not_schema.is_object()
478    {
479        transform(not_schema);
480    }
481
482    // patternProperties
483    if let Some(pattern_props) = obj.get_mut("patternProperties")
484        && let Some(pp_obj) = pattern_props.as_object_mut()
485    {
486        for value in pp_obj.values_mut() {
487            transform(value);
488        }
489    }
490
491    // prefixItems
492    if let Some(prefix_items) = obj.get_mut("prefixItems")
493        && let Some(arr) = prefix_items.as_array_mut()
494    {
495        for item in arr.iter_mut() {
496            transform(item);
497        }
498    }
499
500    // if, then, else (for transforms that don't strip them)
501    for keyword in &["if", "then", "else"] {
502        if let Some(sub) = obj.get_mut(*keyword)
503            && sub.is_object()
504        {
505            transform(sub);
506        }
507    }
508}
509
510/// Recursively applies a transform function with additional context to all nested sub-schemas.
511///
512/// Same traversal as [`recurse_into_subschemas`] but passes a context parameter through.
513fn recurse_into_subschemas_with_context<C: ?Sized>(
514    schema: &mut Value,
515    ctx: &C,
516    transform: fn(&mut Value, &C),
517) {
518    let Some(obj) = schema.as_object_mut() else {
519        return;
520    };
521
522    // properties
523    if let Some(props) = obj.get_mut("properties")
524        && let Some(props_obj) = props.as_object_mut()
525    {
526        for value in props_obj.values_mut() {
527            transform(value, ctx);
528        }
529    }
530
531    // items (single schema or array)
532    if let Some(items) = obj.get_mut("items") {
533        if items.is_object() {
534            transform(items, ctx);
535        } else if let Some(arr) = items.as_array_mut() {
536            for item in arr.iter_mut() {
537                transform(item, ctx);
538            }
539        }
540    }
541
542    // additionalProperties (when it's a schema object, not a boolean)
543    if let Some(additional) = obj.get_mut("additionalProperties")
544        && additional.is_object()
545    {
546        transform(additional, ctx);
547    }
548
549    // allOf, anyOf, oneOf
550    for keyword in &["allOf", "anyOf", "oneOf"] {
551        if let Some(arr_val) = obj.get_mut(*keyword)
552            && let Some(arr) = arr_val.as_array_mut()
553        {
554            for sub in arr.iter_mut() {
555                transform(sub, ctx);
556            }
557        }
558    }
559
560    // not
561    if let Some(not_schema) = obj.get_mut("not")
562        && not_schema.is_object()
563    {
564        transform(not_schema, ctx);
565    }
566
567    // patternProperties
568    if let Some(pattern_props) = obj.get_mut("patternProperties")
569        && let Some(pp_obj) = pattern_props.as_object_mut()
570    {
571        for value in pp_obj.values_mut() {
572            transform(value, ctx);
573        }
574    }
575
576    // prefixItems
577    if let Some(prefix_items) = obj.get_mut("prefixItems")
578        && let Some(arr) = prefix_items.as_array_mut()
579    {
580        for item in arr.iter_mut() {
581            transform(item, ctx);
582        }
583    }
584
585    // if, then, else
586    for keyword in &["if", "then", "else"] {
587        if let Some(sub) = obj.get_mut(*keyword)
588            && sub.is_object()
589        {
590            transform(sub, ctx);
591        }
592    }
593}
594
595/// Collapses `anyOf`/`oneOf` arrays to the first non-null sub-schema.
596///
597/// For each `anyOf` or `oneOf` array, finds the first sub-schema that is NOT
598/// `{"type": "null"}`, merges its fields into the parent schema, and removes
599/// the combiner key. If all sub-schemas are null, uses the first one.
600///
601/// Recurses into nested schemas after collapsing.
602///
603/// # Example
604///
605/// ```rust
606/// use serde_json::json;
607/// use adk_core::schema_utils::collapse_combiners;
608///
609/// let mut schema = json!({
610///     "anyOf": [
611///         {"type": "null"},
612///         {"type": "string", "minLength": 1}
613///     ]
614/// });
615///
616/// collapse_combiners(&mut schema);
617/// assert_eq!(schema["type"], "string");
618/// assert_eq!(schema["minLength"], 1);
619/// assert!(schema.get("anyOf").is_none());
620/// ```
621pub fn collapse_combiners(schema: &mut Value) {
622    let Some(obj) = schema.as_object_mut() else {
623        return;
624    };
625
626    for keyword in &["anyOf", "oneOf"] {
627        if let Some(arr_val) = obj.remove(*keyword) {
628            if let Some(arr) = arr_val.as_array() {
629                // Find the first non-null sub-schema
630                let chosen = arr.iter().find(|sub| !is_null_schema(sub)).or_else(|| arr.first());
631
632                if let Some(chosen_schema) = chosen
633                    && let Some(chosen_obj) = chosen_schema.as_object()
634                {
635                    // Merge chosen sub-schema fields into parent
636                    for (key, value) in chosen_obj {
637                        obj.insert(key.clone(), value.clone());
638                    }
639                }
640            }
641            // Only process one combiner keyword per level
642            break;
643        }
644    }
645
646    recurse_into_subschemas(schema, collapse_combiners);
647}
648
649/// Merges `allOf` arrays by combining all sub-schemas into a single schema.
650///
651/// Combines `properties`, `required`, and other fields from all sub-schemas.
652/// If `type` conflicts across sub-schemas, prefers `"object"`. Removes the
653/// `allOf` key after merging.
654///
655/// Recurses into nested schemas after merging.
656///
657/// # Example
658///
659/// ```rust
660/// use serde_json::json;
661/// use adk_core::schema_utils::merge_all_of;
662///
663/// let mut schema = json!({
664///     "allOf": [
665///         {"type": "object", "properties": {"a": {"type": "string"}}},
666///         {"properties": {"b": {"type": "number"}}, "required": ["b"]}
667///     ]
668/// });
669///
670/// merge_all_of(&mut schema);
671/// assert!(schema.get("allOf").is_none());
672/// assert_eq!(schema["properties"]["a"]["type"], "string");
673/// assert_eq!(schema["properties"]["b"]["type"], "number");
674/// assert_eq!(schema["required"], json!(["b"]));
675/// ```
676pub fn merge_all_of(schema: &mut Value) {
677    let Some(obj) = schema.as_object_mut() else {
678        return;
679    };
680
681    if let Some(arr_val) = obj.remove("allOf")
682        && let Some(arr) = arr_val.as_array()
683    {
684        let mut merged_properties = Map::new();
685        let mut merged_required: Vec<Value> = Vec::new();
686        let mut merged_type: Option<Value> = None;
687        let mut other_fields = Map::new();
688
689        for sub in arr {
690            let Some(sub_obj) = sub.as_object() else {
691                continue;
692            };
693
694            for (key, value) in sub_obj {
695                match key.as_str() {
696                    "properties" => {
697                        if let Some(props) = value.as_object() {
698                            for (pk, pv) in props {
699                                merged_properties.insert(pk.clone(), pv.clone());
700                            }
701                        }
702                    }
703                    "required" => {
704                        if let Some(req_arr) = value.as_array() {
705                            for item in req_arr {
706                                if !merged_required.contains(item) {
707                                    merged_required.push(item.clone());
708                                }
709                            }
710                        }
711                    }
712                    "type" => {
713                        if let Some(existing) = &merged_type {
714                            // Conflict: prefer "object"
715                            if existing != value {
716                                merged_type = Some(Value::String("object".to_string()));
717                            }
718                        } else {
719                            merged_type = Some(value.clone());
720                        }
721                    }
722                    _ => {
723                        other_fields.insert(key.clone(), value.clone());
724                    }
725                }
726            }
727        }
728
729        // Merge other fields first (lower priority)
730        for (key, value) in other_fields {
731            obj.entry(key).or_insert(value);
732        }
733
734        // Merge type
735        if let Some(type_val) = merged_type {
736            obj.insert("type".to_string(), type_val);
737        }
738
739        // Merge properties
740        if !merged_properties.is_empty() {
741            let existing_props =
742                obj.entry("properties").or_insert_with(|| Value::Object(Map::new()));
743            if let Some(existing_obj) = existing_props.as_object_mut() {
744                for (key, value) in merged_properties {
745                    existing_obj.insert(key, value);
746                }
747            }
748        }
749
750        // Merge required
751        if !merged_required.is_empty() {
752            let existing_required =
753                obj.entry("required").or_insert_with(|| Value::Array(Vec::new()));
754            if let Some(existing_arr) = existing_required.as_array_mut() {
755                for item in merged_required {
756                    if !existing_arr.contains(&item) {
757                        existing_arr.push(item);
758                    }
759                }
760            }
761        }
762    }
763
764    recurse_into_subschemas(schema, merge_all_of);
765}
766
767/// Collapses type arrays to the first non-null type string.
768///
769/// When `type` is an array like `["string", "null"]`, collapses to the first
770/// non-null type string (e.g., `"string"`). If all types are `"null"`, uses
771/// `"null"`.
772///
773/// Recurses into nested schemas.
774///
775/// # Example
776///
777/// ```rust
778/// use serde_json::json;
779/// use adk_core::schema_utils::collapse_type_arrays;
780///
781/// let mut schema = json!({
782///     "type": ["string", "null"]
783/// });
784///
785/// collapse_type_arrays(&mut schema);
786/// assert_eq!(schema["type"], "string");
787/// ```
788pub fn collapse_type_arrays(schema: &mut Value) {
789    if let Some(obj) = schema.as_object_mut()
790        && let Some(type_val) = obj.get("type").cloned()
791        && let Some(arr) = type_val.as_array()
792    {
793        let chosen = arr.iter().find(|t| t.as_str() != Some("null")).or_else(|| arr.first());
794
795        if let Some(chosen_type) = chosen {
796            obj.insert("type".to_string(), chosen_type.clone());
797        }
798    }
799    recurse_into_subschemas(schema, collapse_type_arrays);
800}
801
802/// Enforces a maximum nesting depth for object schemas.
803///
804/// Tracks nesting depth through object schemas. When `current >= max_depth`,
805/// replaces the schema with `{"type": "object"}` and emits a `tracing::warn!()`
806/// log. Recurses into properties/items/etc, incrementing depth for object schemas.
807///
808/// # Arguments
809///
810/// * `schema` - The schema to enforce depth on.
811/// * `max_depth` - Maximum allowed nesting depth.
812/// * `current` - Current depth (start at 0).
813///
814/// # Example
815///
816/// ```rust
817/// use serde_json::json;
818/// use adk_core::schema_utils::enforce_nesting_depth;
819///
820/// let mut schema = json!({
821///     "type": "object",
822///     "properties": {
823///         "level1": {
824///             "type": "object",
825///             "properties": {
826///                 "level2": { "type": "string" }
827///             }
828///         }
829///     }
830/// });
831///
832/// enforce_nesting_depth(&mut schema, 1, 0);
833/// // level1 is at depth 1, so it gets replaced
834/// assert_eq!(schema["properties"]["level1"], json!({"type": "object"}));
835/// ```
836pub fn enforce_nesting_depth(schema: &mut Value, max_depth: usize, current: usize) {
837    let Some(obj) = schema.as_object_mut() else {
838        return;
839    };
840
841    let is_object_schema = obj.get("type").and_then(|t| t.as_str()).is_some_and(|t| t == "object")
842        || obj.contains_key("properties");
843
844    if is_object_schema && current >= max_depth {
845        tracing::warn!(
846            depth = current,
847            max_depth,
848            "schema nesting depth exceeded, truncating to {{\"type\": \"object\"}}"
849        );
850        *schema = serde_json::json!({"type": "object"});
851        return;
852    }
853
854    let next_depth = if is_object_schema { current + 1 } else { current };
855
856    // Recurse into properties
857    if let Some(props) = obj.get_mut("properties")
858        && let Some(props_obj) = props.as_object_mut()
859    {
860        for value in props_obj.values_mut() {
861            enforce_nesting_depth(value, max_depth, next_depth);
862        }
863    }
864
865    // Recurse into items
866    if let Some(items) = obj.get_mut("items") {
867        if items.is_object() {
868            enforce_nesting_depth(items, max_depth, next_depth);
869        } else if let Some(arr) = items.as_array_mut() {
870            for item in arr.iter_mut() {
871                enforce_nesting_depth(item, max_depth, next_depth);
872            }
873        }
874    }
875
876    // Recurse into additionalProperties
877    if let Some(additional) = obj.get_mut("additionalProperties")
878        && additional.is_object()
879    {
880        enforce_nesting_depth(additional, max_depth, next_depth);
881    }
882
883    // Recurse into allOf, anyOf, oneOf
884    for keyword in &["allOf", "anyOf", "oneOf"] {
885        if let Some(arr_val) = obj.get_mut(*keyword)
886            && let Some(arr) = arr_val.as_array_mut()
887        {
888            for sub in arr.iter_mut() {
889                enforce_nesting_depth(sub, max_depth, next_depth);
890            }
891        }
892    }
893
894    // Recurse into not
895    if let Some(not_schema) = obj.get_mut("not")
896        && not_schema.is_object()
897    {
898        enforce_nesting_depth(not_schema, max_depth, next_depth);
899    }
900
901    // Recurse into patternProperties
902    if let Some(pattern_props) = obj.get_mut("patternProperties")
903        && let Some(pp_obj) = pattern_props.as_object_mut()
904    {
905        for value in pp_obj.values_mut() {
906            enforce_nesting_depth(value, max_depth, next_depth);
907        }
908    }
909}
910
911/// Returns `true` if the schema represents a null type.
912///
913/// Matches `{"type": "null"}` exactly (with no other fields) or schemas
914/// where the only meaningful content is `type: null`.
915fn is_null_schema(schema: &Value) -> bool {
916    schema
917        .as_object()
918        .and_then(|obj| obj.get("type"))
919        .and_then(|t| t.as_str())
920        .is_some_and(|t| t == "null")
921}
922
923#[cfg(test)]
924mod tests {
925    use super::*;
926    use serde_json::json;
927
928    // --- strip_schema_keyword tests ---
929
930    #[test]
931    fn test_strip_schema_keyword_top_level() {
932        let mut schema = json!({
933            "$schema": "http://json-schema.org/draft-07/schema#",
934            "type": "object"
935        });
936        strip_schema_keyword(&mut schema);
937        assert!(schema.get("$schema").is_none());
938        assert_eq!(schema["type"], "object");
939    }
940
941    #[test]
942    fn test_strip_schema_keyword_nested() {
943        let mut schema = json!({
944            "type": "object",
945            "properties": {
946                "child": {
947                    "$schema": "http://json-schema.org/draft-07/schema#",
948                    "type": "string"
949                }
950            }
951        });
952        strip_schema_keyword(&mut schema);
953        assert!(schema["properties"]["child"].get("$schema").is_none());
954    }
955
956    #[test]
957    fn test_strip_schema_keyword_no_op_when_absent() {
958        let mut schema = json!({"type": "string"});
959        let expected = schema.clone();
960        strip_schema_keyword(&mut schema);
961        assert_eq!(schema, expected);
962    }
963
964    // --- strip_conditional_keywords tests ---
965
966    #[test]
967    fn test_strip_conditional_keywords() {
968        let mut schema = json!({
969            "type": "object",
970            "if": { "properties": { "kind": { "const": "a" } } },
971            "then": { "required": ["extra"] },
972            "else": { "required": [] },
973            "properties": { "kind": { "type": "string" } }
974        });
975        strip_conditional_keywords(&mut schema);
976        assert!(schema.get("if").is_none());
977        assert!(schema.get("then").is_none());
978        assert!(schema.get("else").is_none());
979        assert!(schema.get("properties").is_some());
980    }
981
982    #[test]
983    fn test_strip_conditional_keywords_nested() {
984        let mut schema = json!({
985            "type": "object",
986            "properties": {
987                "child": {
988                    "type": "object",
989                    "if": { "const": true },
990                    "then": { "type": "string" }
991                }
992            }
993        });
994        strip_conditional_keywords(&mut schema);
995        assert!(schema["properties"]["child"].get("if").is_none());
996        assert!(schema["properties"]["child"].get("then").is_none());
997    }
998
999    // --- add_implicit_object_type tests ---
1000
1001    #[test]
1002    fn test_add_implicit_object_type() {
1003        let mut schema = json!({
1004            "properties": {
1005                "name": { "type": "string" }
1006            }
1007        });
1008        add_implicit_object_type(&mut schema);
1009        assert_eq!(schema["type"], "object");
1010    }
1011
1012    #[test]
1013    fn test_add_implicit_object_type_no_op_when_type_present() {
1014        let mut schema = json!({
1015            "type": "object",
1016            "properties": {
1017                "name": { "type": "string" }
1018            }
1019        });
1020        let expected = schema.clone();
1021        add_implicit_object_type(&mut schema);
1022        assert_eq!(schema, expected);
1023    }
1024
1025    #[test]
1026    fn test_add_implicit_object_type_nested() {
1027        let mut schema = json!({
1028            "type": "object",
1029            "properties": {
1030                "nested": {
1031                    "properties": {
1032                        "field": { "type": "number" }
1033                    }
1034                }
1035            }
1036        });
1037        add_implicit_object_type(&mut schema);
1038        assert_eq!(schema["properties"]["nested"]["type"], "object");
1039    }
1040
1041    // --- convert_const_to_enum tests ---
1042
1043    #[test]
1044    fn test_convert_const_to_enum() {
1045        let mut schema = json!({
1046            "type": "string",
1047            "const": "fixed"
1048        });
1049        convert_const_to_enum(&mut schema);
1050        assert!(schema.get("const").is_none());
1051        assert_eq!(schema["enum"], json!(["fixed"]));
1052    }
1053
1054    #[test]
1055    fn test_convert_const_to_enum_null() {
1056        let mut schema = json!({
1057            "const": null
1058        });
1059        convert_const_to_enum(&mut schema);
1060        assert!(schema.get("const").is_none());
1061        assert_eq!(schema["enum"], json!([null]));
1062    }
1063
1064    #[test]
1065    fn test_convert_const_to_enum_nested() {
1066        let mut schema = json!({
1067            "type": "object",
1068            "properties": {
1069                "status": {
1070                    "type": "string",
1071                    "const": "active"
1072                }
1073            }
1074        });
1075        convert_const_to_enum(&mut schema);
1076        assert_eq!(schema["properties"]["status"]["enum"], json!(["active"]));
1077    }
1078
1079    // --- strip_unsupported_formats tests ---
1080
1081    #[test]
1082    fn test_strip_unsupported_formats_removes_unsupported() {
1083        let mut schema = json!({
1084            "type": "string",
1085            "format": "hostname"
1086        });
1087        strip_unsupported_formats(&mut schema, &["date-time", "email"]);
1088        assert!(schema.get("format").is_none());
1089    }
1090
1091    #[test]
1092    fn test_strip_unsupported_formats_keeps_allowed() {
1093        let mut schema = json!({
1094            "type": "string",
1095            "format": "email"
1096        });
1097        strip_unsupported_formats(&mut schema, &["date-time", "email"]);
1098        assert_eq!(schema["format"], "email");
1099    }
1100
1101    #[test]
1102    fn test_strip_unsupported_formats_nested() {
1103        let mut schema = json!({
1104            "type": "object",
1105            "properties": {
1106                "created": { "type": "string", "format": "date-time" },
1107                "hostname": { "type": "string", "format": "hostname" }
1108            }
1109        });
1110        strip_unsupported_formats(&mut schema, &["date-time"]);
1111        assert_eq!(schema["properties"]["created"]["format"], "date-time");
1112        assert!(schema["properties"]["hostname"].get("format").is_none());
1113    }
1114
1115    // --- truncate_tool_name tests ---
1116
1117    #[test]
1118    fn test_truncate_tool_name_short() {
1119        let result = truncate_tool_name("short_name", 64);
1120        assert_eq!(result, "short_name");
1121        assert!(matches!(result, Cow::Borrowed(_)));
1122    }
1123
1124    #[test]
1125    fn test_truncate_tool_name_exact_boundary() {
1126        let name = "a".repeat(64);
1127        let result = truncate_tool_name(&name, 64);
1128        assert_eq!(result.len(), 64);
1129        assert!(matches!(result, Cow::Borrowed(_)));
1130    }
1131
1132    #[test]
1133    fn test_truncate_tool_name_over_limit() {
1134        let name = "a".repeat(100);
1135        let result = truncate_tool_name(&name, 64);
1136        assert_eq!(result.len(), 64);
1137        assert!(matches!(result, Cow::Owned(_)));
1138    }
1139
1140    #[test]
1141    fn test_truncate_tool_name_multibyte_boundary() {
1142        // "é" is 2 bytes in UTF-8. Create a string where byte 64 falls mid-character.
1143        let name = "a".repeat(63) + "é"; // 63 + 2 = 65 bytes
1144        let result = truncate_tool_name(&name, 64);
1145        // Should truncate to 63 bytes (before the multi-byte char)
1146        assert_eq!(result.len(), 63);
1147        assert!(result.is_char_boundary(result.len()));
1148    }
1149
1150    #[test]
1151    fn test_truncate_tool_name_emoji() {
1152        // "🎯" is 4 bytes. Create a string where byte 64 falls mid-emoji.
1153        let name = "a".repeat(62) + "🎯"; // 62 + 4 = 66 bytes
1154        let result = truncate_tool_name(&name, 64);
1155        // Should truncate to 62 bytes (before the emoji)
1156        assert_eq!(result.len(), 62);
1157    }
1158
1159    #[test]
1160    fn test_truncate_tool_name_empty() {
1161        let result = truncate_tool_name("", 64);
1162        assert_eq!(result, "");
1163        assert!(matches!(result, Cow::Borrowed(_)));
1164    }
1165
1166    // --- strip_null_from_enum tests ---
1167
1168    #[test]
1169    fn test_strip_null_from_enum() {
1170        let mut schema = json!({
1171            "type": "string",
1172            "enum": ["a", null, "b"]
1173        });
1174        strip_null_from_enum(&mut schema);
1175        assert_eq!(schema["enum"], json!(["a", "b"]));
1176    }
1177
1178    #[test]
1179    fn test_strip_null_from_enum_all_null() {
1180        let mut schema = json!({
1181            "type": "string",
1182            "enum": [null]
1183        });
1184        strip_null_from_enum(&mut schema);
1185        assert!(schema.get("enum").is_none());
1186    }
1187
1188    #[test]
1189    fn test_strip_null_from_enum_no_null() {
1190        let mut schema = json!({
1191            "type": "string",
1192            "enum": ["a", "b"]
1193        });
1194        let expected = schema.clone();
1195        strip_null_from_enum(&mut schema);
1196        assert_eq!(schema, expected);
1197    }
1198
1199    #[test]
1200    fn test_strip_null_from_enum_nested() {
1201        let mut schema = json!({
1202            "type": "object",
1203            "properties": {
1204                "status": {
1205                    "type": "string",
1206                    "enum": ["active", null, "inactive"]
1207                }
1208            }
1209        });
1210        strip_null_from_enum(&mut schema);
1211        assert_eq!(schema["properties"]["status"]["enum"], json!(["active", "inactive"]));
1212    }
1213
1214    // --- Recursion into combiners tests ---
1215
1216    #[test]
1217    fn test_recursion_into_any_of() {
1218        let mut schema = json!({
1219            "anyOf": [
1220                { "$schema": "draft-07", "type": "string" },
1221                { "$schema": "draft-07", "type": "number" }
1222            ]
1223        });
1224        strip_schema_keyword(&mut schema);
1225        assert!(schema["anyOf"][0].get("$schema").is_none());
1226        assert!(schema["anyOf"][1].get("$schema").is_none());
1227    }
1228
1229    #[test]
1230    fn test_recursion_into_all_of() {
1231        let mut schema = json!({
1232            "allOf": [
1233                { "properties": { "a": { "type": "string" } } },
1234                { "properties": { "b": { "type": "number" } } }
1235            ]
1236        });
1237        add_implicit_object_type(&mut schema);
1238        assert_eq!(schema["allOf"][0]["type"], "object");
1239        assert_eq!(schema["allOf"][1]["type"], "object");
1240    }
1241
1242    #[test]
1243    fn test_recursion_into_items() {
1244        let mut schema = json!({
1245            "type": "array",
1246            "items": {
1247                "$schema": "draft-07",
1248                "type": "string",
1249                "format": "hostname"
1250            }
1251        });
1252        strip_schema_keyword(&mut schema);
1253        strip_unsupported_formats(&mut schema, &["date-time"]);
1254        assert!(schema["items"].get("$schema").is_none());
1255        assert!(schema["items"].get("format").is_none());
1256    }
1257
1258    #[test]
1259    fn test_recursion_into_additional_properties() {
1260        let mut schema = json!({
1261            "type": "object",
1262            "additionalProperties": {
1263                "$schema": "draft-07",
1264                "type": "string"
1265            }
1266        });
1267        strip_schema_keyword(&mut schema);
1268        assert!(schema["additionalProperties"].get("$schema").is_none());
1269    }
1270
1271    #[test]
1272    fn test_recursion_into_not() {
1273        let mut schema = json!({
1274            "not": {
1275                "$schema": "draft-07",
1276                "type": "null"
1277            }
1278        });
1279        strip_schema_keyword(&mut schema);
1280        assert!(schema["not"].get("$schema").is_none());
1281    }
1282
1283    #[test]
1284    fn test_deeply_nested_recursion() {
1285        let mut schema = json!({
1286            "type": "object",
1287            "properties": {
1288                "level1": {
1289                    "type": "object",
1290                    "properties": {
1291                        "level2": {
1292                            "type": "object",
1293                            "properties": {
1294                                "level3": {
1295                                    "$schema": "draft-07",
1296                                    "type": "string",
1297                                    "const": "deep"
1298                                }
1299                            }
1300                        }
1301                    }
1302                }
1303            }
1304        });
1305        strip_schema_keyword(&mut schema);
1306        convert_const_to_enum(&mut schema);
1307        let deep = &schema["properties"]["level1"]["properties"]["level2"]["properties"]["level3"];
1308        assert!(deep.get("$schema").is_none());
1309        assert_eq!(deep["enum"], json!(["deep"]));
1310    }
1311
1312    // --- resolve_refs tests ---
1313
1314    #[test]
1315    fn test_resolve_refs_simple_definitions() {
1316        let mut defs = Map::new();
1317        defs.insert(
1318            "Address".to_string(),
1319            json!({"type": "object", "properties": {"street": {"type": "string"}}}),
1320        );
1321
1322        let mut schema = json!({
1323            "type": "object",
1324            "properties": {
1325                "home": { "$ref": "#/definitions/Address" }
1326            }
1327        });
1328
1329        resolve_refs(&mut schema, &defs, 0);
1330        assert_eq!(schema["properties"]["home"]["type"], "object");
1331        assert!(schema["properties"]["home"].get("$ref").is_none());
1332        assert_eq!(schema["properties"]["home"]["properties"]["street"]["type"], "string");
1333    }
1334
1335    #[test]
1336    fn test_resolve_refs_simple_defs_format() {
1337        let mut defs = Map::new();
1338        defs.insert("Name".to_string(), json!({"type": "string", "minLength": 1}));
1339
1340        let mut schema = json!({
1341            "type": "object",
1342            "properties": {
1343                "name": { "$ref": "#/$defs/Name" }
1344            }
1345        });
1346
1347        resolve_refs(&mut schema, &defs, 0);
1348        assert_eq!(schema["properties"]["name"]["type"], "string");
1349        assert_eq!(schema["properties"]["name"]["minLength"], 1);
1350        assert!(schema["properties"]["name"].get("$ref").is_none());
1351    }
1352
1353    #[test]
1354    fn test_resolve_refs_nested_refs() {
1355        let mut defs = Map::new();
1356        defs.insert("Inner".to_string(), json!({"type": "string"}));
1357        defs.insert(
1358            "Outer".to_string(),
1359            json!({
1360                "type": "object",
1361                "properties": {
1362                    "value": { "$ref": "#/definitions/Inner" }
1363                }
1364            }),
1365        );
1366
1367        let mut schema = json!({
1368            "type": "object",
1369            "properties": {
1370                "wrapper": { "$ref": "#/definitions/Outer" }
1371            }
1372        });
1373
1374        resolve_refs(&mut schema, &defs, 0);
1375        // Outer was inlined
1376        assert_eq!(schema["properties"]["wrapper"]["type"], "object");
1377        // Inner was also resolved within Outer
1378        assert_eq!(schema["properties"]["wrapper"]["properties"]["value"]["type"], "string");
1379        assert!(schema["properties"]["wrapper"]["properties"]["value"].get("$ref").is_none());
1380    }
1381
1382    #[test]
1383    fn test_resolve_refs_unresolvable_ref() {
1384        let defs = Map::new(); // empty definitions
1385
1386        let mut schema = json!({
1387            "type": "object",
1388            "properties": {
1389                "missing": { "$ref": "#/definitions/DoesNotExist" }
1390            }
1391        });
1392
1393        resolve_refs(&mut schema, &defs, 0);
1394        // Unresolvable ref replaced with {"type": "object"}
1395        assert_eq!(schema["properties"]["missing"], json!({"type": "object"}));
1396    }
1397
1398    #[test]
1399    fn test_resolve_refs_unsupported_ref_format() {
1400        let defs = Map::new();
1401
1402        let mut schema = json!({
1403            "type": "object",
1404            "properties": {
1405                "external": { "$ref": "https://example.com/schema.json" }
1406            }
1407        });
1408
1409        resolve_refs(&mut schema, &defs, 0);
1410        // Unsupported ref format replaced with {"type": "object"}
1411        assert_eq!(schema["properties"]["external"], json!({"type": "object"}));
1412    }
1413
1414    #[test]
1415    fn test_resolve_refs_circular_self_reference() {
1416        let mut defs = Map::new();
1417        defs.insert(
1418            "Node".to_string(),
1419            json!({
1420                "type": "object",
1421                "properties": {
1422                    "child": { "$ref": "#/definitions/Node" }
1423                }
1424            }),
1425        );
1426
1427        let mut schema = json!({ "$ref": "#/definitions/Node" });
1428
1429        resolve_refs(&mut schema, &defs, 0);
1430        // The schema should resolve but eventually hit depth limit
1431        assert_eq!(schema["type"], "object");
1432        // At some nesting level, the circular ref should be broken
1433        // Walk down the chain to verify termination
1434        let mut current = &schema;
1435        let mut found_termination = false;
1436        for _ in 0..15 {
1437            if let Some(child) = current.get("properties").and_then(|p| p.get("child")) {
1438                if child == &json!({"type": "object"}) {
1439                    found_termination = true;
1440                    break;
1441                }
1442                current = child;
1443            } else {
1444                found_termination = true;
1445                break;
1446            }
1447        }
1448        assert!(found_termination, "circular ref chain should terminate within depth limit");
1449    }
1450
1451    #[test]
1452    fn test_resolve_refs_mutual_circular_reference() {
1453        let mut defs = Map::new();
1454        defs.insert(
1455            "A".to_string(),
1456            json!({
1457                "type": "object",
1458                "properties": {
1459                    "b": { "$ref": "#/definitions/B" }
1460                }
1461            }),
1462        );
1463        defs.insert(
1464            "B".to_string(),
1465            json!({
1466                "type": "object",
1467                "properties": {
1468                    "a": { "$ref": "#/definitions/A" }
1469                }
1470            }),
1471        );
1472
1473        let mut schema = json!({ "$ref": "#/definitions/A" });
1474
1475        resolve_refs(&mut schema, &defs, 0);
1476        // Should terminate without stack overflow
1477        assert_eq!(schema["type"], "object");
1478    }
1479
1480    #[test]
1481    fn test_resolve_refs_depth_limit_exact() {
1482        // Starting at depth 11 with a $ref should replace with fallback
1483        let mut defs = Map::new();
1484        defs.insert("Foo".to_string(), json!({"type": "number"}));
1485
1486        let mut schema = json!({ "$ref": "#/definitions/Foo" });
1487
1488        resolve_refs(&mut schema, &defs, 11);
1489        // At depth > 10 with a $ref, it should be replaced with fallback
1490        assert_eq!(schema, json!({"type": "object"}));
1491    }
1492
1493    #[test]
1494    fn test_resolve_refs_depth_limit_no_ref_passthrough() {
1495        // Starting at depth 11 without a $ref should leave schema unchanged
1496        let defs = Map::new();
1497        let mut schema = json!({"type": "string", "minLength": 5});
1498        let expected = schema.clone();
1499
1500        resolve_refs(&mut schema, &defs, 11);
1501        assert_eq!(schema, expected);
1502    }
1503
1504    #[test]
1505    fn test_resolve_refs_depth_10_still_resolves() {
1506        let mut defs = Map::new();
1507        defs.insert("Foo".to_string(), json!({"type": "number"}));
1508
1509        let mut schema = json!({ "$ref": "#/definitions/Foo" });
1510
1511        // At depth 10, should still resolve (limit is > 10)
1512        resolve_refs(&mut schema, &defs, 10);
1513        assert_eq!(schema, json!({"type": "number"}));
1514    }
1515
1516    #[test]
1517    fn test_resolve_refs_in_array_items() {
1518        let mut defs = Map::new();
1519        defs.insert("Item".to_string(), json!({"type": "string"}));
1520
1521        let mut schema = json!({
1522            "type": "array",
1523            "items": { "$ref": "#/definitions/Item" }
1524        });
1525
1526        resolve_refs(&mut schema, &defs, 0);
1527        assert_eq!(schema["items"]["type"], "string");
1528        assert!(schema["items"].get("$ref").is_none());
1529    }
1530
1531    #[test]
1532    fn test_resolve_refs_in_any_of() {
1533        let mut defs = Map::new();
1534        defs.insert("Str".to_string(), json!({"type": "string"}));
1535        defs.insert("Num".to_string(), json!({"type": "number"}));
1536
1537        let mut schema = json!({
1538            "anyOf": [
1539                { "$ref": "#/definitions/Str" },
1540                { "$ref": "#/$defs/Num" }
1541            ]
1542        });
1543
1544        resolve_refs(&mut schema, &defs, 0);
1545        assert_eq!(schema["anyOf"][0], json!({"type": "string"}));
1546        assert_eq!(schema["anyOf"][1], json!({"type": "number"}));
1547    }
1548
1549    #[test]
1550    fn test_resolve_refs_no_ref_passthrough() {
1551        let defs = Map::new();
1552        let mut schema = json!({
1553            "type": "object",
1554            "properties": {
1555                "name": { "type": "string" },
1556                "age": { "type": "integer" }
1557            }
1558        });
1559        let expected = schema.clone();
1560
1561        resolve_refs(&mut schema, &defs, 0);
1562        assert_eq!(schema, expected);
1563    }
1564
1565    #[test]
1566    fn test_resolve_refs_both_definitions_and_defs() {
1567        // The function uses a single definitions map; both formats are looked up
1568        let mut defs = Map::new();
1569        defs.insert("FromDefs".to_string(), json!({"type": "boolean"}));
1570        defs.insert("FromDefinitions".to_string(), json!({"type": "integer"}));
1571
1572        let mut schema = json!({
1573            "type": "object",
1574            "properties": {
1575                "a": { "$ref": "#/$defs/FromDefs" },
1576                "b": { "$ref": "#/definitions/FromDefinitions" }
1577            }
1578        });
1579
1580        resolve_refs(&mut schema, &defs, 0);
1581        assert_eq!(schema["properties"]["a"], json!({"type": "boolean"}));
1582        assert_eq!(schema["properties"]["b"], json!({"type": "integer"}));
1583    }
1584
1585    // --- collapse_combiners tests ---
1586
1587    #[test]
1588    fn test_collapse_combiners_any_of_picks_first_non_null() {
1589        let mut schema = json!({
1590            "anyOf": [
1591                {"type": "null"},
1592                {"type": "string", "minLength": 1}
1593            ]
1594        });
1595        collapse_combiners(&mut schema);
1596        assert_eq!(schema["type"], "string");
1597        assert_eq!(schema["minLength"], 1);
1598        assert!(schema.get("anyOf").is_none());
1599    }
1600
1601    #[test]
1602    fn test_collapse_combiners_one_of_picks_first_non_null() {
1603        let mut schema = json!({
1604            "oneOf": [
1605                {"type": "null"},
1606                {"type": "integer", "minimum": 0}
1607            ]
1608        });
1609        collapse_combiners(&mut schema);
1610        assert_eq!(schema["type"], "integer");
1611        assert_eq!(schema["minimum"], 0);
1612        assert!(schema.get("oneOf").is_none());
1613    }
1614
1615    #[test]
1616    fn test_collapse_combiners_all_null_uses_first() {
1617        let mut schema = json!({
1618            "anyOf": [
1619                {"type": "null"},
1620                {"type": "null"}
1621            ]
1622        });
1623        collapse_combiners(&mut schema);
1624        assert_eq!(schema["type"], "null");
1625        assert!(schema.get("anyOf").is_none());
1626    }
1627
1628    #[test]
1629    fn test_collapse_combiners_no_null() {
1630        let mut schema = json!({
1631            "anyOf": [
1632                {"type": "string"},
1633                {"type": "number"}
1634            ]
1635        });
1636        collapse_combiners(&mut schema);
1637        assert_eq!(schema["type"], "string");
1638        assert!(schema.get("anyOf").is_none());
1639    }
1640
1641    #[test]
1642    fn test_collapse_combiners_nested() {
1643        let mut schema = json!({
1644            "type": "object",
1645            "properties": {
1646                "field": {
1647                    "oneOf": [
1648                        {"type": "null"},
1649                        {"type": "boolean"}
1650                    ]
1651                }
1652            }
1653        });
1654        collapse_combiners(&mut schema);
1655        assert_eq!(schema["properties"]["field"]["type"], "boolean");
1656        assert!(schema["properties"]["field"].get("oneOf").is_none());
1657    }
1658
1659    #[test]
1660    fn test_collapse_combiners_preserves_existing_fields() {
1661        let mut schema = json!({
1662            "description": "A nullable string",
1663            "anyOf": [
1664                {"type": "null"},
1665                {"type": "string", "maxLength": 100}
1666            ]
1667        });
1668        collapse_combiners(&mut schema);
1669        assert_eq!(schema["description"], "A nullable string");
1670        assert_eq!(schema["type"], "string");
1671        assert_eq!(schema["maxLength"], 100);
1672    }
1673
1674    // --- merge_all_of tests ---
1675
1676    #[test]
1677    fn test_merge_all_of_combines_properties() {
1678        let mut schema = json!({
1679            "allOf": [
1680                {"type": "object", "properties": {"a": {"type": "string"}}},
1681                {"properties": {"b": {"type": "number"}}}
1682            ]
1683        });
1684        merge_all_of(&mut schema);
1685        assert!(schema.get("allOf").is_none());
1686        assert_eq!(schema["properties"]["a"]["type"], "string");
1687        assert_eq!(schema["properties"]["b"]["type"], "number");
1688    }
1689
1690    #[test]
1691    fn test_merge_all_of_combines_required() {
1692        let mut schema = json!({
1693            "allOf": [
1694                {"required": ["a", "b"]},
1695                {"required": ["b", "c"]}
1696            ]
1697        });
1698        merge_all_of(&mut schema);
1699        let required = schema["required"].as_array().unwrap();
1700        assert!(required.contains(&json!("a")));
1701        assert!(required.contains(&json!("b")));
1702        assert!(required.contains(&json!("c")));
1703        // No duplicates
1704        assert_eq!(required.len(), 3);
1705    }
1706
1707    #[test]
1708    fn test_merge_all_of_conflicting_type_prefers_object() {
1709        let mut schema = json!({
1710            "allOf": [
1711                {"type": "string"},
1712                {"type": "number"}
1713            ]
1714        });
1715        merge_all_of(&mut schema);
1716        assert_eq!(schema["type"], "object");
1717    }
1718
1719    #[test]
1720    fn test_merge_all_of_same_type_no_conflict() {
1721        let mut schema = json!({
1722            "allOf": [
1723                {"type": "object", "properties": {"a": {"type": "string"}}},
1724                {"type": "object", "properties": {"b": {"type": "number"}}}
1725            ]
1726        });
1727        merge_all_of(&mut schema);
1728        assert_eq!(schema["type"], "object");
1729    }
1730
1731    #[test]
1732    fn test_merge_all_of_nested() {
1733        let mut schema = json!({
1734            "type": "object",
1735            "properties": {
1736                "nested": {
1737                    "allOf": [
1738                        {"properties": {"x": {"type": "integer"}}},
1739                        {"properties": {"y": {"type": "integer"}}}
1740                    ]
1741                }
1742            }
1743        });
1744        merge_all_of(&mut schema);
1745        assert!(schema["properties"]["nested"].get("allOf").is_none());
1746        assert_eq!(schema["properties"]["nested"]["properties"]["x"]["type"], "integer");
1747        assert_eq!(schema["properties"]["nested"]["properties"]["y"]["type"], "integer");
1748    }
1749
1750    #[test]
1751    fn test_merge_all_of_other_fields() {
1752        let mut schema = json!({
1753            "allOf": [
1754                {"type": "object", "description": "First"},
1755                {"title": "Second"}
1756            ]
1757        });
1758        merge_all_of(&mut schema);
1759        assert_eq!(schema["description"], "First");
1760        assert_eq!(schema["title"], "Second");
1761    }
1762
1763    // --- collapse_type_arrays tests ---
1764
1765    #[test]
1766    fn test_collapse_type_arrays_string_null() {
1767        let mut schema = json!({"type": ["string", "null"]});
1768        collapse_type_arrays(&mut schema);
1769        assert_eq!(schema["type"], "string");
1770    }
1771
1772    #[test]
1773    fn test_collapse_type_arrays_null_first() {
1774        let mut schema = json!({"type": ["null", "integer"]});
1775        collapse_type_arrays(&mut schema);
1776        assert_eq!(schema["type"], "integer");
1777    }
1778
1779    #[test]
1780    fn test_collapse_type_arrays_all_null() {
1781        let mut schema = json!({"type": ["null"]});
1782        collapse_type_arrays(&mut schema);
1783        assert_eq!(schema["type"], "null");
1784    }
1785
1786    #[test]
1787    fn test_collapse_type_arrays_single_non_null() {
1788        let mut schema = json!({"type": ["boolean"]});
1789        collapse_type_arrays(&mut schema);
1790        assert_eq!(schema["type"], "boolean");
1791    }
1792
1793    #[test]
1794    fn test_collapse_type_arrays_already_string() {
1795        let mut schema = json!({"type": "string"});
1796        let expected = schema.clone();
1797        collapse_type_arrays(&mut schema);
1798        assert_eq!(schema, expected);
1799    }
1800
1801    #[test]
1802    fn test_collapse_type_arrays_nested() {
1803        let mut schema = json!({
1804            "type": "object",
1805            "properties": {
1806                "field": {"type": ["number", "null"]}
1807            }
1808        });
1809        collapse_type_arrays(&mut schema);
1810        assert_eq!(schema["properties"]["field"]["type"], "number");
1811    }
1812
1813    #[test]
1814    fn test_collapse_type_arrays_multiple_non_null() {
1815        let mut schema = json!({"type": ["string", "number", "null"]});
1816        collapse_type_arrays(&mut schema);
1817        // Picks the first non-null
1818        assert_eq!(schema["type"], "string");
1819    }
1820
1821    // --- enforce_nesting_depth tests ---
1822
1823    #[test]
1824    fn test_enforce_nesting_depth_within_limit() {
1825        let mut schema = json!({
1826            "type": "object",
1827            "properties": {
1828                "name": {"type": "string"}
1829            }
1830        });
1831        let expected = schema.clone();
1832        enforce_nesting_depth(&mut schema, 5, 0);
1833        assert_eq!(schema, expected);
1834    }
1835
1836    #[test]
1837    fn test_enforce_nesting_depth_at_limit() {
1838        let mut schema = json!({
1839            "type": "object",
1840            "properties": {
1841                "deep": {
1842                    "type": "object",
1843                    "properties": {
1844                        "deeper": {"type": "string"}
1845                    }
1846                }
1847            }
1848        });
1849        enforce_nesting_depth(&mut schema, 1, 0);
1850        // The root is at depth 0 (object), so next_depth = 1
1851        // "deep" is an object at depth 1 which equals max_depth, so it gets truncated
1852        assert_eq!(schema["properties"]["deep"], json!({"type": "object"}));
1853    }
1854
1855    #[test]
1856    fn test_enforce_nesting_depth_exceeds_limit() {
1857        let mut schema = json!({
1858            "type": "object",
1859            "properties": {
1860                "level1": {
1861                    "type": "object",
1862                    "properties": {
1863                        "level2": {
1864                            "type": "object",
1865                            "properties": {
1866                                "level3": {"type": "string"}
1867                            }
1868                        }
1869                    }
1870                }
1871            }
1872        });
1873        enforce_nesting_depth(&mut schema, 2, 0);
1874        // Root at depth 0, level1 at depth 1, level2 at depth 2 (== max_depth) → truncated
1875        assert_eq!(
1876            schema["properties"]["level1"]["properties"]["level2"],
1877            json!({"type": "object"})
1878        );
1879    }
1880
1881    #[test]
1882    fn test_enforce_nesting_depth_non_object_not_counted() {
1883        let mut schema = json!({
1884            "type": "object",
1885            "properties": {
1886                "arr": {
1887                    "type": "array",
1888                    "items": {
1889                        "type": "object",
1890                        "properties": {
1891                            "name": {"type": "string"}
1892                        }
1893                    }
1894                }
1895            }
1896        });
1897        enforce_nesting_depth(&mut schema, 2, 0);
1898        // Root at depth 0 (object, next=1), arr is array (not object, next stays 1),
1899        // items is object at depth 1 (next=2), name is string — no truncation at depth 2
1900        assert_eq!(schema["properties"]["arr"]["items"]["properties"]["name"]["type"], "string");
1901    }
1902
1903    #[test]
1904    fn test_enforce_nesting_depth_gemini_5_levels() {
1905        // Simulate Gemini's 5-level limit
1906        let mut schema = json!({
1907            "type": "object",
1908            "properties": {
1909                "l1": {
1910                    "type": "object",
1911                    "properties": {
1912                        "l2": {
1913                            "type": "object",
1914                            "properties": {
1915                                "l3": {
1916                                    "type": "object",
1917                                    "properties": {
1918                                        "l4": {
1919                                            "type": "object",
1920                                            "properties": {
1921                                                "l5": {
1922                                                    "type": "object",
1923                                                    "properties": {
1924                                                        "deep": {"type": "string"}
1925                                                    }
1926                                                }
1927                                            }
1928                                        }
1929                                    }
1930                                }
1931                            }
1932                        }
1933                    }
1934                }
1935            }
1936        });
1937        enforce_nesting_depth(&mut schema, 5, 0);
1938        // l5 is at depth 5 (root=0, l1=1, l2=2, l3=3, l4=4, l5=5) → truncated
1939        assert_eq!(
1940            schema["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]["l4"]
1941                ["properties"]["l5"],
1942            json!({"type": "object"})
1943        );
1944        // l4 should still have its properties
1945        assert!(
1946            schema["properties"]["l1"]["properties"]["l2"]["properties"]["l3"]["properties"]["l4"]
1947                .get("properties")
1948                .is_some()
1949        );
1950    }
1951
1952    #[test]
1953    fn test_enforce_nesting_depth_zero_truncates_root_object() {
1954        let mut schema = json!({
1955            "type": "object",
1956            "properties": {"a": {"type": "string"}}
1957        });
1958        enforce_nesting_depth(&mut schema, 0, 0);
1959        assert_eq!(schema, json!({"type": "object"}));
1960    }
1961
1962    // --- is_null_schema tests ---
1963
1964    #[test]
1965    fn test_is_null_schema_true() {
1966        assert!(is_null_schema(&json!({"type": "null"})));
1967    }
1968
1969    #[test]
1970    fn test_is_null_schema_false_for_string() {
1971        assert!(!is_null_schema(&json!({"type": "string"})));
1972    }
1973
1974    #[test]
1975    fn test_is_null_schema_false_for_non_object() {
1976        assert!(!is_null_schema(&json!("null")));
1977        assert!(!is_null_schema(&Value::Null));
1978    }
1979}